Estrategias de pruebas para aplicaciones web modernas
Guía práctica para una estrategia de pruebas que detecta errores reales: unitarias, integración y end-to-end con Vitest, Testing Library y Playwright.

Las pruebas son una herramienta de diseño, no una casilla que marcar
La mayoría de los equipos escriben pruebas principalmente para evitar regresiones. Eso es válido, pero pasa por alto el valor más profundo. Una suite de pruebas bien diseñada te obliga a escribir código modular y desacoplado. Hace que refactorizar sea seguro. Y documenta el comportamiento esperado con más honestidad de lo que cualquier comentario podría.
El problema es que las estrategias de pruebas mal planteadas producen suites lentas, frágiles y que generan una falsa sensación de seguridad. Esta guía trata sobre cómo construir la cobertura correcta: la que detecta errores reales sin pelear contigo en cada merge.
El trofeo de pruebas, no la pirámide
Seguramente conoces la pirámide de pruebas: muchas pruebas unitarias en la base, menos pruebas de integración en el medio y un mínimo de pruebas end-to-end en la cima. Esa pirámide se diseñó para otra época.
Para las aplicaciones web modernas —especialmente las arquitecturas basadas en React y APIs—, el trofeo de pruebas describe mejor la realidad:
- Análisis estático (TypeScript, ESLint): elimina categorías enteras de errores sin costo adicional
- Pruebas unitarias: lógica pura, utilidades, funciones aisladas
- Pruebas de integración: el grueso de tu suite —componentes con comportamiento real, manejadores de API con una base de datos real—
- Pruebas end-to-end: los flujos críticos que un usuario real realmente recorre
La mayoría de los equipos invierte poco en pruebas de integración y demasiado en pruebas unitarias estrechas que sobreviven a los refactors pero no detectan fallos sistémicos.
Configurando Vitest en un proyecto Next.js
Vitest es compatible con Jest, nativo de ESM y notablemente más rápido. Es la opción por defecto correcta para proyectos modernos de TypeScript.
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
exclude: ["node_modules/", "src/test/", "**/*.d.ts"],
},
},
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
});// src/test/setup.ts
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
afterEach(() => {
cleanup();
});
// Mock next/navigation globally — avoids router errors in component tests
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));Pruebas unitarias: acotadas, rápidas y deterministas
Las pruebas unitarias brillan con la lógica pura: transformaciones de datos, reglas de validación, funciones utilitarias. El objetivo es el aislamiento total: sin red, sin base de datos, sin DOM.
// utils/format.ts
export function formatCurrency(amount: number, locale = "en-US", currency = "USD"): string {
return new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);
}
export function truncate(str: string, maxLength: number): string {
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + "...";
}// utils/format.test.ts
import { describe, it, expect } from "vitest";
import { formatCurrency, truncate } from "./format";
describe("formatCurrency", () => {
it("formats USD by default", () => {
expect(formatCurrency(1234.5)).toBe("$1,234.50");
});
it("handles zero", () => {
expect(formatCurrency(0)).toBe("$0.00");
});
it("supports other currencies", () => {
expect(formatCurrency(100, "de-DE", "EUR")).toBe("100,00 €");
});
});
describe("truncate", () => {
it("returns string unchanged when within limit", () => {
expect(truncate("hello", 10)).toBe("hello");
});
it("truncates and appends ellipsis", () => {
expect(truncate("hello world", 8)).toBe("hello...");
});
it("handles exact boundary", () => {
expect(truncate("hello", 5)).toBe("hello");
});
});Mantén tus pruebas unitarias DAMP (Descriptive And Meaningful Phrases) y no excesivamente DRY: una prueba unitaria que falla debería decirte exactamente qué se rompió sin obligarte a rastrear una cadena de abstracciones.
Pruebas de integración: donde vive la confianza
Las pruebas de integración renderizan componentes con sus dependencias reales, disparan eventos reales y verifican resultados reales. Aquí es donde Testing Library resulta indispensable.
// components/LoginForm.test.tsx
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { LoginForm } from "./LoginForm";
// Mock only the network boundary — everything else is real
vi.mock("@/services/auth", () => ({
signIn: vi.fn(),
}));
import { signIn } from "@/services/auth";
describe("LoginForm", () => {
const user = userEvent.setup();
beforeEach(() => {
vi.clearAllMocks();
});
it("renders email and password fields", () => {
render(<LoginForm />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
});
it("disables submit while request is in flight", async () => {
vi.mocked(signIn).mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 500))
);
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), "user@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
expect(screen.getByRole("button", { name: /signing in/i })).toBeDisabled();
});
it("shows a field-level error for invalid email", async () => {
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), "not-an-email");
await user.tab(); // trigger blur validation
expect(await screen.findByText(/enter a valid email/i)).toBeInTheDocument();
});
it("redirects to dashboard on success", async () => {
const mockPush = vi.fn();
vi.mocked(signIn).mockResolvedValueOnce({ success: true });
// Provide router mock at the test level for more control
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: mockPush }),
}));
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), "user@example.com");
await user.type(screen.getByLabelText(/password/i), "password123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/dashboard");
});
});
});Principio clave: consulta por rol y por etiqueta, no por clase CSS ni por test ID. Las pruebas que interactúan como lo haría un usuario sobreviven a los refactors de interfaz; las que consultan .btn-primary no.
Cómo probar los manejadores de rutas de la API
Los manejadores del App Router de Next.js son simples funciones async, por lo que son fáciles de probar sin necesidad de levantar un servidor.
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const post = await db.post.findUnique({ where: { id: params.id } });
if (!post) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(post);
}// app/api/posts/[id]/route.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./route";
vi.mock("@/lib/db", () => ({
db: {
post: {
findUnique: vi.fn(),
},
},
}));
import { db } from "@/lib/db";
describe("GET /api/posts/:id", () => {
beforeEach(() => vi.clearAllMocks());
it("returns the post when found", async () => {
const mockPost = { id: "1", title: "Hello", content: "World" };
vi.mocked(db.post.findUnique).mockResolvedValueOnce(mockPost);
const request = new Request("http://localhost/api/posts/1");
const response = await GET(request, { params: { id: "1" } });
const body = await response.json();
expect(response.status).toBe(200);
expect(body).toEqual(mockPost);
});
it("returns 404 when post does not exist", async () => {
vi.mocked(db.post.findUnique).mockResolvedValueOnce(null);
const request = new Request("http://localhost/api/posts/99");
const response = await GET(request, { params: { id: "99" } });
expect(response.status).toBe(404);
});
});Pruebas end-to-end con Playwright
Las pruebas E2E se ejecutan contra un navegador real con una base de datos real (o previamente sembrada). Son lentas y deben usarse con moderación: resérvalas para los flujos de usuario que, de romperse, causarían el mayor daño.
// tests/e2e/auth.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Authentication", () => {
test.beforeEach(async ({ page }) => {
// Seed a test user via API before each test
await fetch("http://localhost:3000/api/test/seed", { method: "POST" });
await page.goto("/login");
});
test("user can log in with valid credentials", async ({ page }) => {
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Password").fill("TestPassword123!");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading", { name: /welcome/i })).toBeVisible();
});
test("shows error for invalid credentials", async ({ page }) => {
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Password").fill("wrong-password");
await page.getByRole("button", { name: "Sign in" }).click();
await expect(page.getByText(/invalid credentials/i)).toBeVisible();
await expect(page).toHaveURL("/login");
});
test("redirects unauthenticated users to login", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
});
});// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "Mobile Safari", use: { ...devices["iPhone 14"] } },
],
webServer: {
command: "bun run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});Cómo estructurar las pruebas para que sean mantenibles
Un archivo de pruebas imposible de leer es casi tan malo como no tener pruebas. Algunas convenciones que ayudan:
// ✅ Structure: Arrange → Act → Assert, every time
it("calculates total with discount applied", () => {
// Arrange
const cart = [
{ id: "1", price: 100, quantity: 2 },
{ id: "2", price: 50, quantity: 1 },
];
const discount = 0.1; // 10%
// Act
const total = calculateCartTotal(cart, discount);
// Assert
expect(total).toBe(225); // (100*2 + 50) * 0.9
});
// ✅ Test behavior, not implementation
// ❌ Bad: tests internal state
it("sets isLoading to true when fetching", () => {
const { result } = renderHook(() => useData());
expect(result.current.isLoading).toBe(true); // brittle — tied to internal naming
});
// ✅ Good: tests observable behavior
it("shows a loading indicator while data is fetching", async () => {
render(<DataTable />);
expect(screen.getByRole("status", { name: /loading/i })).toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.queryByRole("status"));
});Integración en CI
Las pruebas solo aportan valor si se ejecutan automáticamente. Un pipeline de CI mínimo para un proyecto Next.js:
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main]
pull_request:
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run typecheck
- run: bun run lint
- run: bun run test --coverage
e2e:
runs-on: ubuntu-latest
needs: unit
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bunx playwright install --with-deps chromium
- run: bun run build
- run: bunx playwright test
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/Las pruebas E2E se ejecutan después de que pasan las pruebas unitarias: no tiene sentido probar el flujo completo del navegador si el build ya está roto.
Qué significa realmente tener buena cobertura
Los porcentajes de cobertura son una métrica de vanidad. Un 90% de cobertura con pruebas que solo verifican expect(component).toBeTruthy() no vale nada.
Una estrategia de pruebas saludable se hace mejores preguntas:
- ¿Puedo borrar el código de esta funcionalidad y que una prueba falle? Si no es así, la prueba no está probando la funcionalidad.
- ¿Falla una prueba cuando introduzco un bug real? Ejecuta pruebas de mutación con Stryker periódicamente para comprobarlo.
- ¿Cuánto tarda la suite completa? Menos de 30 segundos para unitarias/integración implica que los desarrolladores realmente la ejecutarán.
- ¿Los fallos son accionables? Una prueba que falla debería nombrar el comportamiento roto, no limitarse a decir "snapshot mismatch".
Una suite de pruebas en la que los desarrolladores confían —y que ejecutan localmente antes de cada commit— detecta errores antes, acelera el code review y hace que refactorizar sea mucho menos aterrador. Ese es el verdadero retorno de la inversión.


