Llegaste al final del Nivel 8. Toca el encargo que lo junta todo: coger tu Team Builder de React —el que construiste en el Nivel 7— y blindarlo para producción. No hay nada nuevo que aprender aquí; es síntesis: aplicar, de una vez y sobre tu propia app, toda la red de seguridad del nivel.
No cabe en el playground de esta página: una suite de tests de la app de verdad, su accesibilidad, su rendimiento y su seguridad viven en tu proyecto y en tu CI. El código que verás en las soluciones es el espinazo de la suite; el blindaje completo lo montas en local (está en el ejercicio).
Lo que vas a montar#
- Una suite real de la app. Tests de componente con Testing Library (queries por rol,
user-eventconawait) y un test del hook conrenderHookyact. - Integración con MSW de los caminos críticos: cargar, fichar, ver subir la cuenta, y el
camino de error —la API que falla—, esperando lo asíncrono con
findBy, sin sleeps. - Accesibilidad como test con axe (un suelo, no un techo), más queries por rol para lo que axe no juzga.
- Un paso de rendimiento: lazy/code-splitting de una ruta pesada y memoización donde paga, medido antes y después.
- Una revisión de seguridad: ningún secreto en el cliente, ningún HTML de usuario sin sanear.
- Una story de Storybook con una play function, y todo corriendo en CI como portería.
El reparto: el trofeo, hecho tu suite#
La pregunta de fondo del nivel era cuántos tests de cada tipo. Aplícalo aquí: la base son los tipos y el linter (gratis, en cada guardado); el grueso va a la integración de features (fichar toca lista, estado y red, y eso se prueba junto con MSW); los unitarios cubren la lógica pura del motor (Nivel 6); y arriba, muy pocos e2e sobre el camino crítico. Ese reparto es el que da más confianza sin que la suite se vuelva lenta y flaky.
Y recuerda que este proyecto se construye sobre el anterior: la app de React del Nivel 7 sigue intacta, y tú le añades la red de calidad encima. Acumulas una capa más —HTML, JS, tipos, React, y ahora calidad— sin descartar nada. Es exactamente como crece un proyecto de verdad.
Comprueba lo que sabes#
Este es el examen del Nivel 8: repasa de una sola pasada todo lo que has montado en el nivel, del testing de componentes a la cobertura en CI, pasando por la seguridad, el rendimiento, la accesibilidad y el sistema de diseño. Es más largo y más exigente que los quizzes intermedios; tómatelo como el examen que es.
Pregunta 1 de 19
Quieres comprobar que existe el botón "Fichar". ¿Por qué se prefiere getByRole("button", { name: "Fichar" }) antes que getByTestId("boton-fichar")?
Tu turno#
Este es un proyecto en local, sobre tu Team Builder de React. Clona la carpeta del ejercicio,
monta la suite y las capas de blindaje, y déjalo en verde con pnpm test y pnpm coverage,
corriendo en CI. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el salto
de un tier al siguiente: de una suite de piezas aisladas, a la integración con MSW, a las capas
transversales con la honestidad del trofeo.
Ejercicio · hazlo en local
Blinda el Team Builder
Coge tu Team Builder de React (Nivel 7) y dótalo de toda la red de seguridad del nivel, en local: una suite de tests de la app que corra en verde, integración con MSW de los caminos críticos, accesibilidad como test, un paso de rendimiento y una revisión de seguridad, todo corriendo en CI. No hay concepto nuevo: es juntar el nivel entero sobre tu propia app, sin descartar nada de lo anterior.
Paso 1: Una suite real de la app, en verde y en CI
- Tests de componente con Testing Library sobre TarjetaHeroe: localizas por rol y nombre accesible (getByRole), usas user-event con await y compruebas el dato derivado (el winrate), no solo que pinta.
- Un test del hook useFavoritos con renderHook y act, comprobando que alternar añade y quita.
- La suite corre en verde con pnpm test, configuras la cobertura (pnpm coverage) y todo se ejecuta en CI en cada PR.
Paso 2: Integración con MSW y los caminos críticos
- Un test de integración de PanelFichajes con MSW: carga los candidatos, fichar saca al héroe del pool y sube la cuenta, y el camino de error (la API responde 500) se prueba también.
- Esperas lo asíncrono con findBy, nunca con sleeps fijos, y arrancas cada test limpio (resetHandlers en afterEach).
- Miras la cobertura de RAMAS para encontrar agujeros (un catch sin probar, un if sin su else), sin perseguir el 100% a ciegas.
Paso 3: Las capas transversales, y honestidad sobre el trofeo
- Accesibilidad como test: axe sobre los componentes clave (un suelo, no un techo) más queries por rol para lo que axe no juzga. Un paso de rendimiento: lazy/code-splitting de una ruta pesada y memoización donde paga, medido antes/después. Una revisión de seguridad: ningún secreto en el cliente, ningún HTML de usuario sin sanear.
- Una story de Storybook de un componente clave con una play function (interaction test). La UI aguanta a 375px.
- Dejas la nota honesta del reparto: el grueso de la confianza en integración, unitarios para la lógica pura, muy pocos e2e sobre el camino crítico. La suite corre en CI como portería del equipo, no como tarea que alguien recuerda hacer.
Cómo hacerlo en local
Clona el repositorio del curso, entra en la carpeta del ejercicio y abre el
index.html en tu navegador. Toda tu solución va en
solucion.js.
git clone <repo>
cd exercises/nivel-8/proyecto-blindar-el-team-builder
# abre index.html en el navegador y edita solucion.js Ver soluciones
// El espinazo de la suite, tier OK: tests de componente (Testing Library) y un test del
// hook (renderHook). Es la base que protege la app cuando la toques dentro de seis meses.
import { describe, it, expect } from "vitest";
import { render, screen, renderHook, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TarjetaHeroe } from "./TarjetaHeroe";
import { useFavoritos } from "./useFavoritos";
// El héroe de prueba que reutilizan los tests de componente.
const tracer = {
nombre: "Tracer",
rol: "Daño",
partidas: 200,
victorias: 130,
} as const;
describe("TarjetaHeroe", () => {
it("muestra el nombre, el rol y el winrate calculado", () => {
// Arrange + Act: montamos la tarjeta con un héroe.
render(<TarjetaHeroe heroe={tracer} />);
// Assert: el nombre va en un encabezado (rol accesible "heading").
expect(screen.getByRole("heading", { name: "Tracer" })).toBeInTheDocument();
// 130 de 200 = 65 %: comprobamos el dato DERIVADO, no solo que pinta algo.
expect(screen.getByText("Winrate: 65%")).toBeInTheDocument();
});
it("alterna el botón de favorito al pulsarlo", async () => {
const user = userEvent.setup();
render(<TarjetaHeroe heroe={tracer} />);
// Arranca sin marcar: el botón ofrece "Marcar favorito".
const boton = screen.getByRole("button", { name: "Marcar favorito" });
// El usuario lo pulsa (await: user-event simula la interacción real, es asíncrono).
await user.click(boton);
// El mismo botón pasa a "Quitar de favoritos": el toggle funcionó.
expect(
screen.getByRole("button", { name: "Quitar de favoritos" }),
).toBeInTheDocument();
});
});
describe("useFavoritos", () => {
it("añade y quita un héroe de la lista", () => {
// renderHook monta el hook aislado; result.current es lo que devuelve.
const { result } = renderHook(() => useFavoritos());
// act envuelve el cambio de estado para que React lo procese antes de afirmar.
act(() => result.current.alternar("Tracer"));
expect(result.current.esFavorito("Tracer")).toBe(true);
// Volver a alternar el mismo nombre lo quita.
act(() => result.current.alternar("Tracer"));
expect(result.current.esFavorito("Tracer")).toBe(false);
});
}); Por qué este nivel
- El espinazo de la suite: tests de componente de TarjetaHeroe (por rol y nombre accesible, con user-event y await) y un test del hook useFavoritos con renderHook y act. Si alguien rompe el winrate o el toggle, salta.
- Su límite: prueba piezas aisladas. Que la feature de fichajes funcione entera —lista, estado y red colaborando— se queda fuera. Eso es integración: el siguiente tier.
// El espinazo de la suite, tier MEJOR: lo del tier OK (componente + hook) MÁS un test de
// integración de una feature entera con MSW, incluido el camino de error. Es la banda gruesa
// del trofeo: varias piezas y su estado colaborando, con la red simulada a nivel de red.
import {
describe,
it,
expect,
beforeAll,
afterEach,
afterAll,
} from "vitest";
import { render, screen, renderHook, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { TarjetaHeroe } from "./TarjetaHeroe";
import { useFavoritos } from "./useFavoritos";
import { PanelFichajes } from "./PanelFichajes";
const tracer = {
nombre: "Tracer",
rol: "Daño",
partidas: 200,
victorias: 130,
} as const;
describe("TarjetaHeroe", () => {
it("muestra el nombre, el rol y el winrate calculado", () => {
render(<TarjetaHeroe heroe={tracer} />);
expect(screen.getByRole("heading", { name: "Tracer" })).toBeInTheDocument();
expect(screen.getByText("Winrate: 65%")).toBeInTheDocument();
});
it("alterna el botón de favorito al pulsarlo", async () => {
const user = userEvent.setup();
render(<TarjetaHeroe heroe={tracer} />);
const boton = screen.getByRole("button", { name: "Marcar favorito" });
await user.click(boton);
expect(
screen.getByRole("button", { name: "Quitar de favoritos" }),
).toBeInTheDocument();
});
});
describe("useFavoritos", () => {
it("añade y quita un héroe de la lista", () => {
const { result } = renderHook(() => useFavoritos());
act(() => result.current.alternar("Tracer"));
expect(result.current.esFavorito("Tracer")).toBe(true);
act(() => result.current.alternar("Tracer"));
expect(result.current.esFavorito("Tracer")).toBe(false);
});
});
// MSW intercepta la red a NIVEL DE RED: el componente hace su fetch real y aquí decidimos
// la respuesta. El handler por defecto es 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 de la red y los pinta", async () => {
render(<PanelFichajes />);
// findByText ESPERA a que la API responda (asíncrono), sin un solo sleep.
expect(await screen.findByText("Genji")).toBeInTheDocument();
expect(screen.getByText("Mercy")).toBeInTheDocument();
});
it("fichar saca al héroe del pool y sube la cuenta del equipo", async () => {
const user = userEvent.setup();
render(<PanelFichajes />);
await screen.findByText("Genji");
// Ficha a Genji: sale del pool y la cuenta de fichados sube a 1.
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 />);
// El camino de error también se prueba: la feature lo dice sin romperse.
expect(await screen.findByText("Error al cargar")).toBeInTheDocument();
});
}); Por qué es mejor que el anterior
- Añade la banda gruesa del trofeo: un test de integración de PanelFichajes con MSW que recorre el camino crítico (cargar, fichar, ver subir la cuenta) Y el de error (la API responde 500), esperando lo asíncrono con findBy, sin sleeps.
- MSW intercepta a nivel de red: el componente hace su fetch real y tú decides la respuesta, sin acoplarte a fetch. resetHandlers en afterEach mantiene cada test limpio del anterior.
// El espinazo de la suite, tier EXCELENTE: lo anterior MÁS la accesibilidad como test (axe).
// Las otras capas del blindaje —rendimiento, seguridad y una story con play— no caben en un
// fichero de tests; van en la app y en Storybook, y se comentan abajo. Esto es el espinazo.
import {
describe,
it,
expect,
beforeAll,
afterEach,
afterAll,
} from "vitest";
import { render, screen, renderHook, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { axe } from "vitest-axe";
import { TarjetaHeroe } from "./TarjetaHeroe";
import { useFavoritos } from "./useFavoritos";
import { PanelFichajes } from "./PanelFichajes";
const tracer = {
nombre: "Tracer",
rol: "Daño",
partidas: 200,
victorias: 130,
} as const;
describe("TarjetaHeroe", () => {
it("muestra el nombre, el rol y el winrate calculado", () => {
render(<TarjetaHeroe heroe={tracer} />);
expect(screen.getByRole("heading", { name: "Tracer" })).toBeInTheDocument();
expect(screen.getByText("Winrate: 65%")).toBeInTheDocument();
});
it("alterna el botón de favorito al pulsarlo", async () => {
const user = userEvent.setup();
render(<TarjetaHeroe heroe={tracer} />);
const boton = screen.getByRole("button", { name: "Marcar favorito" });
await user.click(boton);
expect(
screen.getByRole("button", { name: "Quitar de favoritos" }),
).toBeInTheDocument();
});
it("no tiene violaciones de accesibilidad detectables por axe", async () => {
// axe es un SUELO, no un techo: caza lo automatizable (contraste, roles, alt que falta).
// Lo que no juzga (orden de foco, si el texto comunica) lo afirmas tú por rol y a mano.
const { container } = render(<TarjetaHeroe heroe={tracer} />);
expect(await axe(container)).toHaveNoViolations();
});
});
describe("useFavoritos", () => {
it("añade y quita un héroe de la lista", () => {
const { result } = renderHook(() => useFavoritos());
act(() => result.current.alternar("Tracer"));
expect(result.current.esFavorito("Tracer")).toBe(true);
act(() => result.current.alternar("Tracer"));
expect(result.current.esFavorito("Tracer")).toBe(false);
});
});
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 de la red y los pinta", async () => {
render(<PanelFichajes />);
expect(await screen.findByText("Genji")).toBeInTheDocument();
expect(screen.getByText("Mercy")).toBeInTheDocument();
});
it("fichar saca al héroe del pool y sube la cuenta del equipo", async () => {
const user = userEvent.setup();
render(<PanelFichajes />);
await screen.findByText("Genji");
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 () => {
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("sigue pasando la auditoría de axe con los candidatos cargados", async () => {
const { container } = render(<PanelFichajes />);
await screen.findByText("Genji");
// La accesibilidad aguanta también el estado con datos, no solo el inicial.
expect(await axe(container)).toHaveNoViolations();
});
}); Por qué es mejor que el anterior
- Cierra el espinazo con la accesibilidad como test: axe sobre TarjetaHeroe y sobre el panel ya cargado. Es un suelo, no un techo —caza lo automatizable—, así que se combina con las queries por rol que ya usas.
- Las otras capas del blindaje no caben en un fichero de tests, y por eso van en la app y en Storybook: el rendimiento (lazy/code-splitting + memoización medida), la seguridad (sin secretos en el cliente, sin HTML sin sanear) y una story con play function. Tenerlas todas es lo que separa "tiene tests" de "está blindado".
- Y la honestidad que cierra el nivel: el grueso de la confianza vive en la integración, los unitarios cubren la lógica pura y los e2e son pocos, sobre el camino crítico. Corriendo en CI, esta red es la portería del equipo: nadie sube a producción algo que la rompa.