Saltar al contenido

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.

7 min de lectura
Terminal mostrando una suite de pruebas en verde, con resultados rápidos

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.

tstypescript
// 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"),
    },
  },
});
tstypescript
// 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.

tstypescript
// 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) + "...";
}
tstypescript
// 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.

tstypescript
// 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.

tstypescript
// 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);
}
tstypescript
// 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.

tstypescript
// 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/);
  });
});
tstypescript
// 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:

tstypescript
// ✅ 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:

ymlyaml
# .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.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX