Estrategias modernas de pruebas: de unitarias a extremo a extremo

Todo equipo de software escribe pruebas. No todo equipo escribe pruebas que eviten errores, sobrevivan a las refactorizaciones y den confianza para desplegar un viernes por la tarde. La diferencia entre las pruebas que ayudan y las que estorban no está en el marco de trabajo ni en el porcentaje de cobertura. Está en la estrategia: saber qué probar, a qué nivel y con qué compensación.

El enfoque por defecto de muchos equipos es escribir pruebas unitarias para todo y darlo por hecho. El informe de cobertura marca un 90 por ciento, la integración continua pasa y todo el mundo se siente bien, hasta que un cambio en una función rompe tres funcionalidades que ninguna prueba unitaria detectó. El problema no es falta de disciplina, sino probar en el nivel equivocado. Un componente que enlaza cinco servicios puede tener el 100 por cien de cobertura unitaria y aun así fallar en producción, porque la interacción entre esos servicios nunca se validó.

Esta guía recorre todo el espectro de estrategias modernas —desde pruebas unitarias rápidas y aisladas hasta pruebas de extremo a extremo lentas pero fiables— y explica cuándo cada una aporta valor y cuándo solo genera carga. El objetivo no es convencerte de escribir más pruebas, sino ayudarte a escribir pruebas que justifiquen su existencia: que atrapen errores que importan, corran lo bastante rápido para ejecutarse a menudo y sobrevivan a la inevitable reorganización de tu código.

Buenas prácticas en pruebas unitarias y qué conviene probar

Las pruebas unitarias son la base de casi toda estrategia porque son rápidas, deterministas y aisladas. Una prueba unitaria bien escrita corre en milisegundos, cubre un solo comportamiento lógico y no depende de sistemas externos como bases de datos, sistemas de archivos o APIs de red. Cuando falla, sabes exactamente qué pieza de lógica se rompió y por qué.

El error más común es probar detalles de implementación en vez de comportamiento. Comprobar que una función llama a cierto método interno o asigna un campo privado vuelve frágil la prueba: una refactorización que preserve el comportamiento externo la romperá igualmente. La prueba pasa de red de seguridad a lastre. Escribe pruebas que verifiquen la salida observable o los efectos secundarios de una función, no cómo llega a ese resultado.

Son buenos candidatos las funciones utilitarias puras, la lógica de negocio sin entrada/salida, la validación y el análisis sintáctico, las transformaciones de datos, los cálculos algorítmicos y las máquinas de estados. Son malos candidatos las funciones que hacen llamadas de red, los componentes que dibujan interfaz y todo código fuertemente acoplado a las interioridades del framework. Eso se prueba mejor en el nivel de integración o extremo a extremo.

import { describe, it, expect } from "vitest";
import { calculateDiscount, type Order } from "./pricing";

