Probar el caso feliz demuestra que el código funciona cuando todo va bien. Pero los bugs casi nunca
están ahí: viven en los bordes. Este capítulo va de dos cosas: pensar esos bordes de forma
sistemática, y cubrirlos sin repetirte con it.each. Y de una tercera que se olvida:
testear que las cosas fallan como deben.
Pensar los casos límite#
En vez de improvisar, ten una lista mental que repasar para cada función. Un caso límite es una entrada en el extremo de lo posible:
- Vacío — una lista
[], un string"". - Uno solo — un único elemento (muchos bugs aparecen con uno o con cero).
- Frontera — el mínimo y el máximo: un winrate de
0(sin victorias) o de100(las gana todas). - Negativos / inesperados — un número negativo, un valor del tipo equivocado.
- Duplicados — dos héroes con el mismo nombre, dos winrates iguales.
No todos aplican a cada función, pero repasar la lista te hace pensar en lo que el caso típico nunca
toca. Para winrate, las fronteras claras son 0 y 100; para una lista, el vacío y el de un
elemento.
it.each: muchos casos, un solo test#
Probar winrate con seis entradas distintas con seis it casi idénticos es repetición que se
desincroniza en cuanto cambias algo. it.each resuelve esto: le pasas una tabla (un array de
filas) y genera un test por fila, con el mismo cuerpo.
Fíjate en el informe: cada fila sale como su propia línea, con su nombre. Los %i (o %s para
texto, %o para un objeto) son huecos que se rellenan, en orden, con los valores de la fila. Añadir
un caso nuevo es añadir una fila —no copiar y pegar un test entero—, y si uno falla, ves exactamente
cuál, no “el bloque entero”.
Testear que algo falla como debe#
Hasta ahora hemos comprobado que el código hace lo correcto. Pero a veces lo correcto es rechazar
una entrada. Recupera el smart constructor branded del Nivel 5: crearHeroeId solo deja existir
un id con el formato válido (2-3 letras mayúsculas) y lanza con cualquier otra cosa. Su trabajo
es, literalmente, rechazar lo inválido —así que hay que probar que lo hace—.
Una tabla de ids inválidos, cada uno comprobado con toThrow(); y otra de válidos, con
.not.toThrow(). El «¿y qué?» es importante: si un día alguien afloja esa validación y crearHeroeId
deja de lanzar, un id corrupto se colaría sin un solo error visible, y reventaría más adelante,
lejos de la causa. El test de rechazo es lo que mantiene esa puerta cerrada. Probar solo que acepta
lo válido deja la mitad del validador sin vigilar.
Comprueba lo que sabes#
Pregunta 1 de 5
¿Cuál de estas es una buena lista mental de casos límite a probar?
Tu turno#
Completa la tabla it.each de winrate con las fronteras, y añade los tests de crearHeroeId (que
rechaza lo inválido y acepta lo válido). 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
Casos límite con it.each, y validar que falla
Tienes winrate (lanza sin partidas) y crearHeroeId (branded, valida el formato). Cubre los casos frontera de winrate con una tabla it.each, y comprueba que crearHeroeId rechaza los ids inválidos (y acepta los válidos) también con it.each.
Paso 1: La tabla con las fronteras
- Completas el it.each de winrate con los casos frontera: 0 de 8 (→ 0) y 8 de 8 (→ 100).
- Cada fila usa %i en el nombre para que el informe muestre el caso concreto.
Paso 2: Validar que rechaza lo inválido
- Añades un describe de crearHeroeId con un it.each que prueba varios ids inválidos: minúsculas, demasiado largo, demasiado corto, vacío, con un dígito.
- Cada caso inválido se comprueba con expect(() => crearHeroeId(valor)).toThrow().
Paso 3: Las dos caras, y la frontera inválida de winrate
- Pruebas también que crearHeroeId ACEPTA los ids válidos (2-3 mayúsculas) sin lanzar, con .not.toThrow().
- Cubres la frontera inválida de winrate: sin partidas, lanza.
- Las tablas cubren los tipos de entrada inválida a propósito (no una sola), y los nombres dejan claro qué se prueba en cada fila.
Ver soluciones
// motor.test.ts — tier OK: una tabla de it.each con los casos frontera incluidos.
import { describe, it, expect } from "vitest";
import { winrate } from "./motor";
describe("winrate", () => {
// Cada fila es un caso: victorias, partidas, winrate esperado.
// Las dos últimas son las fronteras: sin victorias (0) y ganándolas todas (100).
it.each([
[6, 10, 60],
[2, 8, 25],
[0, 8, 0],
[8, 8, 100],
])("%i victorias de %i partidas da %i", (victorias, partidas, esperado) => {
expect(winrate(victorias, partidas)).toBe(esperado);
});
}); Por qué este nivel
- Una tabla de cuatro filas, fronteras incluidas, con un solo cuerpo de test. Mucho mejor que cuatro it copiados que se desincronizan en cuanto cambias algo.
- Su límite: solo prueba winrate. El validador crearHeroeId, cuyo trabajo es rechazar, todavía no se toca.
// motor.test.ts — tier mejor: la tabla de winrate y, además, que el smart
// constructor RECHAZA los ids inválidos (también con it.each).
import { describe, it, expect } from "vitest";
import { winrate, crearHeroeId } from "./motor";
describe("winrate", () => {
it.each([
[6, 10, 60],
[2, 8, 25],
[0, 8, 0],
[8, 8, 100],
])("%i victorias de %i partidas da %i", (victorias, partidas, esperado) => {
expect(winrate(victorias, partidas)).toBe(esperado);
});
});
describe("crearHeroeId", () => {
// Testear que las cosas FALLAN como deben es tan importante como el caso feliz.
// "gen" (minúsculas), "GENJI" (largo), "G" (corto), "" (vacío), "G3" (un dígito).
it.each([["gen"], ["GENJI"], ["G"], [""], ["G3"]])(
"rechaza el id inválido '%s'",
(valor) => {
expect(() => crearHeroeId(valor)).toThrow();
},
);
}); Por qué es mejor que el anterior
- Aparece la otra mitad: probar que crearHeroeId LANZA con cada tipo de id inválido. Testear el rechazo es testear que la protección funciona.
- La tabla de inválidos cubre variedad a propósito —minúsculas, largo, corto, vacío, un dígito—, no un solo caso: cada uno rompe la regla de una forma distinta.
// motor.test.ts — tier excelente: fronteras válidas e inválidas, y el smart
// constructor probado por las dos caras (rechaza lo malo, acepta lo bueno).
import { describe, it, expect } from "vitest";
import { winrate, crearHeroeId } from "./motor";
describe("winrate", () => {
it.each([
[6, 10, 60],
[2, 8, 25],
[0, 8, 0],
[8, 8, 100],
])("%i victorias de %i partidas da %i", (victorias, partidas, esperado) => {
expect(winrate(victorias, partidas)).toBe(esperado);
});
it("lanza si no hay partidas (frontera inválida)", () => {
expect(() => winrate(5, 0)).toThrow();
});
});
describe("crearHeroeId", () => {
// Una batería de ids inválidos: minúsculas, demasiado largo, demasiado corto,
// vacío, con un dígito, con un punto. Todos deben lanzar.
it.each([["gen"], ["GENJI"], ["G"], [""], ["G3"], ["D.VA"]])(
"rechaza el id inválido '%s'",
(valor) => {
expect(() => crearHeroeId(valor)).toThrow();
},
);
// Y los válidos NO deben lanzar (2-3 letras mayúsculas).
it.each([["GEN"], ["DVA"], ["RA"]])(
"acepta el id válido '%s'",
(valor) => {
expect(() => crearHeroeId(valor)).not.toThrow();
},
);
}); Por qué es mejor que el anterior
- Las dos caras del validador: rechaza lo inválido (toThrow) y acepta lo válido (.not.toThrow). Sin la segunda, un validador que rechazara TODO también pasaría los tests de rechazo.
- Añade la frontera inválida de winrate (sin partidas, lanza): la guarda de entrada también es comportamiento que proteger.
- Con it.each, añadir un caso nuevo es añadir una fila: la suite crece sin esfuerzo y el informe sigue legible, caso a caso.