Guía de testing con Vitest: De cero a confianza en producción
Tests unitarios, tests de integración, tests de componentes con Testing Library, estrategias de mocking, configuración de coverage y la filosofía de testing que realmente produce mejor software.
La mayoría de los equipos escriben tests de la misma forma en que la mayoría de la gente hace ejercicio: saben que deberían, se sienten culpables cuando no lo hacen, y cuando finalmente lo hacen, se exigen demasiado el primer día y abandonan el viernes. El codebase termina con un puñado de snapshot tests superficiales que se rompen cada vez que alguien cambia una clase CSS, unos pocos tests de integración en los que nadie confía, y un badge de cobertura en el README que miente.
He estado en ambos lados. He lanzado proyectos con cero tests y sudado en cada deploy. También he estado en equipos que perseguían el 100% de cobertura y pasaban más tiempo manteniendo tests que escribiendo funcionalidades. Ninguno funciona. Lo que funciona es escribir los tests correctos, en los lugares correctos, con las herramientas correctas.
Vitest cambió mi forma de pensar sobre el testing en JavaScript. No porque inventó nuevos conceptos — los fundamentos no han cambiado desde que Kent Beck escribió sobre ellos hace décadas. Sino porque eliminó suficiente fricción como para que escribir tests dejara de sentirse como una tarea y empezara a sentirse como parte del ciclo de desarrollo. Cuando tu test runner es tan rápido como tu servidor de desarrollo y usa la misma configuración, las excusas se evaporan.
Este post es todo lo que sé sobre testing con Vitest, desde la configuración inicial hasta la filosofía que hace que todo valga la pena.
Por qué Vitest en lugar de Jest#
Si has usado Jest, ya conoces la mayor parte de la API de Vitest. Eso es por diseño — Vitest es compatible con Jest a nivel de API. describe, it, expect, beforeEach, vi.fn() — todo funciona. Entonces, ¿por qué cambiar?
Soporte nativo de ESM#
Jest fue construido para CommonJS. Puede manejar ESM, pero requiere configuración, flags experimentales y oración ocasional. Si estás usando sintaxis import/export (que es todo lo moderno), probablemente has peleado con el pipeline de transformación de Jest.
Vitest corre sobre Vite. Vite entiende ESM nativamente. No hay paso de transformación para tu código fuente — simplemente funciona. Esto importa más de lo que parece. La mitad de los problemas de Jest que he depurado a lo largo de los años se remontan a la resolución de módulos: SyntaxError: Cannot use import statement outside a module, o mocks que no funcionan porque el módulo ya estaba cacheado en un formato diferente.
La misma configuración que tu servidor de desarrollo#
Si tu proyecto usa Vite (y si estás construyendo una app de React, Vue o Svelte en 2026, probablemente lo hace), Vitest lee tu vite.config.ts automáticamente. Tus alias de rutas, plugins y variables de entorno funcionan en los tests sin ninguna configuración adicional. Con Jest, mantienes una configuración paralela que tiene que estar sincronizada con la configuración de tu bundler. Cada vez que agregas un alias de ruta en vite.config.ts, tienes que recordar agregar el correspondiente moduleNameMapper en jest.config.ts. Es algo pequeño, pero las cosas pequeñas se acumulan.
Velocidad#
Vitest es rápido. Significativamente rápido. No "te ahorra dos segundos" rápido — "cambia tu forma de trabajar" rápido. Usa el grafo de módulos de Vite para entender qué tests se ven afectados por un cambio de archivo y solo ejecuta esos. Su modo watch usa la misma infraestructura de HMR que hace que el servidor de desarrollo de Vite se sienta instantáneo.
En un proyecto con más de 400 tests, cambiar de Jest a Vitest redujo nuestro ciclo de retroalimentación en modo watch de ~4 segundos a menos de 500ms. Esa es la diferencia entre "voy a esperar a que el test pase" y "voy a echarle un vistazo a la terminal mientras mis dedos aún están en el teclado".
Benchmarking integrado#
Vitest incluye bench() de fábrica para pruebas de rendimiento. No se necesita una librería separada:
import { bench, describe } from "vitest";
describe("string concatenation", () => {
bench("template literals", () => {
const name = "world";
const _result = `hello ${name}`;
});
bench("string concat", () => {
const name = "world";
const _result = "hello " + name;
});
});Ejecútalo con vitest bench. No es el evento principal, pero es bueno tener pruebas de rendimiento en la misma cadena de herramientas sin instalar benchmark.js y configurar un runner separado.
Configuración#
Instalación#
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomConfiguración#
Crea vitest.config.ts en la raíz de tu proyecto:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
alias: {
"@": path.resolve(__dirname, "./src"),
},
css: false,
},
});O, si ya tienes un vite.config.ts, puedes extenderlo:
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});La decisión de globals: true#
Cuando globals es true, no necesitas importar describe, it, expect, beforeEach, etc. — están disponibles en todas partes, igual que en Jest. Cuando es false, los importas explícitamente:
// globals: false
import { describe, it, expect } from "vitest";
describe("math", () => {
it("adds numbers", () => {
expect(1 + 1).toBe(2);
});
});Yo uso globals: true porque reduce el ruido visual y coincide con lo que la mayoría de los desarrolladores esperan. Si estás en un equipo que valora los imports explícitos, ponlo en false — no hay respuesta incorrecta aquí.
Si usas globals: true, agrega los tipos de Vitest a tu tsconfig.json para que TypeScript los reconozca:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}Environment: jsdom vs happy-dom vs node#
Vitest te permite elegir la implementación del DOM por test o globalmente:
node— Sin DOM. Para lógica pura, utilidades, rutas de API y cualquier cosa que no toque el navegador.jsdom— El estándar. Implementación completa del DOM. Más pesado pero más completo.happy-dom— Más ligero y rápido que jsdom pero menos completo. Algunos casos extremos (comoRange,SelectionoIntersectionObserver) pueden no funcionar.
Yo uso jsdom por defecto globalmente y lo sobrescribo por archivo cuando necesito node:
// src/lib/utils.test.ts
// @vitest-environment node
import { formatDate, slugify } from "./utils";
describe("slugify", () => {
it("converts spaces to hyphens", () => {
expect(slugify("hello world")).toBe("hello-world");
});
});Archivos de setup#
El archivo de setup se ejecuta antes de cada archivo de test. Aquí es donde configuras los matchers de Testing Library y los mocks globales:
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
// Mock IntersectionObserver — jsdom doesn't implement it
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: MockIntersectionObserver,
});
// Mock window.matchMedia — needed for responsive components
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});La importación de @testing-library/jest-dom/vitest te da matchers como toBeInTheDocument(), toHaveClass(), toBeVisible() y muchos otros. Estos hacen que las aserciones sobre elementos del DOM sean legibles y expresivas.
Escribiendo buenos tests#
El patrón AAA#
Cada test sigue la misma estructura: Arrange, Act, Assert (Preparar, Actuar, Afirmar). Incluso cuando no escribes comentarios explícitos, la estructura debe ser visible:
it("calculates total with tax", () => {
// Arrange
const items = [
{ name: "Widget", price: 10 },
{ name: "Gadget", price: 20 },
];
const taxRate = 0.08;
// Act
const total = calculateTotal(items, taxRate);
// Assert
expect(total).toBe(32.4);
});Cuando veo un test que mezcla preparación, acción y aserción en una sola cadena de llamadas a métodos, sé que va a ser difícil de entender cuando falle. Mantén las tres fases visualmente distintas incluso si no agregas los comentarios.
Nombres de tests#
Hay dos escuelas: it('should calculate total with tax') y it('calculates total with tax'). El prefijo "should" es verboso sin agregar información. Cuando el test falla, verás:
FAIL ✕ calculates total with tax
Eso ya es una oración completa. Agregar "should" solo agrega ruido. Prefiero la forma directa: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').
Para bloques describe, usa el nombre de la unidad bajo prueba:
describe("calculateTotal", () => {
it("sums item prices", () => { /* ... */ });
it("applies tax rate", () => { /* ... */ });
it("returns 0 for empty array", () => { /* ... */ });
it("handles negative prices", () => { /* ... */ });
});Léelo en voz alta: "calculateTotal sums item prices." "calculateTotal applies tax rate." Si la oración funciona, el nombre funciona.
Una aserción por test vs agrupación práctica#
La regla purista dice una aserción por test. La regla práctica dice: un concepto por test. Son cosas diferentes.
// This is fine — one concept, multiple assertions
it("formats user display name", () => {
const user = { firstName: "John", lastName: "Doe", title: "Dr." };
const result = formatDisplayName(user);
expect(result).toContain("John");
expect(result).toContain("Doe");
expect(result).toStartWith("Dr.");
});
// This is not fine — multiple concepts in one test
it("handles user operations", () => {
const user = createUser("John");
expect(user.id).toBeDefined();
const updated = updateUser(user.id, { name: "Jane" });
expect(updated.name).toBe("Jane");
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});El primer test tiene tres aserciones pero todas verifican una cosa: el formato de un nombre para mostrar. Si cualquier aserción falla, sabes exactamente qué está roto. El segundo test son tres tests separados metidos a la fuerza juntos. Si la segunda aserción falla, no sabes si la creación o la actualización está rota, y la tercera aserción nunca se ejecuta.
Descripciones de tests como documentación#
Las buenas suites de tests sirven como documentación viva. Alguien que no conozca el código debería poder leer las descripciones de los tests y entender el comportamiento de la funcionalidad:
describe("PasswordValidator", () => {
describe("minimum length", () => {
it("rejects passwords shorter than 8 characters", () => { /* ... */ });
it("accepts passwords with exactly 8 characters", () => { /* ... */ });
});
describe("character requirements", () => {
it("requires at least one uppercase letter", () => { /* ... */ });
it("requires at least one number", () => { /* ... */ });
it("requires at least one special character", () => { /* ... */ });
});
describe("common password check", () => {
it("rejects passwords in the common passwords list", () => { /* ... */ });
it("performs case-insensitive comparison", () => { /* ... */ });
});
});Cuando esta suite de tests se ejecuta, la salida se lee como una especificación. Ese es el objetivo.
Mocking#
El mocking es la herramienta más poderosa y más peligrosa de tu toolkit de testing. Usado bien, aísla la unidad bajo prueba y hace que los tests sean rápidos y determinísticos. Usado mal, crea tests que pasan sin importar lo que haga el código.
vi.fn() — Creando funciones mock#
El mock más simple es una función que registra sus llamadas:
const mockCallback = vi.fn();
// Call it
mockCallback("hello", 42);
mockCallback("world");
// Assert on calls
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");Puedes darle un valor de retorno:
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Test" }),
});O hacer que retorne diferentes valores en llamadas sucesivas:
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.9);vi.spyOn() — Observando métodos reales#
Cuando quieres observar un método sin reemplazar su comportamiento:
const consoleSpy = vi.spyOn(console, "warn");
validateInput("");
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();spyOn mantiene la implementación original por defecto. Puedes sobreescribirla con .mockImplementation() cuando sea necesario pero restaurar la original después con .mockRestore().
vi.mock() — Mocking a nivel de módulo#
Este es el grande. vi.mock() reemplaza un módulo entero:
// Mock the entire module
vi.mock("@/lib/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]),
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
// The import now uses the mocked version
import { fetchUsers } from "@/lib/api";
describe("UserList", () => {
it("displays users from API", async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
});
});Vitest eleva las llamadas a vi.mock() al inicio del archivo automáticamente. Esto significa que el mock está en su lugar antes de que se ejecute cualquier import. No necesitas preocuparte por el orden de los imports.
Mocking automático#
Si solo quieres que cada exportación sea reemplazada con un vi.fn():
vi.mock("@/lib/analytics");
import { trackEvent, trackPageView } from "@/lib/analytics";
it("tracks form submission", () => {
submitForm();
expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});Sin la función factory, Vitest hace auto-mock de todas las exportaciones. Cada función exportada se convierte en un vi.fn() que retorna undefined. Esto es útil para módulos que quieres silenciar (como analytics o logging) sin especificar cada función.
Clearing vs Resetting vs Restoring#
Esto confunde a todos en algún momento:
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
// mockClear — resetea el historial de llamadas, mantiene la implementación
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // still returns 42
// mockReset — resetea el historial de llamadas Y la implementación
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // no longer returns 42
// mockRestore — para spies, restaura la implementación original
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() now works normally againEn la práctica, usa vi.clearAllMocks() en beforeEach para resetear el historial de llamadas entre tests. Usa vi.restoreAllMocks() si estás usando spyOn y quieres recuperar los originales:
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});El peligro del over-mocking#
Este es el consejo más importante que puedo dar sobre mocking: cada mock es una mentira que le cuentas a tu test. Cuando haces mock de una dependencia, estás diciendo "confío en que esto funciona correctamente, así que lo reemplazo con una versión simplificada". Si tu suposición es incorrecta, el test pasa pero la funcionalidad está rota.
// Over-mocked — tests nothing useful
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
it("processes user input", () => {
processInput("hello");
expect(validator.validate).toHaveBeenCalledWith("hello");
expect(formatter.format).toHaveBeenCalledWith("hello");
});Este test verifica que processInput llama a validate y format. Pero ¿qué pasa si processInput las llama en el orden incorrecto? ¿Qué pasa si ignora sus valores de retorno? ¿Qué pasa si la validación debería impedir que se ejecute el paso de formateo? El test no lo sabe. Has hecho mock de todo el comportamiento interesante.
La regla general: haz mock en los límites, no en el medio. Haz mock de solicitudes de red, acceso al sistema de archivos y servicios de terceros. No hagas mock de tus propias funciones utilitarias a menos que haya una razón convincente (como que son costosas de ejecutar o tienen efectos secundarios).
Testing de componentes React#
Lo básico con Testing Library#
Testing Library impone una filosofía: prueba los componentes de la forma en que los usuarios interactúan con ellos. Sin verificar estado interno, sin inspeccionar instancias de componentes, sin shallow rendering. Renderizas un componente e interactúas con él a través del DOM, igual que lo haría un usuario.
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
describe("Button", () => {
it("renders with label text", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("applies variant classes", () => {
render(<Button variant="primary">Submit</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("bg-primary");
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});Queries: getBy vs queryBy vs findBy#
Aquí es donde los principiantes se confunden. Hay tres variantes de queries y cada una tiene un caso de uso específico:
getBy* — Retorna el elemento o lanza un error si no se encuentra. Úsalo cuando esperas que el elemento exista:
// Throws if no button found — test fails with a helpful error
const button = screen.getByRole("button", { name: "Submit" });queryBy* — Retorna el elemento o null si no se encuentra. Úsalo cuando estás afirmando que algo NO está presente:
// Returns null — doesn't throw
expect(screen.queryByText("Error message")).not.toBeInTheDocument();findBy* — Retorna una Promise. Úsalo para elementos que aparecen de forma asíncrona:
// Waits up to 1000ms for the element to appear
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();Queries con accesibilidad primero#
Testing Library proporciona estas queries en un orden de prioridad deliberado:
getByRole— La mejor query. Usa roles ARIA. Si tu componente no se puede encontrar por rol, podría tener un problema de accesibilidad.getByLabelText— Para elementos de formulario. Si tu input no tiene un label, arregla eso primero.getByPlaceholderText— Aceptable pero más débil. Los placeholders desaparecen cuando el usuario escribe.getByText— Para elementos no interactivos. Encuentra por contenido de texto visible.getByTestId— Último recurso. Úsalo cuando ninguna query semántica funcione.
// Prefer this
screen.getByRole("textbox", { name: "Email address" });
// Over this
screen.getByPlaceholderText("Enter your email");
// And definitely over this
screen.getByTestId("email-input");La clasificación no es arbitraria. Coincide con cómo la tecnología asistiva navega la página. Si puedes encontrar un elemento por su rol y nombre accesible, los lectores de pantalla también pueden. Si solo puedes encontrarlo por un test ID, podrías tener una brecha de accesibilidad.
User Events#
No uses fireEvent. Usa @testing-library/user-event. La diferencia importa:
import userEvent from "@testing-library/user-event";
describe("SearchInput", () => {
it("filters results as user types", async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
const input = screen.getByRole("searchbox");
await user.type(input, "vitest");
// user.type fires keydown, keypress, input, keyup for EACH character
// fireEvent.change just sets the value — skipping realistic event flow
expect(onSearch).toHaveBeenLastCalledWith("vitest");
});
it("clears input on escape key", async () => {
const user = userEvent.setup();
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByRole("searchbox");
await user.type(input, "hello");
await user.keyboard("{Escape}");
expect(input).toHaveValue("");
});
});userEvent simula la cadena completa de eventos que un navegador real dispararía. fireEvent.change es un solo evento sintético. Si tu componente escucha onKeyDown o usa onInput en lugar de onChange, fireEvent.change no disparará esos handlers pero userEvent.type sí.
Siempre llama a userEvent.setup() al principio y usa la instancia user retornada. Esto asegura el orden correcto de eventos y el seguimiento de estado.
Testing de interacciones de componentes#
Un test realista de componente se ve así:
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
describe("TodoList", () => {
it("adds a new todo item", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByRole("textbox", { name: /new todo/i });
const addButton = screen.getByRole("button", { name: /add/i });
await user.type(input, "Write tests");
await user.click(addButton);
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(input).toHaveValue("");
});
it("marks a todo as completed", async () => {
const user = userEvent.setup();
render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it("removes completed items when clear button is clicked", async () => {
const user = userEvent.setup();
render(
<TodoList
initialItems={[
{ id: "1", text: "Done task", done: true },
{ id: "2", text: "Pending task", done: false },
]}
/>
);
await user.click(screen.getByRole("button", { name: /clear completed/i }));
expect(screen.queryByText("Done task")).not.toBeInTheDocument();
expect(screen.getByText("Pending task")).toBeInTheDocument();
});
});Observa: sin inspección de estado interno, sin component.setState(), sin verificar detalles de implementación. Renderizamos, interactuamos, afirmamos sobre lo que el usuario vería. Si el componente refactoriza su gestión de estado interna de useState a useReducer, estos tests siguen pasando. Ese es el punto.
Testing de código asíncrono#
waitFor#
Cuando un componente se actualiza de forma asíncrona, waitFor sondea hasta que la aserción pase:
import { render, screen, waitFor } from "@testing-library/react";
it("loads and displays user profile", async () => {
render(<UserProfile userId="123" />);
// Initially shows loading
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Wait for content to appear
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// Loading indicator should be gone
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});waitFor reintenta el callback cada 50ms (por defecto) hasta que pasa o se agota el tiempo (1000ms por defecto). Puedes personalizar ambos:
await waitFor(
() => expect(screen.getByText("Done")).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);Fake Timers#
Cuando pruebas código que usa setTimeout, setInterval o Date, los fake timers te permiten controlar el tiempo:
describe("Debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("delays execution by the specified time", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
it("resets timer on subsequent calls", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
vi.advanceTimersByTime(200);
debounced(); // reset the timer
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
});Importante: siempre llama a vi.useRealTimers() en afterEach. Los fake timers que se filtran entre tests causan las fallas más confusas que jamás depurarás.
Testing con fake timers y renderizado asíncrono#
Combinar fake timers con testing de componentes React requiere cuidado. La planificación interna de React usa timers reales, así que a menudo necesitas avanzar los timers Y vaciar las actualizaciones de React juntas:
import { render, screen, act } from "@testing-library/react";
describe("Notification", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-dismisses after 5 seconds", async () => {
render(<Notification message="Saved!" autoDismiss={5000} />);
expect(screen.getByText("Saved!")).toBeInTheDocument();
// Advance timers inside act() to flush React updates
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
});
});Mocking de API con MSW#
Para probar la obtención de datos, Mock Service Worker (MSW) intercepta solicitudes de red a nivel de red. Esto significa que el código fetch/axios de tu componente se ejecuta exactamente como lo haría en producción — MSW solo reemplaza la respuesta de red:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
const server = setupServer(
http.get("/api/users", () => {
return HttpResponse.json([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@example.com",
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("UserList", () => {
it("displays users from API", async () => {
render(<UserList />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(await screen.findByText("Bob")).toBeInTheDocument();
});
it("shows error state when API fails", async () => {
// Override the default handler for this one test
server.use(
http.get("/api/users", () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
});
});MSW es mejor que hacer mock de fetch o axios directamente porque:
- El código real de obtención de datos de tu componente se ejecuta — pruebas la integración real.
- Puedes probar el manejo de errores sobreescribiendo handlers por test.
- Los mismos handlers funcionan tanto en tests como en modo de desarrollo del navegador (Storybook, por ejemplo).
Tests de integración#
Tests unitarios vs tests de integración#
Un test unitario aísla una sola función o componente y hace mock de todo lo demás. Un test de integración deja que múltiples unidades trabajen juntas y solo hace mock de los límites externos (red, sistema de archivos, bases de datos).
La verdad es: la mayoría de los bugs que he visto en producción ocurren en los límites entre unidades, no dentro de ellas. Una función funciona perfectamente aislada pero falla porque el llamador pasa datos en un formato ligeramente diferente. Un componente renderiza bien con datos mock pero se rompe cuando la respuesta real de la API tiene un nivel extra de anidamiento.
Los tests de integración detectan estos bugs. Son más lentos que los tests unitarios y más difíciles de depurar cuando fallan, pero dan más confianza por test.
Testing de múltiples componentes juntos#
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
// Only mock the API layer — everything else is real
vi.mock("@/lib/api", () => ({
checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
describe("Shopping Cart Flow", () => {
const renderCart = (initialItems = []) => {
return render(
<CartProvider initialItems={initialItems}>
<ShoppingCart />
</CartProvider>
);
};
it("displays item count and total", () => {
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
{ id: "2", name: "Mouse", price: 49.99, quantity: 2 },
]);
expect(screen.getByText("3 items")).toBeInTheDocument();
expect(screen.getByText("$179.97")).toBeInTheDocument();
});
it("updates quantity and recalculates total", async () => {
const user = userEvent.setup();
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
await user.click(incrementButton);
expect(screen.getByText("$159.98")).toBeInTheDocument();
});
it("completes checkout flow", async () => {
const user = userEvent.setup();
const { checkout } = await import("@/lib/api");
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
await user.click(screen.getByRole("button", { name: /checkout/i }));
expect(checkout).toHaveBeenCalledWith({
items: [{ id: "1", quantity: 1 }],
});
expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
expect(screen.getByText("ORD-123")).toBeInTheDocument();
});
});En este test, ShoppingCart y CartProvider y sus componentes internos (filas de items, selectores de cantidad, visualización de totales) todos trabajan juntos con código real. El único mock es la llamada a la API, porque no queremos hacer solicitudes de red reales en los tests.
Cuándo usar integración vs unitario#
Usa tests unitarios cuando:
- La lógica es compleja y tiene muchos casos extremos (un parser de fechas, una máquina de estados, un cálculo).
- Necesitas retroalimentación rápida sobre el comportamiento de una función específica.
- La unidad es relativamente aislada y no depende mucho de otras unidades.
Usa tests de integración cuando:
- Múltiples componentes necesitan trabajar juntos correctamente (un formulario con validación y envío).
- Los datos fluyen a través de varias capas (context → componente → componente hijo).
- Estás probando un flujo de usuario, no el valor de retorno de una función.
En la práctica, una suite de tests saludable tiene muchos tests de integración para funcionalidades y tests unitarios para utilidades complejas. Los componentes en sí se prueban a través de tests de integración — no necesitas un test unitario separado para cada componente pequeño si el test de integración lo ejercita.
Cobertura#
Ejecutando la cobertura#
vitest run --coverageNecesitarás un proveedor de cobertura. Vitest soporta dos:
# V8 — faster, uses V8's built-in coverage
npm install -D @vitest/coverage-v8
# Istanbul — more mature, more configuration options
npm install -D @vitest/coverage-istanbulConfigúralo en tu configuración de Vitest:
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/test/**",
"src/**/*.d.ts",
"src/**/types.ts",
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});Istanbul vs V8#
Cobertura V8 usa la instrumentación integrada del motor V8. Es más rápida porque no hay paso de transformación de código. Pero puede ser menos precisa para algunos casos extremos, especialmente alrededor de los límites de módulos ES.
Istanbul instrumenta tu código fuente con contadores antes de ejecutar los tests. Es más lenta pero más probada en batalla y da una cobertura de ramas más precisa. Si estás imponiendo umbrales de cobertura en CI, la precisión de Istanbul podría importar.
Yo uso V8 para desarrollo local (retroalimentación más rápida) e Istanbul en CI (imposición más precisa). Puedes configurar diferentes proveedores por entorno si es necesario.
Lo que realmente significa la cobertura#
La cobertura te dice qué líneas de código se ejecutaron durante los tests. Eso es todo. No te dice si esas líneas se probaron correctamente. Considera esto:
function divide(a: number, b: number): number {
return a / b;
}
it("divides numbers", () => {
divide(10, 2);
// No assertion!
});Este test da 100% de cobertura de la función divide. También no prueba absolutamente nada. El test pasaría si divide retornara null, lanzara un error o lanzara misiles.
La cobertura es un indicador negativo útil: baja cobertura significa que definitivamente hay rutas sin probar. Pero alta cobertura no significa que tu código esté bien probado. Solo significa que cada línea se ejecutó durante algún test.
Líneas vs ramas#
La cobertura de líneas es la métrica más común pero la cobertura de ramas es más valiosa:
function getDiscount(user: User): number {
if (user.isPremium) {
return user.yearsActive > 5 ? 0.2 : 0.1;
}
return 0;
}Un test con getDiscount({ isPremium: true, yearsActive: 10 }) toca todas las líneas (100% de cobertura de líneas) pero solo prueba dos de las tres ramas. La ruta isPremium: false y la ruta yearsActive <= 5 no están probadas.
La cobertura de ramas detecta esto. Rastrea cada posible camino a través de la lógica condicional. Si vas a imponer un umbral de cobertura, usa cobertura de ramas.
Ignorando código generado#
Cierto código no debería contarse en la cobertura. Archivos generados, definiciones de tipos, configuración — estos inflan tus métricas sin agregar valor:
// vitest.config.ts
coverage: {
exclude: [
"src/**/*.d.ts",
"src/**/types.ts",
"src/**/*.stories.tsx",
"src/generated/**",
".velite/**",
],
}También puedes ignorar líneas o bloques específicos en tu código fuente:
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
console.log("Debug info:", data);
}
/* v8 ignore stop */
// Or for Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }Usa esto con moderación. Si te encuentras ignorando grandes porciones de código, esas porciones necesitan tests o no deberían estar en el reporte de cobertura para empezar.
Testing en Next.js#
Mocking de next/navigation#
Los componentes de Next.js que usan useRouter, usePathname o useSearchParams necesitan mocks:
import { vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/en/blog",
useSearchParams: () => new URLSearchParams("?page=1"),
useParams: () => ({ locale: "en" }),
}));Para tests que necesitan verificar que la navegación fue llamada:
import { useRouter } from "next/navigation";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("LogoutButton", () => {
it("redirects to home after logout", async () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
forward: vi.fn(),
});
const user = userEvent.setup();
render(<LogoutButton />);
await user.click(screen.getByRole("button", { name: /log out/i }));
expect(mockPush).toHaveBeenCalledWith("/");
});
});Mocking de next-intl#
Para componentes internacionalizados usando next-intl:
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));Este es el enfoque más simple — las traducciones retornan la clave misma, así que t("hero.title") retorna "hero.title". En las aserciones, verificas la clave de traducción en lugar de la cadena traducida real. Esto hace que los tests sean independientes del idioma.
Si necesitas traducciones reales en un test específico:
vi.mock("next-intl", () => ({
useTranslations: () => {
const translations: Record<string, string> = {
"hero.title": "Welcome to My Site",
"hero.subtitle": "Building things for the web",
};
return (key: string) => translations[key] ?? key;
},
}));Testing de Route Handlers#
Los Route Handlers de Next.js son funciones regulares que toman un Request y retornan un Response. Son sencillos de probar:
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
describe("GET /api/users", () => {
it("returns users list", async () => {
const request = new NextRequest("http://localhost:3000/api/users");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
])
);
});
it("supports pagination via search params", async () => {
const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
const response = await GET(request);
const data = await response.json();
expect(data.page).toBe(2);
expect(data.items).toHaveLength(10);
});
});
describe("POST /api/users", () => {
it("creates a new user", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe("Alice");
});
it("returns 400 for invalid body", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});Testing de Middleware#
El middleware de Next.js se ejecuta en el edge y procesa cada solicitud. Pruébalo como una función:
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
const url = new URL(path, "http://localhost:3000");
return new NextRequest(url, { headers });
}
describe("middleware", () => {
it("redirects unauthenticated users from protected routes", async () => {
const request = createRequest("/dashboard");
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/login");
});
it("allows authenticated users through", async () => {
const request = createRequest("/dashboard", {
cookie: "session=valid-token",
});
const response = await middleware(request);
expect(response.status).toBe(200);
});
it("adds security headers", async () => {
const request = createRequest("/");
const response = await middleware(request);
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("handles locale detection", async () => {
const request = createRequest("/", {
"accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
});
const response = await middleware(request);
expect(response.headers.get("location")).toContain("/tr");
});
});Testing de Server Actions#
Los Server Actions son funciones asíncronas que se ejecutan en el servidor. Dado que son solo funciones, puedes probarlas directamente — pero podrías necesitar hacer mock de dependencias exclusivas del servidor:
vi.mock("@/lib/db", () => ({
db: {
user: {
update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
},
},
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
describe("updateProfile", () => {
it("updates user and revalidates profile page", async () => {
const formData = new FormData();
formData.set("name", "New Name");
formData.set("bio", "New bio text");
const result = await updateProfile(formData);
expect(result.success).toBe(true);
expect(revalidatePath).toHaveBeenCalledWith("/profile");
});
it("returns error for invalid data", async () => {
const formData = new FormData();
// Missing required fields
const result = await updateProfile(formData);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});Patrones prácticos#
Función render personalizada#
La mayoría de los proyectos necesitan los mismos providers envolviendo cada componente. Crea un render personalizado:
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
interface CustomRenderOptions extends RenderOptions {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
children: React.ReactNode;
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}) {
return (
<ThemeProvider defaultTheme={theme}>
<CartProvider initialItems={initialCartItems}>
{children}
</CartProvider>
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { theme, initialCartItems, ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => (
<AllProviders theme={theme} initialCartItems={initialCartItems}>
{children}
</AllProviders>
),
...renderOptions,
});
}
// Re-export everything from testing library
export * from "@testing-library/react";
export { renderWithProviders as render };Ahora cada archivo de test importa de tus utilidades personalizadas en lugar de @testing-library/react:
import { render, screen } from "@/test/utils";
it("renders in dark mode", () => {
render(<Header />, { theme: "dark" });
// Header and all its children have access to ThemeProvider and CartProvider
});Testing de custom hooks#
Vitest funciona con el renderHook de @testing-library/react:
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("starts at initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrements with floor", () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0); // doesn't go below min
});
});Testing de Error Boundaries#
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
const ThrowingComponent = () => {
throw new Error("Test explosion");
};
describe("ErrorBoundary", () => {
// Suppress console.error for expected errors
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("displays fallback UI when child throws", () => {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("renders children when no error", () => {
render(
<ErrorBoundary fallback={<div>Error</div>}>
<div>All good</div>
</ErrorBoundary>
);
expect(screen.getByText("All good")).toBeInTheDocument();
expect(screen.queryByText("Error")).not.toBeInTheDocument();
});
});Snapshot testing (con cuidado)#
Los snapshot tests tienen mala reputación porque la gente los usa como sustituto de aserciones reales. Un snapshot de toda la salida HTML de un componente es una carga de mantenimiento — se rompe con cada cambio de clase CSS y nadie revisa el diff cuidadosamente.
Pero los snapshots dirigidos pueden ser útiles:
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
// Good — small, targeted snapshot of a pure function's output
it("formats various currency values consistently", () => {
expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
// Bad — giant snapshot nobody will review
it("renders the dashboard", () => {
const { container } = render(<Dashboard />);
expect(container).toMatchSnapshot(); // Don't do this
});Los snapshots inline (toMatchInlineSnapshot) son mejores que los snapshots de archivo porque el valor esperado es visible directamente en el test. Puedes ver de un vistazo lo que retorna la función sin abrir un archivo .snap separado.
La filosofía de testing#
Prueba el comportamiento, no la implementación#
Este principio es tan importante que merece una sección dedicada. Considera dos tests para la misma funcionalidad:
// Implementation test — brittle, breaks on refactors
it("calls setState with new count", () => {
const setStateSpy = vi.spyOn(React, "useState");
render(<Counter />);
fireEvent.click(screen.getByText("+"));
expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
// Behavior test — resilient, tests what the user sees
it("increments the displayed count when plus button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "+" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});El primer test se rompe si cambias de useState a useReducer, aunque el componente funcione exactamente igual. El segundo test solo se rompe si el comportamiento del componente realmente cambia. No le importa cómo se gestiona el contador internamente — solo que hacer clic en "+" hace que el número suba.
La prueba de fuego es simple: ¿puedes refactorizar la implementación sin cambiar el test? Si sí, estás probando comportamiento. Si no, estás probando implementación.
El trofeo de testing#
Kent C. Dodds propuso el "Trofeo de Testing" como alternativa a la pirámide de testing tradicional:
╭─────────╮
│ E2E │ Pocos — costosos, lentos, alta confianza
├─────────┤
│ │
│ Integr. │ La mayoría — buena relación confianza-costo
│ │
├─────────┤
│ Unit │ Algunos — rápidos, enfocados, bajo costo
├─────────┤
│ Static │ Siempre — TypeScript, ESLint
╰─────────╯
La pirámide tradicional pone los tests unitarios en la base (muchos de ellos) y los tests de integración en el medio (menos). El trofeo invierte esto: los tests de integración son el punto óptimo. Aquí está por qué:
- Análisis estático (TypeScript, ESLint) detecta errores tipográficos, tipos incorrectos y errores lógicos simples gratis. Ni siquiera tienes que ejecutar nada.
- Tests unitarios son geniales para lógica pura compleja pero no te dicen si las piezas funcionan juntas.
- Tests de integración verifican que componentes, hooks y contexts funcionen juntos. Dan la mayor confianza por test escrito.
- Tests end-to-end verifican todo el sistema pero son lentos, inestables y costosos de mantener. Necesitas unos pocos para las rutas críticas pero no cientos.
Sigo esta distribución en la práctica: TypeScript detecta la mayoría de mis errores de tipo, escribo tests unitarios para utilidades y algoritmos complejos, tests de integración para funcionalidades y flujos de usuario, y un puñado de tests E2E para la ruta crítica (registro, compra, flujo de trabajo principal).
Lo que da confianza vs lo que pierde tiempo#
Los tests existen para darte confianza para hacer deploy. No confianza de que cada línea de código se ejecute — confianza de que la aplicación funciona para los usuarios. Son cosas diferentes.
Alta confianza, alto valor:
- Test de integración de un flujo de checkout — cubre validación de formulario, llamadas API, actualizaciones de estado, y UI de éxito/error.
- Test unitario de una función de cálculo de precio con casos extremos — punto flotante, redondeo, descuentos, valores cero/negativos.
- Test de que las rutas protegidas redirigen a usuarios no autenticados.
Baja confianza, pérdida de tiempo:
- Snapshot test de una página de marketing estática — se rompe cada vez que cambia el texto, no detecta nada significativo.
- Test unitario de que un componente pasa un prop a un hijo — estás probando React mismo, no tu código.
- Test de que se llama a
useState— estás probando el framework, no el comportamiento. - 100% de cobertura de un archivo de configuración — son datos estáticos, TypeScript ya valida su forma.
La pregunta que debes hacerte antes de escribir un test: "Si este test no existiera, ¿qué bug podría llegar a producción?" Si la respuesta es "ninguno que TypeScript no detectaría" o "ninguno que nadie notaría", el test probablemente no vale la pena escribirlo.
Testing como retroalimentación de diseño#
El código difícil de probar es generalmente código mal diseñado. Si necesitas hacer mock de cinco cosas para probar una función, esa función tiene demasiadas dependencias. Si no puedes renderizar un componente sin configurar proveedores de context elaborados, el componente está demasiado acoplado a su entorno.
Los tests son un usuario de tu código. Si tus tests luchan para usar tu API, otros desarrolladores también lo harán. Cuando te encuentres peleando con la configuración del test, tómalo como una señal para refactorizar el código bajo prueba, no para agregar más mocks.
// Hard to test — function does too much
async function processOrder(orderId: string) {
const order = await db.orders.findById(orderId);
const user = await db.users.findById(order.userId);
const inventory = await checkInventory(order.items);
if (!inventory.available) {
await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
return { success: false, reason: "out-of-stock" };
}
const payment = await chargeCard(user.paymentMethod, order.total);
if (!payment.success) {
await sendEmail(user.email, "payment-failed", { error: payment.error });
return { success: false, reason: "payment-failed" };
}
await db.orders.update(orderId, { status: "confirmed" });
await sendEmail(user.email, "order-confirmed", { orderId });
return { success: true };
}
// Easier to test — separated concerns
function determineOrderAction(
inventory: InventoryResult,
payment: PaymentResult
): OrderAction {
if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
if (!payment.success) return { type: "payment-failed", error: payment.error };
return { type: "confirmed" };
}La segunda versión es una función pura. Puedes probar cada combinación de inventario y resultados de pago sin hacer mock de una base de datos, proveedor de pagos o servicio de email. La lógica de orquestación (obtener datos, enviar emails) vive en una capa fina que se prueba a nivel de integración.
Este es el verdadero valor del testing: no detectar bugs después de que están escritos, sino prevenir malos diseños antes de que se hagan commit. La disciplina de escribir tests te empuja hacia funciones más pequeñas, interfaces más claras y una arquitectura más modular. Los tests son el efecto secundario. La mejora del diseño es el evento principal.
Escribe tests en los que confíes#
Lo peor que puede pasarle a una suite de tests no es que tenga lagunas. Es que la gente deje de confiar en ella. Una suite de tests con unos pocos tests inestables que fallan aleatoriamente en CI enseña al equipo a ignorar los builds rojos. Una vez que eso pasa, la suite de tests es peor que inútil — activamente proporciona falsa seguridad.
Si un test falla intermitentemente, arréglalo o elimínalo. Si un test es lento, aceléralo o muévelo a una suite de tests lentos separada. Si un test se rompe con cada cambio no relacionado, reescríbelo para probar comportamiento en lugar de implementación.
El objetivo es una suite de tests donde cada falla significa que algo real está roto. Cuando los desarrolladores confían en los tests, los ejecutan antes de cada commit. Cuando no confían en los tests, los saltan con --no-verify y hacen deploy con los dedos cruzados.
Construye una suite de tests en la que apostarías tu fin de semana. Nada menos vale la pena mantener.