Hasta aquí, “el test pasa” ha sido sinónimo de “bien”. No siempre lo es: un test puede pasar y aun así ser malo —frágil, acoplado al de al lado, o que en realidad no comprueba nada tuyo—. Este capítulo separa los tests que protegen de los que solo dan una falsa sensación de seguridad.
Los cinco principios FIRST#
Un buen test cumple cinco propiedades, que se recuerdan con el acrónimo FIRST:
- Fast (rápido). Corre en milisegundos, para que lo lances a cada cambio sin pereza. Las funciones puras del motor lo son por naturaleza.
- Isolated (aislado). No depende de otros tests ni del orden en que corran. Cada uno parte de cero.
- Repeatable (repetible). Mismo resultado siempre. Nada de azar, de la fecha de hoy o de un estado que cambie entre ejecuciones.
- Self-validating (autovalidado). El propio test dice si pasa o falla con un
expect. Si tienes que leer la salida para saber si fue bien, no es un test, es un script. - Timely (a tiempo). Escrito junto al código, no “algún día”. (En el próximo capítulo verás una forma de escribirlo incluso antes.)
Dos son las que más se incumplen: el aislamiento (la I de FIRST) y probar comportamiento en vez de implementación —que no es una letra de FIRST, sino la regla de oro que viste en Anatomía de un test—. Vamos a por el aislamiento.
Aislamiento: cada test, datos frescos#
El síntoma clásico de tests mal aislados: uno pasa cuando lo corres solo, pero falla con toda la suite (o al revés). Eso pasa cuando un test deja un estado —un array mutado, una variable compartida— que otro hereda. El orden los acopla, y un test que depende del orden no es fiable.
La cura es dar a cada test datos frescos, y para eso está beforeEach: una función que
corre antes de cada it.
El primer test hace pop() sobre heroes; el segundo, aun así, los ve completos. ¿Por qué? Porque
beforeEach reconstruye el array antes de cada uno. Quita el beforeEach mentalmente: el segundo
test vería un solo héroe (el que dejó el primero) y fallaría. Ese es el «¿y qué?» del aislamiento:
sin él, añadir o reordenar un test puede romper otro que no has tocado, y persiguiendo ese fallo
“fantasma” se va una tarde entera.
Menos ruido, menos repetición: un builder#
Repetir el objeto de prueba entero en cada test (cinco campos del héroe) tiene dos costes: ruido —cuesta ver qué mira cada test entre tanto campo— y mantenimiento —si el héroe cambia de forma, tocas todos los tests—. Un builder lo arregla: una función que da un dato válido por defecto y deja sobreescribir solo lo relevante.
heroe({ victorias: 0 }) grita “este test va de SIN victorias”; el resto de campos son ruido que el
builder esconde. Y si mañana el héroe gana un campo nuevo, lo añades en un solo sitio. Es DRY
aplicado a los datos de prueba, sin sacrificar legibilidad.
Anti-patrones: tests que no protegen#
Reconócelos para no escribirlos:
// 1) Probar el LENGUAJE, no tu código. Que 2 + 2 sea 4 no es cosa tuya:
// este test no protege nada de la aplicación.
it("suma", () => {
expect(2 + 2).toBe(4);
});// 2) Acoplarse a la IMPLEMENTACIÓN. Afirmar CÓMO se hizo (que se llamó a map)
// en vez de QUÉ se obtuvo: el test se rompe en un refactor que no cambia nada
// observable. Prueba el resultado, no el camino.// 3) Test frágil por datos volátiles. Si afirmas un objeto entero que incluye
// un campo que cambia (una fecha, un id aleatorio), el test fallará mañana
// sin que nada esté roto. Afirma solo lo estable, o controla ese campo.El hilo común: un buen test comprueba comportamiento observable y estable de tu código. Si prueba el lenguaje, la implementación interna o un dato que cambia solo, estorba más que protege.
Comprueba lo que sabes#
Pregunta 1 de 5
¿Qué resume el acrónimo FIRST sobre un buen test?
Tu turno#
Coge la suite de partida (funciona, pero repite y nombra mal) y conviértela en una buena: aíslala con
beforeEach, quita la repetición con un builder y nombra por comportamiento. Cuando lo tengas (o si
te atascas), despliega las soluciones y fíjate en el salto de un nivel al siguiente.
Ejercicio · en esta página
Convierte una suite que funciona en una buena
La suite de partida funciona, pero repite el mismo array en cada test y se llama "filtra Daño" / "filtra Tanque". Mejórala: aíslala con beforeEach, quita la repetición con un builder heroe(overrides) y nombra los tests por su comportamiento.
Paso 1: Independientes y bien nombrados
- Cada test prepara sus propios datos (ninguno depende de otro) y los nombres describen el comportamiento, no "filtra Daño".
- Las afirmaciones comparan el resultado real (toEqual / toHaveLength), no a ojo.
Paso 2: Aislamiento con beforeEach
- El array de prueba se construye en un beforeEach: cada test arranca con datos frescos, sin copiar el array en cada it.
- Un test podría modificar su copia sin afectar a los demás.
Paso 3: Builder, aislamiento y cero ruido
- Un helper heroe(overrides) crea los datos de prueba; cada test sobreescribe solo lo relevante.
- Pruebas también enriquecer (winrate 0 y caso típico) reutilizando el builder.
- Nada de aserciones que prueban el lenguaje (expect(2+2).toBe(4)); todo apunta al motor, y la suite se mantiene cambiando datos en un solo sitio.
Ver soluciones
// motor.test.ts — tier OK: tests independientes y nombres por comportamiento.
import { describe, it, expect } from "vitest";
import { filtrarPorRol, type Heroe } from "./motor";
describe("filtrarPorRol", () => {
it("devuelve solo los héroes del rol pedido", () => {
const heroes: Heroe[] = [
{ nombre: "Genji", rol: "Daño", partidas: 10, victorias: 6 },
{ nombre: "Reinhardt", rol: "Tanque", partidas: 8, victorias: 5 },
];
expect(filtrarPorRol(heroes, "Daño").map((h) => h.nombre)).toEqual(["Genji"]);
});
it("con 'todos' no descarta a nadie", () => {
const heroes: Heroe[] = [
{ nombre: "Genji", rol: "Daño", partidas: 10, victorias: 6 },
{ nombre: "Reinhardt", rol: "Tanque", partidas: 8, victorias: 5 },
];
expect(filtrarPorRol(heroes, "todos")).toHaveLength(2);
});
}); Por qué este nivel
- Renombra los tests por comportamiento ("devuelve solo los héroes del rol pedido") y compara el resultado de verdad. Ya es una suite legible.
- Su límite: sigue copiando el array entero en cada test. Funciona, pero si cambia la forma del héroe hay que tocar todos los tests.
// motor.test.ts — tier mejor: beforeEach da datos frescos, sin repetir el array.
import { describe, it, expect, beforeEach } from "vitest";
import { filtrarPorRol, type Heroe } from "./motor";
describe("filtrarPorRol", () => {
let heroes: Heroe[];
// beforeEach corre ANTES de cada it: cada test recibe un array nuevo, sin tocar.
beforeEach(() => {
heroes = [
{ nombre: "Genji", rol: "Daño", partidas: 10, victorias: 6 },
{ nombre: "Reinhardt", rol: "Tanque", partidas: 8, victorias: 5 },
];
});
it("devuelve solo los héroes del rol pedido", () => {
expect(filtrarPorRol(heroes, "Daño").map((h) => h.nombre)).toEqual(["Genji"]);
});
it("con 'todos' no descarta a nadie", () => {
expect(filtrarPorRol(heroes, "todos")).toHaveLength(2);
});
}); Por qué es mejor que el anterior
- El array se construye en beforeEach: cada test recibe una copia fresca. Aislamiento real: un test puede mutar sus datos sin estropear al siguiente.
- Además quita la repetición: el dato de prueba vive en un solo sitio. Si mañana el héroe gana un campo, lo añades una vez.
// motor.test.ts — tier excelente: builder para no repetir, beforeEach para aislar,
// nombres por comportamiento. La suite se lee y se mantiene sin esfuerzo.
import { describe, it, expect, beforeEach } from "vitest";
import { enriquecer, filtrarPorRol, type Heroe } from "./motor";
// Builder: crea un héroe de prueba con valores por defecto y deja sobreescribir
// solo lo que importa para cada test. Menos ruido, menos repetición.
function heroe(overrides: Partial<Heroe> = {}): Heroe {
return { nombre: "Genji", rol: "Daño", partidas: 10, victorias: 6, ...overrides };
}
describe("enriquecer", () => {
it("calcula el winrate como victorias entre partidas", () => {
// Solo nombramos lo relevante; el resto son defaults del builder.
expect(enriquecer([heroe({ partidas: 10, victorias: 6 })])[0].winrate).toBe(60);
});
it("da 0 cuando no hay victorias", () => {
expect(enriquecer([heroe({ victorias: 0 })])[0].winrate).toBe(0);
});
});
describe("filtrarPorRol", () => {
let equipo: Heroe[];
beforeEach(() => {
equipo = [
heroe({ nombre: "Genji", rol: "Daño" }),
heroe({ nombre: "Reinhardt", rol: "Tanque" }),
heroe({ nombre: "Mercy", rol: "Apoyo" }),
];
});
it("devuelve solo los héroes del rol pedido", () => {
expect(filtrarPorRol(equipo, "Tanque").map((h) => h.nombre)).toEqual([
"Reinhardt",
]);
});
it("devuelve [] cuando ningún héroe tiene el rol", () => {
// Filtramos fuera al tanque en ESTE test; gracias a beforeEach, el siguiente
// vuelve a tener el equipo completo: ningún test depende de otro.
const sinTanques = equipo.filter((h) => h.rol !== "Tanque");
expect(filtrarPorRol(sinTanques, "Tanque")).toEqual([]);
});
}); Por qué es mejor que el anterior
- El builder heroe(overrides) es lo que hace que cada test RESALTE lo que prueba: heroe({ victorias: 0 }) deja claro que ese caso va de "sin victorias", sin el ruido de los otros cuatro campos.
- beforeEach + builder juntos: datos frescos y mínimos por test. La suite cumple FIRST (aislada, repetible, autovalidada) y se lee como documentación.
- Si la forma del héroe cambia, se toca el builder y ya: la suite entera sigue compilando. Esa es la diferencia entre tests que se mantienen y tests que se abandonan.