describe("calculateDiscount", () => {
  it("returns 0 for orders below the threshold", () => {
    const order: Order = {
      items: [{ price: 30, quantity: 1 }],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(0);
  });

  it("applies 10 percent for orders over $100", () => {
    const order: Order = {
      items: [
        { price: 80, quantity: 1 },
        { price: 40, quantity: 1 },
      ],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(12);
  });

  it("applies loyalty bonus for customers over 2 years", () => {
    const order: Order = {
      items: [{ price: 100, quantity: 1 }],
      customerSince: new Date("2022-06-01"),
    };

    expect(calculateDiscount(order)).toBe(15);
  });

  it("does not apply discount when items are on sale", () => {
    const order: Order = {
      items: [{ price: 200, quantity: 1, onSale: true }],
      customerSince: new Date("2025-01-01"),
    };

    expect(calculateDiscount(order)).toBe(0);
  });
});

El ejemplo anterior prueba cuatro comportamientos distintos de una misma función con entradas y salidas esperadas diferentes. Cada prueba es independiente, determinista y corre en menos de 10 milisegundos. Si cambia la lógica de precios, las pruebas dicen exactamente qué escenarios se ven afectados. Esa es la propuesta de valor de las pruebas unitarias: retroalimentación rápida sobre lógica pura.

  • Prueba comportamientos, no implementación. Verifica salidas y efectos secundarios, no llamadas internas ni estado privado.
  • Una afirmación lógica por caso. Una prueba debería fallar por exactamente un motivo, para que sea evidente qué se rompió.
  • Usa nombres descriptivos que se lean como frases. «devuelve 0 para pedidos por debajo del umbral» es mejor que «test_descuento_importe_bajo».
  • Evita el estado mutable compartido entre pruebas. Cada una debe preparar sus datos y limpiar tras de sí.
  • Mantén los valores de prueba simples y significativos. Usa datos realistas que reflejen objetos reales del dominio.

Pruebas de integración con dependencias reales

Las pruebas de integración se sitúan entre las unitarias y las de extremo a extremo en velocidad y alcance. Comprueban cómo colaboran varias unidades: un repositorio que llama a una base de datos real, un servicio que habla con un endpoint concreto o un componente que se dibuja con un almacén real. La diferencia clave con las unitarias es que usan dependencias reales o en contenedor en lugar de simulaciones.

Las pruebas de integración más valiosas comprueban que tu código interactúa correctamente con sistemas externos. Una prueba unitaria que simula la capa de datos te dice si la lógica de consulta es correcta en aislamiento, pero no si la consulta SQL real funciona contra el esquema real. Las pruebas de integración con Testcontainers o una base de datos de prueba detectan desajustes de esquema, violaciones de restricciones, errores en los límites de transacción y problemas de serialización que las unitarias no ven.

Brillan en capas de repositorio, manejadores de rutas de API, consumidores de colas de mensajes, migraciones y cualquier código que serialice o deserialice datos a través de una frontera. El precio es la velocidad —una prueba que levanta un contenedor de Postgres tarda segundos en lugar de milisegundos—, pero los errores que atrapa son proporcionalmente más caros de encontrar en producción.

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createDatabase, teardownDatabase } from "./test-utils";
import { OrderRepository } from "./order-repository";

describe("OrderRepository", () => {
  let db: Awaited<ReturnType<typeof createDatabase>>;
  let repo: OrderRepository;

  beforeAll(async () => {
    db = await createDatabase();
    repo = new OrderRepository(db);
  });

  afterAll(async () => {
    await teardownDatabase(db);
  });

  it("persists and retrieves an order", async () => {
    const order = {
      id: "ord_001",
      customerId: "cus_001",
      total: 1250,
      status: "pending" as const,
      createdAt: new Date("2025-06-01"),
    };

    await repo.save(order);
    const retrieved = await repo.findById("ord_001");

    expect(retrieved).toEqual(order);
  });

  it("returns null for a non-existent order", async () => {
    const result = await repo.findById("ord_nonexistent");
    expect(result).toBeNull();
  });

  it("updates order status", async () => {
    await repo.save({
      id: "ord_002",
      customerId: "cus_001",
      total: 500,
      status: "pending",
      createdAt: new Date(),
    });

    await repo.updateStatus("ord_002", "shipped");
    const updated = await repo.findById("ord_002");

    expect(updated?.status).toBe("shipped");
  });
});

La regla esencial es mantener aislado el estado de la base de datos entre ejecuciones. Cada suite debería crear su propio esquema o base, aplicar migraciones, ejecutar pruebas y desmontarlo todo. Correr contra una base compartida produce pruebas inestables, en las que los datos de una se filtran en las comprobaciones de otra. Usa Testcontainers, Docker Compose o el soporte de base de datos de prueba de tu framework para que cada ejecución arranque desde un estado conocido.

Una prueba de integración con una base de datos real vale por cien pruebas unitarias que la simulan. La simulación te dice lo que esperas que haga la base. La base real te dice lo que hace de verdad. Y ambas rara vez coinciden después de la primera migración de esquema.

Pruebas de extremo a extremo con Playwright

Las pruebas de extremo a extremo simulan interacciones reales a través de toda la pila: navegador, frontend, API, base de datos y servicios externos. Son las más lentas y caras, pero atrapan los errores más realistas: navegación rota, validación de formularios ausente, respuestas de API mal presentadas, fallos en el flujo de autenticación y problemas de representación entre navegadores.

Playwright se ha convertido en la herramienta dominante por su soporte de varios navegadores, sus comprobaciones con espera automática, la intercepción de red y su experiencia de desarrollo. A diferencia de herramientas anteriores que exigían esperas y reintentos explícitos, Playwright aguarda a que un elemento sea accionable antes de interactuar con él, lo que elimina la causa más común de pruebas inestables.

import { test, expect } from "@playwright/test";

test("user completes a purchase flow", async ({ page }) => {
  await page.goto("/products");

  await page.getByRole("link", { name: "Wireless Headphones" }).click();
  await page.getByRole("button", { name: "Add to Cart" }).click();
  await page.getByRole("button", { name: "View Cart" }).click();

  await expect(page.getByText("Wireless Headphones")).toBeVisible();
  await expect(page.getByText("$89.99")).toBeVisible();

  await page.getByRole("button", { name: "Checkout" }).click();

  await page.getByLabel("Email").fill("test@example.com");
  await page.getByLabel("Card Number").fill("4242424242424242");
  await page.getByLabel("Expiry").fill("12/28");
  await page.getByLabel("CVC").fill("123");
  await page.getByRole("button", { name: "Pay Now" }).click();

  await expect(page.getByText("Order confirmed")).toBeVisible();
  await expect(page.getByText("ord_001")).toBeVisible();
});

El mayor error con estas pruebas es escribir demasiadas. Cada una añade minutos a la integración continua, y una suite de 200 puede tardar fácilmente media hora. Su economía exige selectividad. Concéntrate en los recorridos críticos —registro, inicio de sesión, compra, búsqueda, pago— y deja que los niveles inferiores cubran casos límite y estados de error.

  • Prioriza los flujos críticos para el negocio: pago, mejora de suscripción, procesamiento de cobros.
  • Mantén las pruebas independientes. Cada una crea sus propios datos y limpia tras de sí.
  • Usa llamadas a la API en la preparación en vez de interacciones de interfaz, para llegar antes al estado de partida.
  • Ejecútalas contra un entorno de preproducción o vista previa, nunca en producción. Controla la visibilidad de los datos de prueba con indicadores de funcionalidad.
  • Invierte en observabilidad: capturas, trazas y grabaciones de las pruebas fallidas ahorran horas de depuración.

Desarrollo guiado por pruebas en la práctica

El desarrollo guiado por pruebas no va de probar, sino de diseñar. El ciclo rojo, verde, refactorizar te obliga a pensar en la interfaz de tu código antes de implementarlo. Escribir primero la prueba te hace responder: ¿qué debe aceptar esta función y qué debe devolver? Esa restricción conduce a mejores APIs, menor acoplamiento y código más modular.

El rechazo suele venir de dos sitios. O alguien lo intentó en un problema poco adecuado (código de interfaz, trabajo exploratorio), o lo aplicó dogmáticamente y pasó más tiempo peleando con el marco de pruebas que escribiendo código útil. Es una herramienta, no una religión. Funciona mejor en lógica algorítmica, transformaciones de datos, diseño de APIs y todo código donde el contrato entre entrada y salida está claro de antemano.

Un flujo práctico es así: escribe una sola prueba que describa el siguiente comportamiento que quieres implementar. Ejecútala y mírala fallar; eso confirma que es válida y que detecta la ausencia de la funcionalidad. Escribe el mínimo código para que pase, sin sobreingeniería: la implementación más simple que la satisface es la correcta. Después mejora la estructura sin cambiar el comportamiento, apoyándote en las pruebas que ya pasan para detectar regresiones.

El desarrollo guiado por pruebas no te hace escribir más pruebas. Te hace escribir mejor código. La prueba es un efecto secundario del proceso de diseño, no el objetivo. Si escribes pruebas después de la implementación, estás probando. Si las escribes antes, estás diseñando.

Es especialmente eficaz para corregir errores. Cuando alguien reporta uno, escribe una prueba que lo reproduzca (rojo), corrige el código (verde) y comprueba que la corrección no rompe nada existente (todas las pruebas siguen pasando). Esa prueba se convierte en una salvaguarda permanente. Con el tiempo, así se forma una suite que refleja el historial real de errores del código, mucho más valiosa que una generada para cumplir un objetivo de cobertura.

Pruebas basadas en propiedades y fuzzing

Las pruebas tradicionales por ejemplo comprueban entradas concretas contra salidas esperadas. Las basadas en propiedades toman otro camino: definen una propiedad que debe cumplirse para todas las entradas y luego generan cientos o miles de entradas aleatorias para verificarla. Esta técnica atrapa casos límite que a ninguna persona se le ocurriría escribir como ejemplo.

Por ejemplo, en vez de escribir cinco casos concretos para una función de ordenación, defines una propiedad: para cualquier lista de elementos comparables, el resultado debe contener los mismos elementos en orden no decreciente. El marco genera listas aleatorias de distintos tamaños, con duplicados, listas vacías y valores extremos, y verifica la propiedad en cada una.

import { describe, it, expect } from "vitest";
import { faker } from "@faker-js/faker";

describe("sortByPrice", () => {
  it("returns items in ascending price order for any input", () => {
    for (let i = 0; i < 100; i++) {
      const items = Array.from(
        { length: faker.number.int({ min: 0, max: 50 }) },
        () => ({
          name: faker.commerce.productName(),
          price: faker.number.int({ min: 1, max: 1000 }),
        })
      );

      const sorted = sortByPrice(items);

      for (let j = 1; j < sorted.length; j++) {
        expect(sorted[j - 1].price).toBeLessThanOrEqual(sorted[j].price);
      }
    }
  });

  it("preserves all original elements", () => {
    for (let i = 0; i < 100; i++) {
      const items = Array.from(
        { length: faker.number.int({ min: 0, max: 50 }) },
        () => ({
          name: faker.commerce.productName(),
          price: faker.number.int({ min: 1, max: 1000 }),
        })
      );

      const sorted = sortByPrice(items);

      expect(sorted).toHaveLength(items.length);
      expect(sorted.map((i) => i.name).sort()).toEqual(
        items.map((i) => i.name).sort()
      );
    }
  });
});

Fast-check es una biblioteca muy usada para pruebas basadas en propiedades en TypeScript, integrada con Vitest y Jest. Aporta generadores para tipos habituales, combinadores para estructuras complejas y reducción automática: cuando encuentra un caso que falla, intenta reducirlo a la entrada mínima que sigue fallando, lo que facilita mucho la depuración.

Resultan especialmente valiosas en lógica de serialización y deserialización, algoritmos de ordenación y filtrado, reglas de validación, transiciones de máquinas de estados y cualquier función cuyo espacio de entradas sea grande. Combinadas con técnicas de fuzzing que inyectan datos malformados o inesperados, exponen vulnerabilidades y caídas que de otro modo solo se descubrirían en producción.

Pruebas de regresión visual

Las pruebas funcionales comprueban que la aplicación se comporta bien. Las de regresión visual comprueban que se ve bien. Cambios de CSS, actualizaciones de dependencias y componentes refactorizados pueden introducir desplazamientos sutiles, desajustes de color, cambios de tipografía o problemas en los puntos de ruptura que ninguna prueba funcional detecta. La regresión visual captura imágenes de componentes o páginas, las compara con referencias y señala cualquier diferencia para revisión humana.

Herramientas como Chromatic, Percy y la captura integrada de Playwright lo implementan a distintos niveles. Chromatic se integra con Storybook y compara píxel a píxel con una lógica de diferencias que ignora el suavizado y las variaciones de subpíxel. Percy ofrece capacidades similares con soporte más amplio de frameworks. La comprobación toHaveScreenshot de Playwright permite escribir afirmaciones visuales directamente en tus pruebas de extremo a extremo, sin herramientas adicionales.

import { test, expect } from "@playwright/test";

test("product page renders correctly", async ({ page }) => {
  await page.goto("/products/wireless-headphones");

  await expect(page).toHaveScreenshot("product-page.png", {
    maxDiffPixelRatio: 0.01,
    threshold: 0.2,
    fullPage: true,
  });
});

test("checkout form is visually stable across states", async ({ page }) => {
  await page.goto("/checkout");

  await expect(page).toHaveScreenshot("checkout-empty.png");

  await page.getByLabel("Email").fill("invalid-email");
  await page.getByRole("button", { name: "Continue" }).click();

  await expect(page).toHaveScreenshot("checkout-validation-error.png");
});

El reto principal es gestionar las imágenes de referencia. Todo cambio visual intencionado exige actualizarlas, y el proceso de revisión puede volverse un cuello de botella en equipos que tocan la interfaz a menudo. La solución es tratar esa actualización como parte del flujo de desarrollo: aprobar las diferencias visuales en el mismo ciclo de revisión que el código, con una plataforma integrada en el flujo de pull requests.

  • Empieza por las páginas críticas: portada, pago, inicio de sesión, ficha de producto y cualquier página con maquetación compleja.
  • Fija umbrales adecuados para ignorar artefactos de suavizado y diferencias de tipografía entre sistemas operativos.
  • Usa capturas a nivel de componente (con Storybook) para una cobertura dirigida, en vez de capturas de página completa sensibles a cualquier cambio.
  • Integra la revisión visual en tu flujo de pull requests. Exige aprobación humana para las diferencias en páginas modificadas.
  • Ejecuta las pruebas visuales en un entorno uniforme (Docker) para eliminar diferencias de representación entre máquinas.

Probar componentes de servidor de React y Next.js

Los componentes de servidor de React cambiaron el panorama de pruebas. Se ejecutan exclusivamente en el servidor y no envían JavaScript al cliente. Pueden acceder directamente a bases de datos, sistemas de archivos y servicios de backend sin exponer nada de esa lógica al navegador. Eso permite probarlos con un enfoque distinto al de los componentes de cliente.

En esencia son funciones asíncronas que devuelven JSX. Puedes probarlos como cualquier función asíncrona: llamarlos con propiedades, esperar el resultado y verificar la salida. Sin entorno de navegador y sin simular fetch ni llamadas a base de datos si trabajas contra una base de prueba real. Esta sencillez es una de las ventajas menos valoradas de los componentes de servidor: la preparación es mucho más simple que con componentes de cliente, que necesitan un DOM.

import { describe, it, expect } from "vitest";
import { ProductList } from "./product-list";

describe("ProductList (Server Component)", () => {
  it("renders products from the database", async () => {
    // No mocking needed - the component calls the database directly
    const { props } = await ProductList({ category: "electronics" });

    expect(props.products.length).toBeGreaterThan(0);
    expect(props.products.every((p) => p.category === "electronics")).toBe(
      true
    );
  });

  it("shows empty state when no products match", async () => {
    const { props } = await ProductList({ category: "nonexistent" });

    expect(props.products).toHaveLength(0);
  });
});

Los componentes de cliente que usan useState, useEffect, APIs del navegador o manejadores de eventos necesitan un entorno DOM de prueba. Vitest ofrece happy-dom o jsdom para simular un navegador. Testing Library aporta utilidades para consultar la salida y simular interacciones. La diferencia con los componentes de servidor es que debes renderizar, esperar a que corran los efectos y verificar el estado resultante del DOM.

Las aplicaciones Next.js añaden enrutado, obtención de datos y acciones de servidor que exigen consideraciones extra. En páginas con getServerSideProps o generateStaticParams, prueba las funciones de obtención de datos aparte del componente. Las acciones de servidor pruébalas como funciones asíncronas normales: no son otra cosa. En diseños y páginas del App Router, prueba la capa de datos separada de la de presentación para no acoplar tus pruebas a las interioridades de renderizado del framework.

import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form";

describe("CheckoutForm", () => {
  it("shows validation errors for empty required fields", async () => {
    const user = userEvent.setup();

    render(<CheckoutForm />);

    await user.click(screen.getByRole("button", { name: "Submit" }));

    expect(screen.getByText("Email is required")).toBeVisible();
    expect(screen.getByText("Card number is required")).toBeVisible();
  });

  it("submits the form with valid data", async () => {
    const onSubmit = vi.fn();
    const user = userEvent.setup();

    render(<CheckoutForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText("Email"), "test@example.com");
    await user.type(screen.getByLabelText("Card Number"), "4242424242424242");
    await user.type(screen.getByLabelText("Expiry"), "12/28");
    await user.type(screen.getByLabelText("CVC"), "123");

    await user.click(screen.getByRole("button", { name: "Submit" }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "test@example.com",
      cardNumber: "4242424242424242",
      expiry: "12/28",
      cvc: "123",
    });
  });
});

La distinción más importante en el nuevo modelo de React es separar las pruebas de componentes de servidor de las de cliente. Los de servidor son funciones asíncronas puras: pruébalos sin entorno DOM. Los de cliente necesitan simulación de interacción: pruébalos con Testing Library y userEvent. Mezclar ambas cosas hace tus pruebas más lentas y complicadas de lo necesario.

Estrategias de simulación y cuándo evitarla

La simulación es uno de los temas más discutidos. Los simuladores sustituyen dependencias reales por sustitutos controlados y permiten probar código en aislamiento sin montar bases de datos, llamar a APIs externas ni esperar temporizadores. El precio es que verificas tu código contra una versión simulada de la dependencia, no contra la real. Si el simulador no refleja fielmente el comportamiento real, la prueba pasa mientras el código falla.

La pauta general es simular en la frontera de tu sistema. Simula servicios externos que no controlas (APIs de terceros, pasarelas de pago, envío de correo) e infraestructura cara o no determinista (bases de datos, sistemas de archivos, relojes). No simules módulos internos, funciones utilitarias ni tus propios objetos de dominio: esos deben probarse con sus implementaciones reales en pruebas de integración.

Vitest ofrece vi.mock, que eleva las declaraciones al principio del archivo y reemplaza implementaciones de módulos antes de ejecutar. Resulta útil para variables de entorno, SDK de terceros y objetos globales. El riesgo es que la simulación a nivel de módulo es invisible y puede producir pruebas que pasan aisladas pero fallan juntas por interferencias.

import { describe, it, expect, vi } from "vitest";
import { PaymentService } from "./payment-service";

const mockStripe = {
  charges: {
    create: vi.fn(),
  },
};

vi.mock("stripe", () => ({
  default: vi.fn(() => mockStripe),
}));

describe("PaymentService", () => {
  it("creates a charge successfully", async () => {
    mockStripe.charges.create.mockResolvedValue({
      id: "ch_001",
      status: "succeeded",
    });

    const service = new PaymentService();
    const result = await service.charge(5000, "tok_visa");

    expect(result.status).toBe("succeeded");
    expect(mockStripe.charges.create).toHaveBeenCalledWith({
      amount: 5000,
      currency: "usd",
      source: "tok_visa",
    });
  });

  it("throws on failed payment", async () => {
    mockStripe.charges.create.mockRejectedValue(
      new Error("card_declined")
    );

    const service = new PaymentService();

    await expect(service.charge(5000, "tok_visa")).rejects.toThrow(
      "card_declined"
    );
  });
});

Una alternativa más fiable son los dobles de prueba que implementan la misma interfaz que la dependencia real, pero con una versión simplificada en memoria. En vez de simular el módulo de base de datos, crea una versión en memoria de tu repositorio que guarde los datos en un Map. Ese doble es ligero pero fiel, y recorre los mismos caminos de código que la base real sin su coste de preparación.

Cada simulación abre una brecha entre tus pruebas y la realidad. En esa brecha se esconden los errores. Antes de recurrir a una, pregúntate: ¿puedo probar esto con la dependencia real en un entorno controlado? Si la respuesta es sí, usa lo real. Si es no —demasiado lenta, cara o no determinista—, simula, pero solo en la frontera del sistema.

  • Simula en las fronteras del sistema: APIs de terceros, SDK, pasarelas de pago, servicios externos que no puedes ejecutar en local.
  • Usa dobles en memoria para tus propias abstracciones (repositorios, cachés, colas) en vez de simularlas.
  • No simules lo que no es tuyo: simular interioridades de una biblioteca acopla tus pruebas a detalles de implementación.
  • Prefiere la inyección de dependencias a la simulación a nivel de módulo. Pasar dependencias de forma explícita hace transparente la preparación.
  • Limita el alcance de cada simulación a la prueba que la necesita. Reinicia todas las simulaciones entre pruebas.

Pruebas en las tuberías de CI/CD

Una suite de pruebas solo vale si se ejecuta de forma consistente y da retroalimentación rápida. Las pruebas lentas, inestables o que solo corren en máquinas de desarrollo son un lastre. Integrarlas en la tubería garantiza que se ejecuten en cada pull request, en cada fusión a la rama principal y, para los caminos críticos, antes de cada despliegue.

La pirámide se traduce con naturalidad en etapas de la tubería. Primero corren las unitarias, en cada empuje, porque son rápidas y atrapan errores básicos de lógica. Después, las de integración, en paralelo cuando se pueda, con dependencias en contenedor. Por último las de extremo a extremo, solo en pull requests y antes de desplegar, porque son lentas y caras. Las de regresión visual corren junto a estas, comparando capturas con la referencia de la rama principal.

El rendimiento importa. Una suite que tarda 45 minutos anima a saltarse las pruebas locales y a empujar código roto. Estrategias útiles: ejecutar solo las pruebas afectadas por el cambio actual, repartir las suites entre ejecutores en paralelo, cachear node_modules y los navegadores de Playwright entre ejecuciones, y programar las pruebas lentas de extremo a extremo en vez de correrlas en cada confirmación.

# .github/workflows/test.yml - example pipeline structure
name: test
on: [push, pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx vitest run --project unit

  integration:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: test
          POSTGRES_PASSWORD: test
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx vitest run --project integration

  e2e:
    needs: [unit, integration]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test

Las pruebas inestables son la mayor amenaza para la confianza en la integración continua. Una prueba que falla de forma intermitente por motivos ajenos al código erosiona la confianza en toda la tubería. El equipo empieza a ignorar fallos, a fusionar con la integración en rojo, y la suite acaba siendo ruido. Invierte en detectarlas: márcalas, ponlas en cuarentena y prioriza arreglarlas o eliminarlas. Una prueba en la que no se puede confiar es peor que ninguna, porque crea el hábito de ignorar los fallos.

El informe de resultados es otra práctica infravalorada. Una salida que dice «3 pruebas fallidas» sin contexto obliga a rebuscar en los registros el mensaje de error. Un buen informe muestra la afirmación fallida, los valores esperados y reales, el archivo y la línea, y contexto relevante como capturas o trazas. Los informes en HTML de Vitest y Playwright o las anotaciones de GitHub Actions hacen visibles los resultados dentro del propio flujo del pull request.

El debate entre el trofeo y la pirámide

La pirámide clásica —unitarias en la base, integración en medio, extremo a extremo arriba— ha guiado la estrategia de pruebas durante dos décadas. Transmite una idea simple: muchas pruebas rápidas y aisladas, menos de integración y muy pocas de extremo a extremo. El modelo funciona bien para servicios de backend donde la unidad de despliegue es una función o clase con fronteras claras de entrada y salida.

Como alternativa se propuso el trofeo de pruebas, que refleja mejor el desarrollo frontend moderno. Reconfigura la pirámide en un rombo donde las pruebas de integración forman la capa mayor, con menos unitarias y menos de extremo a extremo. El argumento es que, en el frontend, el mayor valor viene de comprobar cómo colaboran los componentes —justo lo que verifican las de integración— y no de probar funciones aisladas o recorridos completos.

El trofeo refleja una realidad práctica: las pruebas más valiosas de una aplicación React son las que renderizan un componente, interactúan con él y verifican el resultado. Ejercitan a la vez el componente, sus hijos, sus hooks, su gestión de estado y sus llamadas a la API. Son más rápidas que las de extremo a extremo y más realistas que las unitarias. Una suite de buenas pruebas de integración da más confianza por prueba que cualquiera de las otras dos en un contexto frontend.

La realidad es que ambos modelos simplifican una verdad más compleja. La estrategia correcta depende de tu arquitectura. Un microservicio con lógica de negocio pesada se beneficia de la pirámide: cobertura unitaria profunda del dominio, menos pruebas de integración para la capa de API y unas pocas pruebas de contrato para servicios externos. Una aplicación frontend con interacciones complejas se beneficia del trofeo: amplia cobertura de integración de componentes y páginas, pruebas unitarias selectivas para utilidades y pruebas de extremo a extremo para los caminos críticos.

La pirámide y el trofeo se equivocan los dos si los sigues con dogmatismo. La estrategia correcta es la que atrapa tus errores, corre lo bastante rápido para tu equipo y sobrevive a tu arquitectura. Puede parecer una pirámide, un trofeo o una forma que nadie ha bautizado. Deja de discutir sobre la forma y mira qué atrapan de verdad tus pruebas.

La idea importante tras ambos modelos es que cada nivel tiene un perfil distinto de coste y beneficio. Una prueba unitaria cuesta milisegundos escribirla y ejecutarla. Una de integración cuesta segundos. Una de extremo a extremo cuesta minutos. Reparte tu presupuesto de pruebas en proporción al valor que cada nivel aporta a tu aplicación concreta. Si es un panel con mucha lógica de negocio, invierte en unitarias e integración. Si es un sitio de contenidos con poca interactividad, las de extremo a extremo para los flujos críticos y las de regresión visual para la maquetación serán la mejor inversión.

Construir una cultura de pruebas

La mejor estrategia fracasa si el equipo no cree en las pruebas. La cultura de pruebas no se impone con umbrales de cobertura ni obligando a practicar TDD. Se crea haciendo que escribir pruebas sea el camino natural de menor resistencia. La gente escribe pruebas cuando son fáciles de escribir, rápidas de ejecutar y claramente útiles. Cuando son frágiles, lentas y dan falsos fallos, se buscan maneras de evitarlas.

Empieza por hacer excelente la experiencia. Invierte en infraestructura: ejecutores rápidos, bases de datos de prueba fiables, detección de pruebas inestables. Escribe utilidades que hagan concisas las comprobaciones habituales. Documenta los patrones en una guía de equipo para que cualquiera sepa cómo probar un nuevo endpoint, un componente de React o una migración sin empezar de cero. Construir una buena infraestructura una vez cuesta mucho menos que el desgaste diario de pelear con malas pruebas.

Las revisiones de código deben incluir la revisión de pruebas. Quien revisa debería preguntar: ¿cubre esta prueba el comportamiento que dice cubrir? ¿Podría pasar aunque la implementación fuera incorrecta? ¿Está probando lo adecuado en el nivel adecuado? ¿Es legible y mantenible? Trata las pruebas como código de primera clase, sujeto a los mismos estándares de revisión, guías de estilo y expectativas de calidad que el código de producción.

Las métricas de cobertura son una buena herramienta de diagnóstico y un pésimo objetivo. Si fijas un 90 por ciento, obtendrás un 90 por ciento, no mejores pruebas. La gente escribirá pruebas triviales sobre métodos de acceso y constructores vacíos para cumplir la cifra sin mejorar la red de seguridad. Usa los informes para encontrar caminos sin probar, no para imponer umbrales arbitrarios. Un 70 por ciento con pruebas de integración bien colocadas vale más que un 95 por ciento con unitarias superficiales.

  • Haz fácil escribir pruebas: utilidades, fábricas y ayudantes que reduzcan el código repetido en cada archivo.
  • Celebra las mejoras en pruebas igual que la entrega de funcionalidades. Una prueba que elimina una clase de errores es una funcionalidad.
  • Rota la responsabilidad sobre la infraestructura de pruebas para que el conocimiento se reparta y no se concentre en una persona.
  • Haz sesiones periódicas de revisión de pruebas inestables. Sigue su número como indicador de salud del equipo y prioriza los arreglos.
  • Escribe un manifiesto de pruebas del equipo que defina qué probar en cada nivel, qué no probar y las compensaciones acordadas.

El objetivo último de una cultura de pruebas no es una cifra de cobertura ni una tubería perfecta. Es confianza. Confianza en que una refactorización no romperá producción. Confianza en que desplegar un viernes por la tarde es seguro. Confianza en que, cuando se reporte un error, la corrección aguantará. Una estrategia que da esa confianza al equipo es una buena estrategia, tenga forma de pirámide, de trofeo o de algo intermedio.

Juntándolo todo

Construir una estrategia eficaz no consiste en elegir un enfoque sobre otro, sino en entender las compensaciones y aplicar la herramienta adecuada a cada situación. Las unitarias atrapan errores de lógica en milisegundos. Las de integración atrapan fallos de interacción con dependencias reales. Las de extremo a extremo atrapan fallos visibles para el usuario en toda la pila. Las basadas en propiedades atrapan casos límite que ni sabías que existían. Las de regresión visual atrapan cambios estéticos no deseados.

Los equipos que entregan con fiabilidad no son los de mayor cobertura. Son los que han pensado con criterio qué aporta cada nivel, han alineado su inversión con su perfil de riesgo y han construido una cultura donde las pruebas se escriben porque aportan valor, no porque una política lo exige. Empieza auditando tu suite actual. Para cada prueba, pregunta: ¿justifica su existencia? Si la respuesta no es un sí claro, quítala o sustitúyela. La mejor suite no es la más grande, sino la que da más confianza por minuto de ejecución.

See what your own repository can account for.

Thirty minutes on a repository you choose, including the part the record cannot attribute.