learning-front

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

Cobertura en CI y tests que no fallan en falso

Correr la suite en cada PR, leer la cobertura sin obsesionarse con el 100%, y por qué aparecen los tests flaky (y cómo aislarlos). El testing como portería automática del equipo, no como tarea manual que alguien se acuerda de hacer.

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:

yaml
# .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:

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

text
 % 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 un Date.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 waitForTimeout del e2e, o no usar findBy/waitFor cuando 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:

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

typescript
// 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 repeats de Vitest —se la pasas al test (it("…", { repeats: 50 }, () => {…})) o la pones en vitest.config.ts (test: { repeats: 50 }); ojo, que repeats es 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.
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.