Durante muchos años, la clásica pirámide de pruebas de Mike Cohn (2009) se mantuvo como el estándar de facto para la distribución de esfuerzos: una enorme base de pruebas unitarias aisladas (Unit), una capa moderada de verificaciones de integración y un volumen mínimo de escenarios de extremo a extremo (End-to-End) en la cúspide. Sin embargo, en el desarrollo web moderno de 2026 con arquitecturas de componentes, React 19, Server Actions y compiladores estrictos, este modelo demuestra una ineficiencia sistémica.
Las pruebas unitarias aisladas de componentes, donde cada dependencia externa (hooks, cliente de red, router, contextos) se sustituye por mocks ficticios, evalúan exclusivamente los detalles de implementación (Implementation Details). Modificar la estructura interna de un componente rompe estas pruebas a pesar de mantener idéntico el comportamiento externo (Falsos Positivos), mientras que los defectos de integración reales —unión incorrecta de componentes, desajustes en contratos de red, rotura del modelo de eventos del DOM— pasan inadvertidos directamente a producción (Falsos Negativos).
El modelo Testing Trophy redefine el equilibrio entre el coste de mantenimiento de las pruebas (Cost) y el grado de certidumbre en la estabilidad de cada despliegue (Confidence).
+---------------------------------------+
| End-to-End | <- Alta confianza (Confidence),
| (Playwright / Cypress) | pero alto coste y CI lento
+---------------------------------------+
| |
| INTEGRATION | <- El punto óptimo (The Sweet Spot):
| (Vitest + React Testing Lib) | máximo ROI, DOM real,
| | interceptores de red (MSW)
+---------------------------------------+
| Unit | <- Funciones puras, utilidades,
| (Vitest) | matemáticas, máquinas de estado complejas
+---------------------------------------+
| STATIC ANALYSIS | <- Cero coste en runtime:
| (TypeScript Strict, ESLint, Oxlint)| tipos, contratos, sintaxis
+---------------------------------------+tsc --noEmit y linters nativos (oxlint) en lugar de suites pesadas.El análisis estático en 2026 constituye la primera línea defensiva de la calidad del software. El modo estricto de TypeScript (strict: true) junto con las banderas modernas del compilador elimina hasta un 40% de los errores potenciales en tiempo de ejecución (null/undefined, discrepancias estructurales, errores tipográficos en claves de objetos).
{
"compilerOptions": {
"target": "ES2023",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noImplicitOverride": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"useUnknownInCatchVariables": true,
"skipLibCheck": true,
"isolatedModules": true
}
}La bandera noUncheckedIndexedAccess: true obliga al desarrollador a gestionar explícitamente los accesos indefinidos a índices de arrays y diccionarios (infiriendo el tipo T | undefined), erradicando la clase de errores TypeError: Cannot read properties of undefined antes incluso de ejecutar los tests.
Las pruebas unitarias aportan un valor excepcional en algoritmos puros, lógica de dominio sin efectos secundarios externos, parsers, calculadoras financieras y máquinas de estado finito donde el DOM y la red son inexistentes.
import { describe, it, expect } from 'vitest';
export interface CartItem {
id: string;
priceInCents: number;
quantity: number;
category: 'electronics' | 'groceries' | 'apparel';
}
export interface DiscountPolicy {
bulkThreshold: number;
bulkDiscountPercentage: number;
}
export function calculateOrderTotal(
items: readonly CartItem[],
policy: DiscountPolicy
): number {
return items.reduce((total, item) => {
const rawCost = item.priceInCents * item.quantity;
if (item.quantity >= policy.bulkThreshold) {
const discount = Math.round(rawCost * (policy.bulkDiscountPercentage / 100));
return total + (rawCost - discount);
}
return total + rawCost;
}, 0);
}
describe('calculateOrderTotal (Pure Unit Logic)', () => {
const policy: DiscountPolicy = {
bulkThreshold: 5,
bulkDiscountPercentage: 10,
};
it('aplica el descuento por volumen al alcanzar el umbral estipulado', () => {
const items: readonly CartItem[] = [
{ id: '1', priceInCents: 1000, quantity: 5, category: 'apparel' },
];
// 5 * 1000 = 5000; descuento 10% = 500; total = 4500
expect(calculateOrderTotal(items, policy)).toBe(4500);
});
it('no aplica descuento si la cantidad es estrictamente inferior al umbral', () => {
const items: readonly CartItem[] = [
{ id: '2', priceInCents: 1000, quantity: 4, category: 'apparel' },
];
expect(calculateOrderTotal(items, policy)).toBe(4000);
});
it('gestiona correctamente un carrito vacío', () => {
expect(calculateOrderTotal([], policy)).toBe(0);
});
});Aquí no interviene ningún mock (vi.mock), el test se ejecuta en escasos 2 milisegundos y proporciona un 100% de confianza sobre el algoritmo matemático.
Consideremos un componente de carrito de compras CartCheckout. Con el enfoque heredado, los desarrolladores mockeaban los componentes hijos CartSummary, la llamada de red hacia la pasarela y el hook de gestión de estado, reduciendo la prueba a verificar si se llamaban ciertas funciones con determinados parámetros.
Bajo la estrategia del Testing Trophy, renderizamos el árbol del componente íntegramente utilizando un DOM real (mediante happy-dom), interceptamos las peticiones HTTP con Mock Service Worker (MSW) y emulamos las acciones del usuario con @testing-library/user-event.
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import React, { useState } from 'react';
// Interfaz de datos auténtica
interface OrderRequest {
couponCode: string;
itemsCount: number;
}
// Handlers de red de MSW v2
const server = setupServer(
http.post('/api/checkout', async ({ request }) => {
const body = (await request.json()) as OrderRequest;
if (body.couponCode === 'INVALID2026') {
return HttpResponse.json(
{ error: 'El cupón no es válido o ha expirado' },
{ status: 422 }
);
}
return HttpResponse.json({ orderId: 'ORD-98765', status: 'confirmed' });
})
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Componente bajo test: verificamos la interacción de formulario, estado local y respuesta de red
function CartCheckoutForm({ itemsCount }: { itemsCount: number }) {
const [coupon, setCoupon] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setStatus('loading');
setErrorMessage('');
try {
const res = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ couponCode: coupon, itemsCount }),
});
if (!res.ok) {
const err = (await res.json()) as { error: string };
setErrorMessage(err.error);
setStatus('error');
return;
}
setStatus('success');
} catch {
setErrorMessage('Error de red al conectar con el servidor');
setStatus('error');
}
};
return (
<form onSubmit={handleSubmit} aria-label="Formulario de confirmación de pedido">
<label htmlFor="coupon-input">Cupón de descuento</label>
<input
id="coupon-input"
type="text"
value={coupon}
onChange={(e) => setCoupon(e.target.value)}
/>
<button type="submit" disabled={status === 'loading'}>
{status === 'loading' ? 'Procesando...' : 'Confirmar pedido'}
</button>
{status === 'success' && <p role="status">¡Pedido confirmado con éxito!</p>}
{status === 'error' && <p role="alert">{errorMessage}</p>}
</form>
);
}
describe('Integración: Tramitación de pedido y validación de cupón', () => {
it('muestra el mensaje de error si el cupón introducido es inválido', async () => {
const user = userEvent.setup();
render(<CartCheckoutForm itemsCount={3} />);
const input = screen.getByLabelText(/cupón de descuento/i);
const submitBtn = screen.getByRole('button', { name: /confirmar pedido/i });
await user.type(input, 'INVALID2026');
await user.click(submitBtn);
const errorAlert = await screen.findByRole('alert');
expect(errorAlert).toHaveTextContent('El cupón no es válido o ha expirado');
});
it('transiciona al estado de éxito cuando la solicitud es válida', async () => {
const user = userEvent.setup();
render(<CartCheckoutForm itemsCount={3} />);
const input = screen.getByLabelText(/cupón de descuento/i);
const submitBtn = screen.getByRole('button', { name: /confirmar pedido/i });
await user.type(input, 'SUMMER2026');
await user.click(submitBtn);
const successMessage = await screen.findByRole('status');
expect(successMessage).toHaveTextContent('¡Pedido confirmado con éxito!');
});
});Este test de integración verifica la interacción real del usuario, el envío del formulario, la serialización JSON, la respuesta HTTP adecuada y el renderizado accesible (mediante roles ARIA role="alert" y role="status"). La arquitectura subyacente puede refactorizarse libremente (por ejemplo, migrando a useActionState), y el test seguirá pasando con total fidelidad.
Un error habitual consiste en aplicar vi.mock('./ComplexDropdown'), sustituyendo componentes hijos por simples div. Esto priva al test de verificar la integración auténtica de props, eventos y sincronización de estado.
// ❌ Antipatrón: el test evalúa una simulación ficticia y no el comportamiento real
vi.mock('./CartItemRow', () => ({
CartItemRow: () => <div data-testid="mock-row" />
}));
// ✅ Solución recomendada: renderizar el árbol de componentes completo
// Las dependencias de red o de contexto se mockean en los límites (MSW, Store Provider),
// mientras que los componentes del árbol interactúan de forma nativa.
render(<CartList items={sampleItems} />);
expect(screen.getAllByRole('listitem')).toHaveLength(sampleItems.length);La métrica del 100% de cobertura de líneas estimula la creación de tests para getters triviales, reducers simples y constantes estáticas. Esto incrementa de forma desproporcionada el coste de mantenimiento (Cost) sin aportar certeza real (Confidence).
// ❌ Antipatrón: test redundante que no aporta confianza en la lógica de negocio
it('retorna el estado inicial por defecto', () => {
expect(initialState.theme).toBe('light'); // Testea una constante
});
// ✅ Solución recomendada: focalizarse en casos de fallo y flujos críticos de usuario
it('alterna el tema visual y persiste la preferencia en almacenamiento local', async () => {
const user = userEvent.setup();
render(<ThemeToggle />);
await user.click(screen.getByRole('button', { name: /cambiar tema/i }));
expect(localStorage.getItem('user-theme')).toBe('dark');
});Ejecutar 50 variantes de un test E2E en Playwright para comprobar cada caso frontera de validación de un formulario (campo vacío, longitud máxima, caracteres inválidos) ralentiza los pipelines de CI en decenas de minutos.
// ❌ Antipatrón: 20 tests pesados de navegador E2E para comprobar cada error tipográfico
test('E2E: validación de formato de email con 15 entradas erróneas', async ({ page }) => {
// Inicialización de navegador, latencias de red, navegación
});
// ✅ Solución recomendada: validación a nivel de integración con Vitest (milisegundos)
// En Playwright se conserva únicamente un Happy Path transversal y un flujo negativo crítico.Desactivar la verificación de TypeScript en archivos de test (// @ts-ignore o uso indiscriminado de any) provoca que los tests diverjan del contrato real de la aplicación a medida que las interfaces evolucionan.
// ❌ Antipatrón: enmascarar propiedades inválidas con tipos inseguros
// @ts-expect-error silenciar discrepancias estructurales
render(<UserProfile user={{ id: 1, name: 'Alice' } as any} />);
// ✅ Solución recomendada: factorías de datos de prueba con tipado 100% estricto
const mockUser: User = createTestUser({ name: 'Alice' });
render(<UserProfile user={mockUser} />);Definir tipos estrictos de entrada y salida con TypeScript. Emplear Zod o ArkType para validar los límites en tiempo de ejecución.
Si el módulo incluye funciones puras, parsers matemáticos o transformaciones de estructuras de datos, implementar tests unitarios veloces y aislados.
Montar el componente con sus componentes hijos en Vitest + RTL. Configurar los escenarios de red con MSW v2 y verificar mediante roles ARIA.
Escribir un único escenario E2E con Playwright para asegurar el ciclo de vida completo en un navegador real (Smoke test de valor de negocio).