ZOMBIES en TDD: qué tests escribir primero, con ejemplo
Sabes que en TDD se escribe el test antes que el código. Abres el editor, miras el problema y te bloqueas: ¿por qué test empiezo? Es tentador lanzarse a por el caso difícil, el que más miedo da. Y ahí empiezan los tests enormes que tardan media hora en ponerse en verde.
ZOMBIES responde a esa pregunta con un orden: primero lo más simple, después lo que se complica. Aquí verás qué significa cada letra según su autor, por qué no es una lista para seguir al pie de la letra y un ejemplo paso a paso con node:test.
Qué es ZOMBIES en TDD
ZOMBIES es un acrónimo que James Grenning, autor de Test-Driven Development for Embedded C, publicó en 2016 en el artículo «TDD Guided by ZOMBIES» de su blog. Cuenta que en 1999 vio a Kent Beck, Ron Jeffries y otros hacer TDD y le llamó la atención que siempre empezaban por los casos más simples y avanzaban poco a poco, con intención, hacia los complicados. El patrón ZOMBIES pone nombre a ese hábito.
Estas son las siete letras tal como las escribe Grenning, con nuestra traducción y explicación:
| Letra | En el original | Qué te pide |
|---|---|---|
| Z | Zero | Empieza por el caso cero: el objeto recién creado, la lista vacía, la entrada sin nada. |
| O | One | Pasa a un solo elemento: el paso de cero a uno. |
| M | Many (or More complex) | Generaliza: muchos elementos o un caso más complejo. |
| B | Boundary Behaviors | Busca los límites: lleno, vacío, el primero, el último, el valor justo donde cambia la regla. |
| I | Interface definition | Usa cada test para decidir cómo se llama y cómo se usa el código. |
| E | Exercise Exceptional behavior | Prueba lo que no debería pasar: errores, datos inválidos, abusos. |
| S | Simple Scenarios, Simple Solutions | Mantén simples los tests y el código que los hace pasar. |
La explicación de cada letra es nuestra; la de Grenning está en su artículo, con un ejemplo en C (y tests en C++).
El trío del principio tiene historia propia. Grenning explica que en su libro lo llamaba «0, 1, N», y que su amigo Tim Ottinger lo rebautizó como ZOM: según esa idea, en programación solo hay tres números importantes, el cero, el uno y muchos. El cero y el uno son casos especiales; «muchos» es la primera generalización.
No es una lista para seguir de arriba abajo
Este es el punto que más se pierde al resumir ZOMBIES. El propio Grenning avisa: «ZOMBIES no es el típico acrónimo secuencial. Solo es secuencial en parte» (traducción nuestra). Tiene dos ejes:
- Z, O y M son el orden. Los primeros tests siguen ese camino: cero, uno, muchos.
- B, I y E son lo que tienes en cuenta mientras recorres ese camino. Un límite puede aparecer en el caso cero (una cola recién creada está vacía, que ya es un límite), y la interfaz se decide desde el primer test.
- La S lo une todo: en cada paso, el escenario más simple y la solución más simple.
Piensa en la cola de una tienda con aforo. Para comprobar que la puerta funciona, no empiezas un sábado a mediodía con la tienda a reventar. Primero miras la tienda vacía, luego entra una persona, luego varias. Y en cada paso te fijas en lo mismo: si se llega al aforo (límite), cómo se entra y se sale (interfaz) y qué pasa si alguien intenta colarse con la tienda llena (lo excepcional). Ese es el ejemplo de este artículo.
Ejemplo de ZOMBIES paso a paso: una cola con aforo
Vamos a programar una ColaDeEspera con una capacidad máxima. Las personas entran por orden y se atienden por orden de llegada, como en la carnicería: el primero que llega es el primero en salir.
Es el mismo tipo de problema que usa Grenning en su artículo, un buffer circular en C (y tests en C++), contado con una cola de tienda y en JavaScript.
El ejemplo completo está en GitHub, en kata-zombies-cola-con-aforo, con un commit por cada paso de este artículo: puedes recorrer el historial y ver cada rojo y cada verde.
Necesitas Node.js 24 o posterior (trae el ejecutor de tests node:test, sin instalar nada), una carpeta con un package.json que contenga { "type": "module" } y dos ficheros: cola.js y cola.test.js. Los tests se lanzan con:
node --test
En cada paso escribimos un test, lo vemos fallar (rojo), escribimos lo mínimo para que pase (verde) y, con todo en verde, miramos si hay algo que limpiar (refactor). Si el ciclo te suena nuevo, empieza por qué es TDD o por el algoritmo de TDD.
Antes de empezar: la lista de tests, sin ordenar
Grenning empieza su ejemplo apuntando los escenarios que hay que probar, «sin ningún orden en particular» (traducción nuestra). Para la cola:
- vacía;
- llena;
- se atiende por orden de llegada;
- alguien intenta entrar con la cola llena;
- se intenta atender con la cola vacía;
- la capacidad no es válida.
Ahora ZOM pone el orden: primero lo que pasa con cero personas, luego con una, luego con varias. Los límites y los errores irán apareciendo por el camino.
Z: la cola recién creada
¿Qué es lo más simple que puede pasar? Que creas la cola y no ha entrado nadie. Debe estar vacía.
// cola.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ColaDeEspera } from './cola.js';
test('Z: una cola recién creada está vacía', () => {
const cola = new ColaDeEspera(3);
assert.equal(cola.estaVacia(), true);
});
Con una clase vacía, el test falla porque el método todavía no existe:
✖ Z: una cola recién creada está vacía
TypeError: cola.estaVacia is not a function
Fíjate en lo que acaba de pasar: sin escribir lógica, ya has decidido cómo se crea la cola (new ColaDeEspera(3), con su capacidad) y cómo se le pregunta si está vacía. Eso es la I de ZOMBIES trabajando desde el primer test.
Lo mínimo para que pase es devolver true. Parece hacer trampa, y es a propósito: el primer test de O lo desmontará.
// cola.js
export class ColaDeEspera {
constructor(capacidad) {}
estaVacia() {
return true;
}
}
El caso cero tiene otra cara: una cola recién creada tampoco está llena. Grenning hace lo mismo con su buffer: recién creado, está vacío y no está lleno.
test('Z: una cola recién creada no está llena', () => {
const cola = new ColaDeEspera(3);
assert.equal(cola.estaLlena(), false);
});
✖ Z: una cola recién creada no está llena
TypeError: cola.estaLlena is not a function
Se arregla con un estaLlena() que devuelve false. Dos tests en verde y la interfaz ya tiene forma.
O: entra una persona
El paso de cero a uno. Si entra Ana, la cola ya no está vacía.
test('O: con una persona dentro, la cola ya no está vacía', () => {
const cola = new ColaDeEspera(3);
cola.entrar('Ana');
assert.equal(cola.estaVacia(), false);
});
✖ O: con una persona dentro, la cola ya no está vacía
TypeError: cola.entrar is not a function
Basta con recordar a una persona:
export class ColaDeEspera {
#persona;
constructor(capacidad) {}
entrar(persona) {
this.#persona = persona;
}
estaVacia() {
return this.#persona === undefined;
}
estaLlena() {
return false;
}
}
La # delante de persona hace que el campo sea privado: solo la propia clase puede leerlo.
Falta la otra mitad de «uno»: si se atiende a Ana, sale Ana y la cola vuelve a quedar vacía.
test('O: se atiende a la única persona y la cola vuelve a quedar vacía', () => {
const cola = new ColaDeEspera(3);
cola.entrar('Ana');
assert.equal(cola.atender(), 'Ana');
assert.equal(cola.estaVacia(), true);
});
✖ O: se atiende a la única persona y la cola vuelve a quedar vacía
TypeError: cola.atender is not a function
atender() {
const persona = this.#persona;
this.#persona = undefined;
return persona;
}
Cuatro en verde. Una sola variable aguanta mientras solo haya una persona. Con el siguiente paso, no.
M: entran varias personas
Ahora generalizamos. Con tres personas, se atienden en el orden en que llegaron.
test('M: con varias personas, se atienden por orden de llegada', () => {
const cola = new ColaDeEspera(3);
cola.entrar('Ana');
cola.entrar('Luis');
cola.entrar('Marta');
assert.equal(cola.atender(), 'Ana');
assert.equal(cola.atender(), 'Luis');
assert.equal(cola.atender(), 'Marta');
});
El rojo dice justo lo que falla: la variable se quedó con la última en entrar.
✖ M: con varias personas, se atienden por orden de llegada
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
'Marta' !== 'Ana'
Toca cambiar la variable por una lista. push añade al final y shift saca el primero:
export class ColaDeEspera {
#personas = [];
constructor(capacidad) {}
entrar(persona) {
this.#personas.push(persona);
}
atender() {
return this.#personas.shift();
}
estaVacia() {
return this.#personas.length === 0;
}
estaLlena() {
return false;
}
}
Cinco en verde. Y el código ha quedado más corto que antes: la solución general era también la más simple. En el refactor, nada que limpiar.
B: el aforo
El límite de esta cola es su capacidad. Con capacidad 2, una persona no la llena; dos, sí.
test('B: la cola está llena justo al llegar a su capacidad', () => {
const cola = new ColaDeEspera(2);
cola.entrar('Ana');
assert.equal(cola.estaLlena(), false);
cola.entrar('Luis');
assert.equal(cola.estaLlena(), true);
});
✖ B: la cola está llena justo al llegar a su capacidad
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
false !== true
Por fin la capacidad sirve para algo:
#capacidad;
constructor(capacidad) {
this.#capacidad = capacidad;
}
estaLlena() {
return this.#personas.length === this.#capacidad;
}
Los límites se cruzan en las dos direcciones. Si la cola está llena y se atiende a alguien, deja de estarlo:
test('B: al atender a alguien de una cola llena, deja de estar llena', () => {
const cola = new ColaDeEspera(2);
cola.entrar('Ana');
cola.entrar('Luis');
cola.atender();
assert.equal(cola.estaLlena(), false);
});
Este pasa a la primera. No es un fallo del método: confirma un comportamiento que el código ya cumplía y lo deja por escrito para quien venga después. Para fiarte de él, rompe estaLlena un momento (por ejemplo, que devuelva siempre true) y míralo en rojo; después, deshaz el cambio.
I: la interfaz, decidida test a test
La I no ha tenido un paso propio porque ha estado en todos. Repasa lo que han decidido los tests sin que lo pensaras aparte:
- La cola se crea con su capacidad:
new ColaDeEspera(3). - Se pregunta con
estaVacia()yestaLlena(), que devuelventrueofalse. - Se entra con
entrar(persona)y se sale conatender(), que devuelve a quien toca.
Cada test es el primer sitio donde se usa el código, así que es el mejor momento para notar si un nombre es raro o si una llamada es incómoda. Grenning lo resume en su ejemplo: a esas alturas la interfaz está casi completa, y si fuera incómoda de usar ya lo sabríamos.
E: lo que no debería pasar
Con lo normal funcionando, toca lo excepcional. ¿Qué pasa si alguien intenta entrar con la cola llena? Decidimos que se lance un error:
test('E: nadie puede entrar en una cola llena', () => {
const cola = new ColaDeEspera(1);
cola.entrar('Ana');
assert.throws(() => cola.entrar('Luis'), { message: 'La cola está llena' });
assert.equal(cola.atender(), 'Ana');
assert.equal(cola.estaVacia(), true);
});
Las dos últimas líneas comprueban que Luis se ha quedado fuera de verdad: dentro solo estaba Ana.
✖ E: nadie puede entrar en una cola llena
AssertionError [ERR_ASSERTION]: Missing expected exception.
entrar(persona) {
if (this.estaLlena()) throw new Error('La cola está llena');
this.#personas.push(persona);
}
¿Y si se intenta atender con la cola vacía? Ahora shift devolvería undefined sin avisar. Mejor un error claro:
test('E: no se puede atender a nadie si la cola está vacía', () => {
const cola = new ColaDeEspera(3);
assert.throws(() => cola.atender(), { message: 'La cola está vacía' });
});
✖ E: no se puede atender a nadie si la cola está vacía
AssertionError [ERR_ASSERTION]: Missing expected exception.
atender() {
if (this.estaVacia()) throw new Error('La cola está vacía');
return this.#personas.shift();
}
Grenning toma otra decisión en su buffer: al leer de uno vacío, devuelve un valor por defecto que se elige al crearlo. Ninguna de las dos es la correcta en general; lo importante es que el test deja la decisión a la vista.
Queda un abuso más, de los que el propio Grenning apunta como pendientes: crear la cola con capacidad cero o negativa. Una cola sin sitio para nadie no tiene sentido, y tampoco una con sitio para persona y media.
test('E: la capacidad tiene que ser un número entero mayor que cero', () => {
assert.throws(() => new ColaDeEspera(0), RangeError);
assert.throws(() => new ColaDeEspera(-1), RangeError);
assert.throws(() => new ColaDeEspera(1.5), RangeError);
});
✖ E: la capacidad tiene que ser un número entero mayor que cero
AssertionError [ERR_ASSERTION]: Missing expected exception (RangeError).
constructor(capacidad) {
if (!Number.isInteger(capacidad) || capacidad < 1) {
throw new RangeError('La capacidad tiene que ser un entero mayor que 0');
}
this.#capacidad = capacidad;
}
Cada parte de la condición tiene su caso: sin Number.isInteger, la capacidad de 1,5 se aceptaría y el test volvería a rojo. Con todo en verde, el refactor: los tres errores tienen su mensaje y el código se lee bien, así que nada que limpiar.
S: simple en todo momento
La S tampoco es un paso final. Ha estado en cada decisión: un test con un solo caso, lo mínimo para ponerlo en verde, la variable antes que la lista hasta que un test pidió la lista. Grenning lo relaciona con una regla que le enseñó Kent Beck: haz lo más simple que pueda funcionar.
Este es el código completo:
// cola.js
export class ColaDeEspera {
#personas = [];
#capacidad;
constructor(capacidad) {
if (!Number.isInteger(capacidad) || capacidad < 1) {
throw new RangeError('La capacidad tiene que ser un entero mayor que 0');
}
this.#capacidad = capacidad;
}
entrar(persona) {
if (this.estaLlena()) throw new Error('La cola está llena');
this.#personas.push(persona);
}
atender() {
if (this.estaVacia()) throw new Error('La cola está vacía');
return this.#personas.shift();
}
estaVacia() {
return this.#personas.length === 0;
}
estaLlena() {
return this.#personas.length === this.#capacidad;
}
}
Y la ejecución de los diez tests, que se leen como la especificación de la cola:
node --test
✔ Z: una cola recién creada está vacía
✔ Z: una cola recién creada no está llena
✔ O: con una persona dentro, la cola ya no está vacía
✔ O: se atiende a la única persona y la cola vuelve a quedar vacía
✔ M: con varias personas, se atienden por orden de llegada
✔ B: la cola está llena justo al llegar a su capacidad
✔ B: al atender a alguien de una cola llena, deja de estar llena
✔ E: nadie puede entrar en una cola llena
✔ E: no se puede atender a nadie si la cola está vacía
✔ E: la capacidad tiene que ser un número entero mayor que cero
ℹ tests 10
ℹ pass 10
ℹ fail 0
Poner la letra delante del nombre de cada test es una ayuda para este artículo, no algo que pida ZOMBIES. En un proyecto real, el nombre basta.
ZOMBIES como guion para un agente de IA
Esta sección es interpretación nuestra, desde el enfoque IA Limpia; Grenning escribió ZOMBIES en 2016 pensando en personas.
Si le pides a un agente de código «hazme una cola con aforo y sus tests», es fácil que te devuelva de golpe la clase entera y un puñado de tests escritos después. Pasarán, porque se han escrito para ese código. Pero no sabes si cubren el cero, el límite o los errores, y tampoco has decidido tú cómo se comporta la cola cuando está vacía.
ZOMBIES te da un guion para ir de test en test:
- Pide un test cada vez, en orden ZOM. Un ejemplo de petición: «Escribe solo el test del caso cero: una cola recién creada está vacía. No escribas la implementación». Luego uno, luego muchos.
- Mira el rojo antes del verde. Si el test pasa a la primera sin que exista el código, no está comprobando lo que crees.
- En cada test, pregúntate por B, I y E. ¿Hay un límite aquí? ¿Me gusta cómo se llama esto? ¿Qué pasa si los datos son absurdos? Las decisiones de diseño, como lanzar un error o devolver un valor por defecto, las tomas tú y quedan en el test.
- Exige la S. Si para un test de «uno» el agente escribe la solución general con validaciones que nadie ha pedido, pídele lo mínimo.
- Rompe el código a propósito. Cambia el
===deestaLlenapor>: en nuestra ejecución fallan el test del aforo y el de entrar con la cola llena. Cambiashiftporpop: falla el de varias personas. Si rompes algo y nada falla, falta un test.
La idea de dar al agente la lista de tests y seguir el ciclo uno a uno ya se explica, con el ejemplo de Kent Beck, en TDD cuando programa la IA. ZOMBIES añade el orden de esa lista: empezar por lo simple hace que cada paso sea pequeño y fácil de revisar, también cuando lo escribe una máquina.
Errores frecuentes con ZOMBIES
- Leerlo como siete pasos seguidos. Dejar la interfaz o los errores para el final suele producir tests enormes. Los límites, la interfaz y los errores se miran en cada caso.
- Empezar por el caso difícil. Grenning cuenta que, en sus cursos, muchos programadores van directos a lo complicado (en su ejemplo, cuando el buffer da la vuelta) porque les enseñaron a atacar primero lo difícil. ZOMBIES propone aplazarlo con intención: anótalo en la lista de tests y llegarás a él con una base sólida.
- Saltarse el cero. Parece trivial, pero es el test que te obliga a decidir la interfaz sin lógica de por medio.
- Confundir «muchos» con «todos». Para M basta con el primer caso que obliga a generalizar: dos bastan; con tres se ve mejor el orden.
- Hacer complicada la S. Si un test necesita veinte líneas de preparación, el escenario no es simple. Pártelo o simplifica el diseño.
Tu siguiente paso
- Repite el ejemplo sin mirar y añade una función nueva siguiendo ZOMBIES:
cuantasPersonas(). ¿Cuál es su caso cero? - Elige una kata de la lista de katas para practicar TDD y escribe primero solo la lista de tests, ordenada con Z, O y M.
- Si quieres afinar el ciclo, repasa los pasos para aplicar TDD.
- Clona el repositorio del ejemplo y recorre su historial con
git log --reverse: cada commit es un paso de ZOMBIES.
Fuentes
- James Grenning, «TDD Guided by ZOMBIES», blog de Wingman Software, 31 de octubre de 2016. Leído el 9 de noviembre de 2026. De ahí salen las siete letras tal como las escribe, la lista de escenarios sin ordenar, los dos ejes (ZOM y BIE), el origen de ZOM con Tim Ottinger, la referencia a Kent Beck y la idea de aplazar lo difícil. Las citas son traducción nuestra; las demás explicaciones son un resumen nuestro.
- David Cozens, «TDD Zombies», COSOSO, 14 de enero de 2017. Leído el 9 de noviembre de 2026: un resumen del método que lo atribuye a Grenning.
- Documentación de
node:testpara Node 24 y deassert.throws. - Código: todo el código se ha ejecutado con Node.js 24.21.0 (funciona igual en Node 26.13.0) y
node:testel 9 de noviembre de 2026, test a test, en el orden del artículo. Las salidas son las reales, recortadas a las líneas que importan y sin los tiempos de ejecución.