learning-front

Nivel 8 · Calidad: que no se rompa en producción

End-to-end con Playwright, de concepto

Qué cubre un test end-to-end que ninguno de los anteriores alcanza, cuándo merece la pena, y por qué el frontend de 2026 suele preferir el trofeo de testing (Kent C. Dodds) a la pirámide clásica (Mike Cohn). Un smoke test de principio a fin.

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: sticky o un z-index mal 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:

typescript
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:

  1. page es el navegador. En vez de render(<Componente />), conduces una página real con page.goto, page.getByRole(...).click(), .fill(...). No montas un componente suelto: usas la app entera, servida.
  2. Las aserciones esperan solas (web-first assertions). En el capítulo anterior necesitabas findBy o waitFor para 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 un await 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:

typescript
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.

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.