Hasta aquí has probado piezas sueltas: un componente con Testing Library, un hook con
renderHook, una dependencia con un doble, la red con MSW. Pero una persona no usa “un componente”:
usa una feature —varias piezas que colaboran, con un estado compartido y datos que vienen de la
red—. Probar ese conjunto, siguiendo un flujo real de usuario, es un test de integración. Y es,
con diferencia, el tipo de test que más confianza da.
El trofeo de testing#
En el Nivel 6 viste la pirámide clásica: muchos unitarios abajo, algunos de integración en medio, pocos e2e arriba. El frontend moderno la recoloca en una figura distinta, el trofeo de testing (Kent C. Dodds), donde el peso baja al centro:
- Estático (la base ancha): TypeScript y ESLint. No ejecutan tu código, pero atrapan typos y errores de tipo gratis, en cada tecla.
- Unitario: una función o un hook aislado. Barato y rápido, pero prueba poco.
- Integración (la banda más gorda): varias piezas juntas siguiendo un flujo. Aquí va el grueso.
- End-to-end: la app entera en un navegador real. El más realista, pero lento y frágil.
¿Por qué la integración es la banda gorda? Porque es el punto dulce entre confianza y coste. Un unitario es baratísimo pero te asegura una pieza diminuta; un e2e te asegura todo pero cuesta mantenerlo y es lento. La integración prueba el flujo real —el usuario carga, busca, pulsa, ve el resultado— a un coste razonable. Es donde más rinde cada test que escribes.
Montar la feature entera, no las piezas#
Un test de integración hace lo contrario que un unitario: en vez de aislar una pieza, monta el
conjunto a propósito. Mira el demo: una sola feature, el ConstructorEquipo, con la lista del roster
y un panel de equipo dentro, compartiendo estado. El test renderiza la feature entera y recorre el flujo:
espera al roster de la red, ficha a dos héroes, retira a uno desde el panel y comprueba que la cuenta y la
lista del equipo se actualizan en cada paso.
Fíjate en lo que no hace el test: no prueba la lista por su cuenta, ni el botón por su cuenta, ni el panel por su cuenta. Los monta todos juntos y comprueba que el recorrido completo funciona. La única frontera que simula es la red, con MSW; todo lo de dentro es real.
La costura entre piezas: lo que un unitario no ve#
El bug que caza un test de integración vive en la costura entre componentes: ese punto donde una pieza le pasa algo a otra. Y la trampa es que cada pieza, por separado, funciona.
Imagina que el botón “Quitar” del panel borra a un héroe por su posición en la lista en vez de por su identidad. El test de la lista pasa: pinta los héroes. El test del botón pasa: dispara su evento. El test del panel pasa: muestra lo que le llega. Pero en cuanto retiras a uno, las posiciones se desplazan, y el siguiente “Quitar” borra al héroe equivocado.
¿Y qué? Que ese bug llega a producción con toda la suite en verde, porque ningún test aislado lo ve: solo aparece cuando las piezas trabajan juntas y retiras a más de uno en orden. El usuario quita a “Genji”… y se le borra “Mercy”. Un test de integración que recorra añadir → quitar → comprobar el panel revienta exactamente ahí. Por eso esta banda da tanta confianza: prueba lo que de verdad rompe.
Cuánto montar y qué simular#
La regla práctica: monta la feature, simula solo la frontera. La frontera de tu sistema es lo que
no controlas —casi siempre la red—, y para eso está MSW: el componente hace su fetch real y tú decides
la respuesta, sin tocar su código.
Lo que no debes hacer es doblar los componentes internos. Si sustituyes la lista, o el panel, por un doble “para aislarlos”, te quedas sin lo único que querías probar: que esas piezas colaboren de verdad. El doble fingiría que la costura está bien justo donde quieres comprobar que lo está.
Snapshots: una foto del resultado#
La segunda mitad del capítulo es otra herramienta: el snapshot testing. Un snapshot es una foto de una salida (un objeto, un mensaje de error, el HTML que pinta un componente). La primera vez, el test guarda esa foto; las siguientes, compara contra ella y falla si algo cambió.
Hay dos formas. La más útil para cosas pequeñas es el snapshot inline, que vive dentro del propio test (Vitest lo escribe entre backticks por ti):
import { it, expect } from "vitest";
import { resumenAlineacion } from "./alineacion";
it("resume la alineación en una ficha estable", () => {
// resumenAlineacion es una función PURA: mismos héroes, mismo resumen.
const ficha = resumenAlineacion([
{ nombre: "Genji", rol: "Daño" },
{ nombre: "Reinhardt", rol: "Tanque" },
{ nombre: "Mercy", rol: "Apoyo" },
]);
// La PRIMERA vez, Vitest rellena el snapshot entre los backticks por ti.
// Las siguientes, compara; si la ficha cambia, el test falla y enseña el diff.
expect(ficha).toMatchInlineSnapshot(`
{
"porRol": {
"Apoyo": 1,
"Daño": 1,
"Tanque": 1,
},
"tamaño": 3,
}
`);
});La otra forma, toMatchSnapshot(), guarda la foto en un fichero aparte (__snapshots__/….snap) en vez
de inline. Útil cuando la salida es grande, pero tiene un coste: la foto vive en otro fichero que es
fácil de no mirar al revisar un cambio.
Los snapshots se escriben así en tu proyecto con Vitest, pero dependen de ficheros (
.snap) o de reescribir el propio test, y eso el playground no lo guarda entre corridas. Por eso aquí los ves como código, no como un demo ejecutable: lo importante no es correrlos, es entender cuándo usarlos.
Cuándo el snapshot ayuda y cuándo solo hace ruido#
Un snapshot ayuda cuando la salida es pequeña, estable y serializable: una función pura que
devuelve un objeto, el mensaje de un Error, la configuración que genera un helper. Cambia solo cuando
cambia algo de verdad, y el diff es lo bastante corto para leerlo en cada revisión.
El snapshot se vuelve ruido cuando es una foto enorme —el HTML de un componente entero—:
// El anti-patrón: una foto del componente ENTERO.
it("el panel se ve bien", () => {
const { container } = render(<PanelFichajes />);
// Cientos de líneas de HTML serializado en un .snap que nadie revisa.
expect(container).toMatchSnapshot();
});¿Y qué? Que cualquier cambio legítimo —una clase, un espacio, un texto— rompe esa foto. El equipo
aprende a actualizarla a ciegas con vitest -u, sin leer el diff, porque “seguro que es el cambio de
antes”. Y el día que se cuela una regresión real, también la aprueban sin mirar. Llegado ese punto, el
snapshot pasa en verde aunque la UI esté rota y falla en rojo aunque esté bien: un test que miente y
solo añade fricción.
Seamos honestos: un snapshot de algo pequeño y estable (un Badge, una ficha de datos) puede valer
perfectamente. La línea es esta pregunta: ¿lo revisarías de verdad, entero, cada vez que cambie? Si la
respuesta es no, no es un test, es ruido; cámbialo por una aserción explícita (toHaveTextContent,
toBeInTheDocument) que diga qué compruebas.
Pruébalo#
Abre el demo de arriba y rompe la costura a propósito: en añadir, ignora el héroe recibido y añade
siempre roster[0]. Tras pulsar “Añadir Reinhardt”, en el equipo aparecerá “Genji” (y su “Quitar Genji”):
el héroe equivocado. El test del flujo se pondrá rojo —la cuenta sube, pero del que no es—, justo el
tipo de bug que ningún test de pieza aislada cazaría. Devuélvelo a su sitio y vuelve a verde.
Comprueba lo que sabes#
Pregunta 1 de 6
En los capítulos anteriores probabas un componente solo, o un hook solo. ¿Qué prueba un test de INTEGRACIÓN?
Tu turno#
Prueba PanelFichajes de integración: monta la feature entera, simula la red con MSW y recorre el flujo
del usuario (cargar, fichar, ver el equipo). Cuando lo tengas (o si te atascas), despliega las soluciones
y fíjate en el salto de un tier al siguiente: de comprobar que carga, a recorrer el flujo y su costura, y
de ahí al contrato completo con el error y el pool vacío.
Ejercicio · en esta página
Prueba PanelFichajes de integración
PanelFichajes es una feature: carga candidatos de una API y, al fichar uno, lo saca del pool y lo mete en el equipo subiendo la cuenta. No tocas el componente: escribe un test de INTEGRACIÓN que monte la feature entera, simule la red con MSW y compruebe el flujo del usuario (cargar -> fichar -> ver el equipo), no las piezas por separado.
Paso 1: La feature monta y carga
- Montas un servidor MSW con un handler para GET de los candidatos.
- Gestionas su ciclo de vida con beforeAll(listen), afterEach(resetHandlers) y afterAll(close).
- Renderizas <PanelFichajes /> y compruebas con findBy que pinta los candidatos que "devolvió la API".
Paso 2: El flujo del usuario y la costura
- Con userEvent fichas a un candidato pulsando su botón "Fichar".
- Compruebas la costura entre piezas: ese héroe sale del pool (su botón ya no está), aparece en "Fichados" y la cuenta sube a (1).
Paso 3: El contrato completo de la feature
- Pruebas el error: con server.use fuerzas un 500 solo en ese test y compruebas el aviso de error.
- Pruebas el caso de quedarse sin candidatos: fichas a todos y compruebas "No quedan candidatos" sin que la feature se rompa.
- Leyendo los tests se entiende el comportamiento de la feature en cada caso, sin haber tocado el componente.
Ver soluciones
import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
import { render, screen } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { PanelFichajes } from "./PanelFichajes";
// El backend de mentira: los candidatos que "devuelve la API".
const server = setupServer(
http.get("https://api.fichajes.test/candidatos", () =>
HttpResponse.json([
{ nombre: "Genji", rol: "Daño" },
{ nombre: "Mercy", rol: "Apoyo" },
]),
),
);
// Enciende la intercepción antes de todo, límpiala entre tests, apágala al final.
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("PanelFichajes (integración)", () => {
it("carga los candidatos y los pinta", async () => {
// Monta la feature entera; el componente hace su fetch real y MSW responde.
render(<PanelFichajes />);
// findBy espera a que la red responda y la lista se pinte.
expect(await screen.findByText("Genji")).toBeInTheDocument();
expect(screen.getByText("Mercy")).toBeInTheDocument();
});
}); Por qué este nivel
- Monta la feature entera y la prueba como un todo: un servidor MSW responde la red y findBy espera a que la lista se pinte. No aísla ninguna pieza: ese es el punto de un test de integración.
- Su límite: solo comprueba que carga. El valor de verdad —que el flujo del usuario (fichar, mover al equipo, subir la cuenta) funcione de cabo a rabo— se queda fuera.
import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { PanelFichajes } from "./PanelFichajes";
// El backend de mentira: los candidatos que "devuelve la API".
const server = setupServer(
http.get("https://api.fichajes.test/candidatos", () =>
HttpResponse.json([
{ nombre: "Genji", rol: "Daño" },
{ nombre: "Mercy", rol: "Apoyo" },
]),
),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("PanelFichajes (integración)", () => {
it("carga los candidatos y los pinta", async () => {
render(<PanelFichajes />);
expect(await screen.findByText("Genji")).toBeInTheDocument();
});
it("fichar a un candidato lo mueve del pool al equipo y sube la cuenta", async () => {
const user = userEvent.setup();
render(<PanelFichajes />);
// Espera a que cargue el pool antes de tocar nada.
await screen.findByText("Genji");
// Al inicio no hay nadie fichado.
expect(
screen.getByRole("heading", { name: "Fichados (0)" }),
).toBeInTheDocument();
// El gesto del usuario: ficha a Genji.
await user.click(screen.getByRole("button", { name: "Fichar Genji" }));
// La costura entre piezas: Genji ya no se puede fichar (salió del pool)...
expect(
screen.queryByRole("button", { name: "Fichar Genji" }),
).not.toBeInTheDocument();
// ...y la cuenta del panel ha subido a 1.
expect(
screen.getByRole("heading", { name: "Fichados (1)" }),
).toBeInTheDocument();
});
}); Por qué es mejor que el anterior
- Conduce el flujo con userEvent: ficha a un candidato y comprueba la COSTURA entre piezas a la vez —sale del pool, entra en el panel y la cuenta sube—. Eso es lo que ningún test unitario de cada pieza por separado ve.
- Le falta el otro lado del contrato: qué hace la feature cuando la API falla y cuando se acaban los candidatos. Esos caminos son donde más bugs se esconden.
import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { PanelFichajes } from "./PanelFichajes";
// El handler por defecto: el caso feliz, dos candidatos.
const server = setupServer(
http.get("https://api.fichajes.test/candidatos", () =>
HttpResponse.json([
{ nombre: "Genji", rol: "Daño" },
{ nombre: "Mercy", rol: "Apoyo" },
]),
),
);
beforeAll(() => server.listen());
// resetHandlers deshace los server.use de cada test: nadie contamina al siguiente.
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("PanelFichajes (integración)", () => {
it("carga los candidatos y los pinta", async () => {
render(<PanelFichajes />);
expect(await screen.findByText("Genji")).toBeInTheDocument();
expect(screen.getByText("Mercy")).toBeInTheDocument();
});
it("fichar mueve al héroe del pool al equipo y sube la cuenta", async () => {
const user = userEvent.setup();
render(<PanelFichajes />);
await screen.findByText("Genji");
// Ficha a Genji: sale del pool, entra en fichados, la cuenta sube.
await user.click(screen.getByRole("button", { name: "Fichar Genji" }));
expect(
screen.queryByRole("button", { name: "Fichar Genji" }),
).not.toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Fichados (1)" }),
).toBeInTheDocument();
});
it("avisa cuando la API falla", async () => {
// Solo en este test la API responde 500; afterEach lo deshace después.
server.use(
http.get(
"https://api.fichajes.test/candidatos",
() => new HttpResponse(null, { status: 500 }),
),
);
render(<PanelFichajes />);
expect(await screen.findByText("Error al cargar")).toBeInTheDocument();
});
it("avisa cuando el pool se queda sin candidatos", async () => {
const user = userEvent.setup();
render(<PanelFichajes />);
await screen.findByText("Genji");
// Ficha a todos: el pool debe quedarse vacío.
await user.click(screen.getByRole("button", { name: "Fichar Genji" }));
await user.click(screen.getByRole("button", { name: "Fichar Mercy" }));
// Y la feature lo dice, sin romperse, con los dos en el equipo.
expect(screen.getByText("No quedan candidatos")).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Fichados (2)" }),
).toBeInTheDocument();
});
}); Por qué es mejor que el anterior
- Cubre el contrato completo de la feature: carga, flujo de fichaje, error (500 con server.use) y pool vacío. Cada caso que se desvía del feliz usa server.use y confía en afterEach(resetHandlers) para no contaminar al resto.
- Leyendo la suite se entiende cómo se comporta la feature ante cualquier situación, sin haber tocado el componente. Eso es un test de integración que de verdad da confianza para tocar el código.