Tienes el trofeo de testing entero: los tipos y el linter en la base —que en TypeScript puedes
incluso testear como tales, con expectTypeOf/assertType de Vitest, para que un cambio que rompa la
forma de tus datos falle como un test más—, los unitarios del motor del Nivel 6, la banda gruesa de
tests de componentes e integración con Testing Library y MSW, y la punta —un smoke test e2e—. Pero falta la pregunta que decide si todo eso sirve de algo en un
equipo: ¿quién lo corre, y cuándo?
Ahora mismo la respuesta es “yo, cuando me acuerdo”. Y eso no escala: el día que alguien tiene prisa antes de una release, no corre la suite, y justo ahí es donde se cuela la regresión. Este capítulo va de convertir la suite en la portería automática del equipo —que corra sola en cada cambio— y de las dos cosas que deciden si esa portería es creíble: leer bien la cobertura y matar los tests flaky.
La suite que corre sola: integración continua#
La integración continua (CI) es una idea simple: un servidor corre tu suite —tests, linter, build— automáticamente en cada push y en cada pull request, no cuando alguien decide acordarse. Si algo se pone en rojo, el pull request se bloquea y no se puede mergear hasta arreglarlo.
La herramienta más común para esto en 2026 es GitHub Actions: defines un fichero en tu repo que describe qué correr y cuándo. El cómo a fondo —workflows, jobs, secrets, caché, despliegues— es del Nivel 9; aquí solo necesitas la idea, y la idea cabe en unas líneas:
# .github/workflows/ci.yml — lo verás A FONDO en el Nivel 9. Aquí, solo la idea:
name: CI
# en cada push y cada pull request...
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
# baja el código del repo
- uses: actions/checkout@v5
# instala dependencias
- run: pnpm install
# corre el linter...
- run: pnpm lint
# ...y la suite con su cobertura. Si algo falla (o la cobertura cae del umbral),
# el job termina en rojo y el PR se bloquea.
- run: pnpm test --coverage¿Y qué? Un test solo protege si se ejecuta. Una suite verde que vive en tu máquina y se corre “cuando toca” deja de correr el día que se te olvida —y los olvidos no son aleatorios: pasan cuando hay prisa, que es justo cuando más bugs entran—. CI quita la calidad de las manos de la memoria humana: la suite corre siempre, igual para todo el equipo, y un rojo no es una sugerencia, es una puerta cerrada. Eso es un quality gate.
Leer la cobertura sin obsesionarse con el 100%#
Ya viste la cobertura en el Nivel 6: el porcentaje de tu código que los tests llegan a ejecutar, con su trampa —ejecutar una línea no es comprobarla, así que un 100% de líneas no significa bien testeado—. En un equipo, esa cobertura se mide en CI en cada PR. Para leerla bien, el número que mira la mayoría (las líneas) es el que menos dice. El que de verdad encuentra huecos es la cobertura de ramas.
Una rama es cada salida de una decisión: las dos del if/else, cada lado de un &&, cada
case. Mira esta función del Team Builder y un test que la deja “100% cubierta”… o eso parece:
// nombreParaMostrar usa el apodo si lo hay; si no, cae a las siglas.
function nombreParaMostrar(siglas: string, apodo?: string): string {
// if en una sola línea: si no hay apodo, usa las siglas.
if (!apodo) apodo = siglas;
return apodo;
}
// El test solo pasa un apodo: nunca ejercita el caso "no hay apodo".
it("muestra el apodo cuando lo hay", () => {
expect(nombreParaMostrar("TRA", "Tracer")).toBe("Tracer");
});El informe de cobertura de algo así se ve más o menos como esto:
% Coverage report from v8
-----------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------|---------|----------|---------|---------|-------------------
motor.ts | 100 | 50 | 100 | 100 | 4
-----------|---------|----------|---------|---------|-------------------100% de líneas, 50% de ramas. La línea del if cuenta como cubierta porque la condición se
evalúa (el test pasa por ahí), pero la rama que asigna las siglas no se toma nunca. Y esa rama
sin probar es exactamente donde un cambio futuro metería un bug sin que ningún test se ponga en
rojo: si mañana alguien rompe el fallback a las siglas, la suite verde seguiría verde y nadie se
enteraría hasta que un héroe sin apodo apareciera en blanco en producción. Por eso, al leer un
informe, vas a la columna % Branch y a las Uncovered Lines: ahí están los agujeros
concretos, no en el porcentaje global que tranquiliza.
Ese número se puede convertir en parte de la portería: un umbral de cobertura
(coverage.thresholds en la config de Vitest) hace que CI falle si la cobertura de ramas baja
de, digamos, el 80%. Útil como suelo: evita que alguien borre tests y la cobertura se desplome
en silencio. Pero ojo con volverlo religión —forzar el 100% empuja a escribir tests huecos que
ejecutan código sin afirmar nada, solo para subir el número—. El umbral protege de caídas
accidentales; la calidad de verdad la ponen las aserciones, no el porcentaje.
Tests que no fallan en falso: el flaky#
Un test flaky es el que a veces pasa y a veces falla sin que el código haya cambiado. Ya le pusiste nombre con los sleeps fijos del e2e; aquí lo ves entero, porque es el enemigo número uno de la portería automática.
¿Y qué? Un flaky que falla al azar enseña al equipo un reflejo fatal: “relanza hasta que se ponga verde”. Si los rojos casi siempre son ruido, la gente deja de leerlos. Y el día que un rojo es una regresión real, también le dan a reintentar y la sueltan a producción. Una portería que nadie se cree no protege nada —solo añade fricción—. Por eso, en un equipo, un flaky no es una molestia menor: es una grieta en la única red de seguridad automática que tenéis.
El flaky tiene cuatro orígenes típicos. El primero es el más común, y lo puedes tocar:
- Estado compartido entre tests. Un dato que vive fuera de los tests y que uno deja “sucio” para el siguiente. El orden empieza a importar, y en cuanto el runner aleatoriza o paraleliza, el fallo aparece y desaparece.
- Dependencia del tiempo. Un
new Date()o unDate.now()dentro del test: el resultado cambia según el día u hora en que corra. - Dependencia del azar. Un
Math.random()cuyo resultado el test intenta afirmar. - Carreras asíncronas. Esperar con un tiempo fijo en vez de esperar a que la condición se cumpla
—los
waitForTimeoutdel e2e, o no usarfindBy/waitForcuando toca, del capítulo de async—.
Pruébalo: una suite que se contamina a sí misma#
Aquí está el primero —el estado compartido— en vivo. La suite arranca en rojo: dos tests
comparten un mismo equipo, y el segundo hereda lo que dejó el primero. Lee los comentarios y, para
ver la cura, descomenta el beforeEach:
Fíjate en la trampa: en este playground los tests corren en orden fijo, así que el fallo es
determinista —siempre el mismo—. Pero en una suite real, donde el runner aleatoriza el orden y
corre tests en paralelo, ese MISMO bug se vuelve flaky: a veces “B” corre antes que “A” y pasa, a
veces después y falla. La causa es idéntica; lo único que cambia es que el orden deja de ser tuyo. Y
la cura también es la misma: aislamiento de tests. Cada test arranca con datos frescos —con
beforeEach, o creando los datos dentro del propio test— y deja de importar quién corrió antes.
Las otras caras del flaky: tiempo y azar#
El estado compartido se arregla aislando. El tiempo y el azar se arreglan con la misma idea de fondo: que lo que varía entre por la puerta como un dato que tú controlas, en vez de leerlo del entorno. Esto se llama inyectar la dependencia, y devuelve el determinismo al test.
Con el tiempo: si la función lee el reloj por dentro, el test no tiene forma de fijarlo. Si en cambio recibe el “ahora” como parámetro, el test le pasa una fecha fija y el resultado es siempre el mismo:
// diasDesde recibe el "ahora" en vez de leer el reloj por dentro: así es testeable.
function diasDesde(fecha: Date, ahora: Date): number {
const ms = ahora.getTime() - fecha.getTime();
// milisegundos a días: 1000 ms × 60 s × 60 min × 24 h.
return Math.floor(ms / (1000 * 60 * 60 * 24));
}
// MAL: new Date() es AHORA. El resultado cambia según el día en que corra el test.
it("Tracer jugó hace poco (flaky)", () => {
const ultimaPartida = new Date("2026-06-25");
expect(diasDesde(ultimaPartida, new Date())).toBeLessThan(7);
});
// BIEN: las dos fechas son fijas. El resultado es siempre 3, corras hoy o en 2030.
it("calcula los días entre dos fechas", () => {
const ultimaPartida = new Date("2026-06-25");
const ahora = new Date("2026-06-28");
expect(diasDesde(ultimaPartida, ahora)).toBe(3);
});Cuando no puedes refactorizar la función para que reciba el reloj, el plan B son los fake timers
que viste en Async y dobles de test: vi.useFakeTimers() y vi.setSystemTime(...) congelan el
reloj a una fecha que tú eliges. Inyectar es más limpio; congelar es el comodín cuando no te dejan
tocar la función.
Con el azar, el patrón es idéntico. Una función que llama a Math.random() por dentro no se
puede afirmar: cambia en cada ejecución. La sacas fuera —el valor aleatorio entra como parámetro— y
en el test le pasas uno fijo:
// La función NO tira el dado: recibe el índice ya elegido. Pura y testeable.
function heroeEnPosicion(heroes: string[], indice: number): string {
return heroes[indice];
}
// En producción: heroeEnPosicion(roster, Math.floor(Math.random() * roster.length)).
// En el test: le pasas un índice fijo y compruebas exactamente, sin azar.
it("elige el héroe de la posición dada", () => {
expect(heroeEnPosicion(["TRA", "REI", "GEN"], 1)).toBe("REI");
});En los tres casos —estado, tiempo, azar— la moraleja es la misma que ya conoces del Nivel 6: una función pura, que solo depende de lo que recibe, es trivial de testear de forma determinista. El flaky casi siempre es una pista de que algo del entorno se coló dentro cuando debería haber entrado por la puerta.
Cómo cazar y aislar un flaky#
Cuando un test falla “a veces”, lo primero es reproducirlo. Dos técnicas:
- Córrelo en bucle. Lánzalo muchas veces seguidas con la opción
repeatsde Vitest —se la pasas al test (it("…", { repeats: 50 }, () => {…})) o la pones envitest.config.ts(test: { repeats: 50 }); ojo, querepeatses opción de test/config, no una flag de la CLI—: repite el test esas veces aunque pase, justo para provocar la inestabilidad y verla. Si de 50 vueltas falla 7, es flaky confirmado. No lo confundas con--retry, que hace lo contrario —reintenta un test fallido hasta que pase y lo da por verde—: eso esconde el flaky, no lo caza (es el reflejo de “relanza hasta verde” que erosiona la confianza, justo el que no quieres tener). - Solo vs. en suite. Si pasa cuando lo corres aislado pero falla dentro de la suite completa, casi seguro es estado compartido: alguien lo contamina (o él contamina a otro).
Y mientras lo arreglas, a veces vale ponerlo en cuarentena con .skip. Es una decisión
defendible a corto plazo —un flaky activo erosiona la confianza en TODA la suite, así que aislarlo
puede valer más que dejarlo molestando—. Pero es deuda: lo que ese test cubría queda sin
vigilar. Un .skip honesto lleva un comentario con el porqué y el compromiso de volver; un .skip
olvidado es un agujero permanente disfrazado de verde.
Comprueba lo que sabes#
Pregunta 1 de 8
Ya corres la suite en tu máquina antes de subir. ¿Por qué montarla además en CI?
Tu turno#
La suite que tienes delante arranca en rojo: todos sus tests comparten un mismo equipo, así que
el orden importa y el segundo falla por culpa del primero. Hazla determinista —que cada test
arranque limpio— y, de paso, cúbrele las ramas de rechazo que el camino feliz nunca toca. No tocas
motor.ts: el bug está en el test. Cuando lo tengas (o si te atascas), despliega las soluciones y
fíjate en el salto de un tier al siguiente: de volver a verde, a aislar de verdad, a una suite que
prueba también lo que debe rechazar.
Ejercicio · en esta página
Haz determinista una suite contaminada
Tienes una suite del motor del Team Builder que arranca en ROJO: todos sus tests comparten un mismo equipo, así que el orden importa y el segundo falla por culpa del primero. Aíslala para que sea determinista y, de paso, cúbrele las ramas de rechazo que el camino feliz nunca toca. No tocas motor.ts: el problema está en el test. El informe de % Branch lo verías en tu terminal con pnpm test --coverage (aquí no sale); en este ejercicio practicas el trabajo que mueve ese número: aislar la suite y cubrir las ramas que nadie probaba.
Paso 1: La suite vuelve a verde
- Das a cada test un equipo fresco con beforeEach, de modo que ninguno herede el estado del anterior y el orden deje de importar.
- La suite pasa a verde sin tocar motor.ts: el problema estaba en el test (estado compartido), no en el código probado.
Paso 2: Aislamiento real y aserciones con sentido
- Eliminas el equipo compartido por completo: cada test crea el suyo dentro, así no hay nada que resetear ni que se pueda contaminar.
- Afirmas el CONTENIDO del equipo con toEqual (qué héroes y en qué orden), no solo su longitud: un test que solo mira la longitud pasaría aunque fichara al héroe equivocado.
Paso 3: Determinista y con las ramas cubiertas
- Cubres las dos ramas de rechazo de fichar que el camino feliz nunca ejecuta: que lanza al fichar un duplicado y que lanza al séptimo héroe (toThrow).
- Compruebas que, al rechazar, el equipo NO cambió (sigue con los héroes que tenía): el rechazo es comportamiento que proteger, no solo una excepción.
- Nombras los tests por comportamiento ("rechaza al séptimo héroe"), no por implementación, y cada uno corre aislado.
Ver soluciones
// SOLUCIÓN ok — el arreglo mínimo: que cada test parta de un equipo fresco con
// beforeEach. Con eso, el orden deja de importar y la suite se pone en verde.
import { describe, it, expect, beforeEach } from "vitest";
import { crearEquipo, fichar } from "./motor";
describe("fichar", () => {
// equipo se declara aquí, pero se REASIGNA antes de cada test.
let equipo: string[];
// beforeEach corre ANTES de cada it: da a cada test un equipo nuevo, así que
// ninguno hereda lo que dejó el anterior. Es la cura del estado compartido.
beforeEach(() => {
equipo = crearEquipo();
});
it("ficha a un héroe", () => {
fichar(equipo, "TRA");
expect(equipo).toHaveLength(1);
});
it("un equipo recién creado está vacío", () => {
// Ahora SÍ: cada test arranca de cero, el orden deja de importar.
expect(equipo).toHaveLength(0);
});
}); Por qué este nivel
- El arreglo mínimo y correcto: beforeEach da a cada test un equipo nuevo, así que el orden deja de importar y la suite se pone en verde. La contaminación desaparece sin tocar el código probado: el bug estaba en el test.
- Su límite: equipo sigue siendo una variable compartida en el describe (un let que se reasigna). Funciona, pero todavía hay estado común rondando; el siguiente tier lo elimina del todo.
// SOLUCIÓN mejor — sin estado compartido en absoluto: cada test construye SUS
// propios datos. No hay nada que resetear porque no hay nada que se herede. Y las
// aserciones comprueban QUÉ contiene el equipo (toEqual), no solo su longitud.
import { describe, it, expect } from "vitest";
import { crearEquipo, fichar } from "./motor";
describe("fichar", () => {
it("añade el héroe a un equipo vacío", () => {
// Cada test crea su equipo dentro: aislamiento total, sin beforeEach siquiera.
const equipo = crearEquipo();
fichar(equipo, "TRA");
// toEqual comprueba el CONTENIDO real, no solo "tiene 1": un test que solo
// mira la longitud pasaría aunque fichara al héroe equivocado.
expect(equipo).toEqual(["TRA"]);
});
it("acumula varios héroes en el orden en que se fichan", () => {
const equipo = crearEquipo();
fichar(equipo, "TRA");
fichar(equipo, "REI");
expect(equipo).toEqual(["TRA", "REI"]);
});
}); Por qué es mejor que el anterior
- Sin estado compartido en absoluto: cada test construye su propio equipo. No hay nada que resetear porque no hay nada que se herede: el aislamiento más simple y el más robusto.
- Y las aserciones miran el CONTENIDO con toEqual, no solo la longitud: comprueban qué héroes hay y en qué orden. Su límite: solo prueba el camino feliz; las ramas de rechazo de fichar siguen sin tocarse.
// SOLUCIÓN excelente — determinista, aislada Y con las ramas cubiertas. Sin estado
// compartido, aserciones por contenido, nombres por comportamiento, y los dos tests
// que faltaban: los que ejercitan las ramas de RECHAZO de fichar (donde vive el bug).
import { describe, it, expect } from "vitest";
import { crearEquipo, fichar } from "./motor";
describe("fichar", () => {
it("añade el héroe a un equipo vacío", () => {
const equipo = crearEquipo();
fichar(equipo, "TRA");
expect(equipo).toEqual(["TRA"]);
});
it("rechaza fichar dos veces al mismo héroe", () => {
// La rama del duplicado: sin este test, esa línea de fichar nunca corre en la
// suite y la cobertura de ramas se queda por debajo del 100%. Justo ahí, en la
// rama sin probar, es donde un cambio futuro metería un bug sin que nadie lo vea.
const equipo = crearEquipo();
fichar(equipo, "TRA");
expect(() => fichar(equipo, "TRA")).toThrow();
// Y comprobamos que, al rechazar, el equipo NO cambió: sigue con un solo héroe.
expect(equipo).toEqual(["TRA"]);
});
it("rechaza al séptimo héroe cuando el equipo está lleno", () => {
// La otra rama: el equipo lleno. Llenamos con 6 y el 7º debe lanzar.
const equipo = crearEquipo();
["TRA", "REI", "GEN", "DVA", "ANA", "MER"].forEach((heroe) => fichar(equipo, heroe));
expect(() => fichar(equipo, "ZAR")).toThrow();
// El equipo se queda en 6: el rechazo no añadió al séptimo.
expect(equipo).toHaveLength(6);
});
}); Por qué es mejor que el anterior
- Determinista, aislado y con las ramas cubiertas: añade los dos tests que faltaban —el duplicado y el séptimo héroe—, justo las ramas que el camino feliz nunca ejecuta y donde la cobertura de ramas se quedaba corta. Ahí es donde un cambio futuro metería un bug en silencio.
- Además comprueba que, al RECHAZAR, el equipo no cambia: el rechazo es comportamiento, no un detalle. Una suite así es la que hace que la portería de CI signifique algo: cuando esté verde, te lo crees.