Has subido el trofeo de testing entero salvo la punta. Tienes la base —los tipos y el linter—, los unitarios del motor del Nivel 6 y, desde el capítulo anterior, la banda gruesa: tests de integración que montan una feature entera con su estado y su red simulada con MSW. Todo eso corre en jsdom: un navegador de mentira, en Node, rapidísimo, que finge tener un DOM pero no pinta nada. Falta la cúspide: probar la app entera, en un navegador de verdad, como la usa una persona. Eso es un test end-to-end (e2e), y este capítulo va de qué cubre, cuánto cuesta y cuándo merece la pena.
Lo ves de concepto: un e2e necesita un navegador real y tu app servida de verdad, así que no cabe en el playground de esta página. El código que verás es real y lo escribes en tu proyecto (está en el ejercicio); aquí lo importante es entender el porqué.
Qué prueba un e2e que nada por debajo alcanza#
Un test de integración con Testing Library te da mucha confianza, pero corre en jsdom, no en un navegador. jsdom finge el DOM: no pinta nada, no aplica CSS de verdad, no navega entre páginas, no ejecuta el bundle de producción. Y la red la finge MSW. Eso es justo lo que lo hace rápido y determinista… y también lo que deja huecos que solo un navegador real ve:
- El navegador de verdad. El layout se pinta, el CSS se aplica, y un
position: stickyo unz-indexmal puesto puede tapar un botón. Para jsdom no hay altura ni capas: el botón “está ahí”. Un e2e intenta pulsarlo donde el usuario lo pulsaría, y se entera si no se puede. - El build de producción. El e2e corre contra tu app construida y servida (
vite build+preview, del Nivel 4), no contra tu código fuente. Si el bundle de producción rompe algo que en desarrollo iba —una ruta de assets, una variable de entorno que falta—, el e2e lo caza; la integración, que prueba tu código fuente, no. - La navegación real. Varias pantallas, la URL que cambia, el botón “atrás”, recargar la página: el routing del Nivel 7 funcionando de verdad, no simulado.
- La red real (o casi). El e2e habla con un backend real, o uno de pruebas, no con la mentira controlada de MSW. Comprueba que la integración con el servidor de verdad funciona.
En una frase: la integración prueba que tus piezas colaboran; el e2e prueba que el producto, montado y servido entero, funciona. Son cosas distintas, y por eso el e2e caza bugs que ninguno de abajo ve —a cambio de costar mucho más, como verás—.
Un ejemplo concreto del Team Builder: tu test de integración del capítulo anterior pasaba en verde.
Pero al desplegar, un <header> con position: sticky y demasiada altura tapaba el botón “Añadir”
de la primera tarjeta en móvil. En jsdom no hay altura ni sticky, así que el test no lo veía. Un
e2e que intenta pulsar ese botón en un navegador real falla —Playwright detecta que el botón
está tapado y no es “clicable”— justo donde la suite verde mentía.
Playwright: conducir un navegador de verdad#
La herramienta estándar de e2e en el frontend de 2026 es Playwright (de Microsoft). Automatiza un navegador real —Chromium, Firefox o WebKit— por código: abre páginas, escribe, pulsa y comprueba lo que aparece. Y trae una ventaja si vienes de Testing Library: ya conoces casi toda su API, porque Playwright adoptó la misma filosofía de localizadores accesibles. Mira un smoke test entero:
import { test, expect } from "@playwright/test";
// Un test e2e: abre la app de verdad y comprueba el camino crítico de punta a punta.
test("la app arranca y el roster carga", async ({ page }) => {
// Navega a la app SERVIDA (page.goto resuelve contra la baseURL del config).
await page.goto("/");
// getByRole es el MISMO localizador accesible que ya usabas con Testing Library,
// ahora dirigiendo un navegador real en vez del DOM falso de jsdom.
await expect(page.getByRole("heading", { name: "Team Builder" })).toBeVisible();
// El roster llega de la API real. toBeVisible() REINTENTA solo hasta que el héroe
// aparece: no necesitas findBy ni waitFor (eso es una web-first assertion).
await expect(page.getByRole("button", { name: /Añadir Tracer/ })).toBeVisible();
});Dos cosas nuevas respecto a Testing Library, y son las que importan:
pagees el navegador. En vez derender(<Componente />), conduces una página real conpage.goto,page.getByRole(...).click(),.fill(...). No montas un componente suelto: usas la app entera, servida.- Las aserciones esperan solas (web-first assertions). En el capítulo anterior necesitabas
findByowaitForpara esperar a que algo asíncrono apareciera. En Playwright,expect(locator).toBeVisible()reintenta automáticamente hasta un timeout: si el roster tarda 800 ms en cargar, la aserción espera esos 800 ms y sigue; si nunca aparece, falla al agotar el tiempo. Nunca metes unawait page.waitForTimeout(3000)(“espera 3 segundos”): un sleep fijo o se queda corto (y el test falla en falso en un CI lento) o sobra (y la suite se arrastra). La espera va dentro de la aserción.
Un smoke test: ¿arranca sin echar humo?#
El test de arriba es un smoke test. El nombre viene del hardware: enchufas la placa y miras si echa humo. Un smoke test de software es lo mismo: comprueba que lo esencial funciona —que la app arranca y el camino crítico va de principio a fin— sin entrar en cada detalle. Para el Team Builder, el camino crítico de punta a punta:
test("filtra, ficha y la cuenta del equipo sube", async ({ page }) => {
// Arranca en la home.
await page.goto("/");
// 1) Espera a que el roster real cargue (auto-waiting, sin sleeps).
await expect(page.getByRole("button", { name: /Añadir Tracer/ })).toBeVisible();
// 2) Filtra por nombre, escribiendo en el campo localizado por su etiqueta accesible.
await page.getByRole("textbox", { name: "Filtrar por nombre" }).fill("Tracer");
// 3) Ficha a Tracer y comprueba el resultado que VE el usuario: la cuenta sube a 1.
await page.getByRole("button", { name: /Añadir Tracer/ }).click();
await expect(page.getByRole("heading", { name: "Tu equipo (1)" })).toBeVisible();
});Fíjate en lo que el smoke test no hace: no prueba cada validación, cada estado de error, cada filtro posible. Recorre un camino, el que de verdad da valor —cargar, filtrar, fichar, ver el equipo—. Eso es deliberado, y la siguiente sección explica por qué.
El precio del e2e: lento, frágil, y por eso pocos#
Un e2e parece el test perfecto: prueba lo mismo que vive el usuario. ¿Por qué no cubrirlo todo así? Por el precio.
- Lento. Arrancar un navegador, servir la app, cargar páginas: cada e2e tarda segundos, no milisegundos. Un unitario corre en unos 5 ms; un e2e, cientos de veces más. Una suite de 300 e2e tarda lo que el equipo no está dispuesto a esperar en cada push.
- Frágil (flaky). Depende de cosas que se mueven: la red, el timing, el entorno, un dato que hoy está y mañana no. Un test flaky es el que a veces pasa y a veces falla sin que el código haya cambiado.
¿Y qué? Una suite e2e flaky que falla al azar enseña al equipo un reflejo fatal: “vuelve a
lanzar hasta que se ponga verde”. Si los fallos casi siempre son ruido, la gente deja de leerlos.
Y el día que un fallo es real —una regresión de verdad—, también le dan a “reintentar” y la
sueltan a producción. Es exactamente la misma enfermedad que el snapshot gigante que todos
actualizan a ciegas con -u: un test que el equipo deja de creerse no protege nada, solo añade
fricción. La cura no es aguantar el ruido reintentando: es escribir pocos e2e, sobre los
caminos críticos, sin sleeps fijos y con localizadores estables, para que cuando uno falle,
el equipo se lo crea y vaya a mirar.
Por eso la regla del e2e es menos es más: el checkout de una tienda, el login, el alta de un usuario, el camino que paga las facturas. Lo exhaustivo —validar cada campo, cada estado de error, cada rama— va más abajo, en unitarios e integración, que son baratos y deterministas. El e2e no compite con ellos: los remata.
Pirámide o trofeo: dónde poner el peso#
Esto nos lleva a la pregunta de fondo del nivel: ¿cuántos tests de cada tipo? Hay dos figuras para responderla.
La clásica es la pirámide de testing (Mike Cohn): muchos unitarios en la base, algunos de integración en medio, pocos e2e en la punta. Nació en una época de aplicaciones con mucho backend, donde automatizar un navegador era caro y lentísimo, así que tenía todo el sentido empujar el esfuerzo hacia abajo.
El frontend moderno la recoloca en el trofeo de testing (Kent C. Dodds), que ya viste en el capítulo anterior: el peso baja al centro, a la integración. ¿Por qué cambia en el front? Porque aquí la “unidad” que de verdad importa rara vez es una función suelta: es un componente colaborando con otros —el flujo del usuario—. Un test de integración con Testing Library prueba ese flujo a un coste razonable y da más confianza por línea escrita que un montón de unitarios sobre piezas diminutas. Y herramientas como MSW hicieron barato simular la red sin un navegador real.
Pero fíjate en lo que las dos figuras comparten: el e2e es la punta fina en ambas. La pirámide no estaba equivocada —respondía a su época—; el trofeo solo ajusta el reparto del medio para el frontend de hoy. En las dos, el e2e es pocos, caros y arriba del todo. Tu smoke test del Team Builder es exactamente eso: una o dos pruebas que confirman que el producto entero, montado y servido, no echa humo.
Comprueba lo que sabes#
Pregunta 1 de 8
Tu test de integración (Testing Library + MSW) del capítulo anterior pasa en verde. ¿Qué prueba un e2e que ese test, por bien hecho que esté, NO alcanza?
Tu turno#
Este ejercicio se hace en local, sobre tu Team Builder de React: un e2e necesita un navegador real y la app servida de verdad. Instala Playwright, escribe un smoke test del camino crítico y resiste la tentación de llevarlo todo a e2e. Cuando lo tengas (o si te atascas), despliega las soluciones y fíjate en el salto de un tier al siguiente: de comprobar que arranca, a recorrer el flujo, a un único e2e resiliente que sabe qué no debe probar.
Ejercicio · hazlo en local
Escribe el smoke test e2e del Team Builder
En tu Team Builder de React —en local: un e2e necesita un navegador real y la app servida de verdad, no cabe en el editor de esta página—, instala Playwright y escribe un smoke test end-to-end. La gracia no es cubrirlo todo: es escribir POCOS e2e sobre el camino crítico. Sube de tier: de comprobar que la app arranca, a recorrer el flujo del usuario (filtrar, fichar, ver subir la cuenta), a un único e2e resiliente —sin sleeps fijos, con localizadores estables— y la nota honesta de por qué no llevas todo a e2e.
Paso 1: El smoke test arranca
- Instalas Playwright (npm init playwright) y, en playwright.config.ts, configuras baseURL y webServer para que page.goto("/") sirva tu app construida.
- Escribes un test que abre la app con page.goto("/") y comprueba con getByRole que el encabezado "Team Builder" está visible.
- Compruebas que el roster real carga (un botón "Añadir <héroe>" visible) con una web-first assertion (toBeVisible), sin un solo waitForTimeout.
Paso 2: El camino crítico de punta a punta
- Recorres el flujo del usuario: filtras por nombre, fichas a un héroe y compruebas que la cuenta del equipo sube ("Tu equipo (1)").
- Compruebas también el negativo: tras filtrar, el héroe que no coincide deja de estar visible (toBeHidden, que Playwright también reintenta).
- Localizas todo por rol y nombre accesible (getByRole), nunca por clases CSS.
Paso 3: Resiliente y honesto sobre su alcance
- Mantienes UN solo e2e sobre el camino crítico; no intentas cubrir cada validación, error y filtro con e2e (eso vive en unitarios e integración).
- Cero sleeps fijos: te apoyas solo en las web-first assertions, que esperan lo justo, y arrancas limpio con beforeEach.
- Dejas la nota honesta: por qué este e2e es la punta del trofeo y el grueso de la confianza vive más abajo (Testing Library + MSW).
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/e2e-con-playwright
# abre index.html en el navegador y edita solucion.js Ver soluciones
import { test, expect } from "@playwright/test";
// Smoke test: ¿arranca el Team Builder sin echar humo? Abre la app de verdad en un
// navegador real y comprueba que lo esencial está vivo. Es el e2e mínimo que vale la pena.
test("la app arranca y el roster carga", async ({ page }) => {
// Navega a la app servida (page.goto resuelve contra la baseURL del config).
await page.goto("/");
// El título principal. getByRole es el MISMO localizador accesible que usabas con
// Testing Library, ahora dirigiendo un navegador real en vez de un DOM de jsdom.
await expect(
page.getByRole("heading", { name: "Team Builder" }),
).toBeVisible();
// El roster llega de la API real. NO hace falta findBy ni waitFor: toBeVisible()
// reintenta solo hasta que el héroe aparece (eso es una web-first assertion).
await expect(
page.getByRole("button", { name: /Añadir Tracer/ }),
).toBeVisible();
});
</content> Por qué este nivel
- Un smoke test de verdad: abre la app en un navegador real y comprueba que lo esencial está vivo (el título y el roster cargado). page.goto sirve tu app gracias al webServer del config, y toBeVisible reintenta hasta que la API responde, sin un solo sleep.
- Su límite: solo prueba que ARRANCA. Que el flujo que da valor —filtrar, fichar, ver el equipo— funcione de punta a punta se queda fuera. Es el siguiente tier.
import { test, expect } from "@playwright/test";
// El camino crítico de principio a fin: cargar, filtrar, fichar y ver crecer el equipo.
// Es UN flujo de usuario —el que de verdad da valor a la app—, no piezas sueltas.
test("filtra un héroe, lo ficha y la cuenta del equipo sube", async ({ page }) => {
// Arranca en la home.
await page.goto("/");
// Espera a que el roster real esté cargado antes de tocar nada (auto-waiting, sin sleeps).
await expect(
page.getByRole("button", { name: /Añadir Tracer/ }),
).toBeVisible();
// Filtra por nombre escribiendo en el campo, localizado por su etiqueta accesible.
await page
.getByRole("textbox", { name: "Filtrar por nombre" })
.fill("Tracer");
// Comprueba la costura de la UI: tras filtrar, Reinhardt deja de estar visible.
// El negativo se afirma con toBeHidden(): Playwright también lo reintenta.
await expect(
page.getByRole("button", { name: /Añadir Reinhardt/ }),
).toBeHidden();
// Ficha a Tracer pulsando su botón.
await page.getByRole("button", { name: /Añadir Tracer/ }).click();
// El resultado de cara al usuario: la cuenta del equipo, que vive en el encabezado
// del panel, sube a 1. Afirmas lo que el usuario VE, no el estado interno.
await expect(
page.getByRole("heading", { name: "Tu equipo (1)" }),
).toBeVisible();
});
</content> Por qué es mejor que el anterior
- Recorre el camino crítico completo como una persona: filtra, comprueba que el que no coincide desaparece (toBeHidden, también reintentado), ficha y verifica el resultado que VE el usuario —la cuenta del equipo sube—. Todo por localizadores accesibles, los que no se rompen con un cambio de CSS.
- Le falta el remate de disciplina: dejar explícito que esto es UN e2e y que no se cubre todo aquí. Sin esa frontera, la tentación es llenar la suite de e2e y acabar con algo lento y flaky.
import { test, expect } from "@playwright/test";
// Un ÚNICO e2e sobre el camino que paga las facturas: que la app, montada y servida de
// verdad, arranca y el flujo crítico (cargar -> filtrar -> fichar) funciona de cabo a rabo
// en un navegador real. No intentamos cubrirlo TODO con e2e: lo barato y exhaustivo ya vive
// más abajo en el trofeo (unitarios, integración con Testing Library + MSW). Este test es
// solo la red que confirma que el conjunto no echa humo.
test.describe("Team Builder — smoke del camino crítico", () => {
// Antes de cada test, arranca limpio en la home: sin estado heredado entre tests.
test.beforeEach(async ({ page }) => {
await page.goto("/");
});
test("carga el roster, filtra y ficha a un héroe", async ({ page }) => {
// Localiza por ROL y nombre accesible, no por clase CSS ni id frágil. Si mañana
// cambia el diseño pero el botón sigue siendo "Añadir Tracer", el test sigue verde.
// Atarse a un .btn-primary--v2 es pedir un test que se rompe con cada retoque.
const añadirTracer = page.getByRole("button", { name: /Añadir Tracer/ });
// web-first assertion: reintenta hasta que el roster real cargue. CERO waitForTimeout:
// un sleep fijo o se queda corto (test flaky en CI lento) o sobra (suite lenta). El
// reintento automático espera justo lo necesario, ni un ms más.
await expect(añadirTracer).toBeVisible();
// Filtra y comprueba la costura de la UI: queda Tracer, desaparece Reinhardt.
await page
.getByRole("textbox", { name: "Filtrar por nombre" })
.fill("Tracer");
await expect(
page.getByRole("button", { name: /Añadir Reinhardt/ }),
).toBeHidden();
// Ficha y verifica el resultado de cara al usuario: la cuenta del equipo sube a 1.
await añadirTracer.click();
await expect(
page.getByRole("heading", { name: "Tu equipo (1)" }),
).toBeVisible();
});
});
</content> Por qué es mejor que el anterior
- La disciplina del capítulo, hecha código y comentarios: un único e2e sobre el camino que paga las facturas, localizadores por rol y texto (resisten un rediseño), CERO waitForTimeout (las web-first assertions esperan lo justo) y beforeEach para arrancar limpio, sin estado heredado entre tests.
- Y la nota honesta, que es la lección entera: este e2e es la PUNTA del trofeo —confirma que el conjunto montado y servido no echa humo—; el grueso de la confianza, exhaustivo y barato, vive más abajo en unitarios e integración con Testing Library + MSW. Saber qué NO llevar a e2e es tan importante como saber escribirlo.