Saltar a contenido

Testing

De un vistazo

Materia: Capa 1 · Código correcto y mantenible · Tiempo de lectura: ~35 min · Requisitos previos: Capa 0 y la lección de Prácticas de ingeniería.

En una frase: vas a entender por qué se escriben tests y qué los hace buenos —no como un impuesto que pagas al final, sino como la red de seguridad que te devuelve el permiso para cambiar tu propio código sin miedo, y como una especificación ejecutable de lo que el sistema debe hacer.

Por qué esto importa

Hay un momento que todo el que programa reconoce: tienes que tocar una función que escribió otro (o tú hace seis meses), funciona, nadie sabe muy bien por qué, y te da miedo cambiarla. Así que no la cambias. La rodeas. Añades un if encima en vez de arreglar el fondo. Copias y pegas en vez de reutilizar. El código se pudre no porque nadie sepa mejorarlo, sino porque nadie se atreve a tocarlo.

Ese miedo tiene un nombre técnico: falta de red de seguridad. Sin tests, la única forma de saber si tu cambio rompió algo es desplegarlo y esperar a que un usuario grite. Con esa incertidumbre, la respuesta racional es no tocar nada, y un código que no se puede tocar está muerto en vida.

Los tests atacan justo eso. La pregunta que responde esta lección no es "¿cómo escribo un assertEquals?" —eso es sintaxis de cinco minutos—, sino:

  • ¿Por qué un test que pasa siempre no vale para nada, y por qué uno que falla a veces es peor que no tener test?
  • ¿Por qué la gente con experiencia escribe muchos tests pequeños y pocos grandes, y no al revés?
  • ¿Por qué "tengo el 100% de cobertura" no significa "mi código funciona"?
  • ¿Cuándo TDD te salva la vida y cuándo es una ceremonia inútil?
  • ¿Por qué el equipo que más mockea suele ser el que peor duerme?

Todas tienen la misma raíz: un test no existe para "comprobar que el código funciona hoy". Existe para dejarte cambiar el código mañana. Cuando interiorizas eso, cada decisión sobre testing —qué probar, a qué nivel, cuánto aislar— deja de ser dogma y pasa a ser una consecuencia.

Intuición primero: el arnés de escalada

Imagina a alguien escalando una pared. Sin arnés, cada movimiento es una decisión de vida o muerte: avanza despacio, se agarra a lo seguro, evita los tramos difíciles aunque sean el mejor camino. Con arnés y cuerda, la misma persona escala agresivamente: prueba un agarre arriesgado, y si falla, la cuerda lo sujeta un metro más abajo. No escala sin caerse; escala pudiendo caerse sin consecuencias.

Tu suite de tests es ese arnés. No evita que cometas errores al refactorizar —los vas a cometer—; hace que cuando los cometas, caigas veinte centímetros en lugar de al vacío. El test rojo salta al segundo de introducir el fallo, en tu máquina, antes de que llegue a producción.

graph LR
  C["Cambio el código"] --> T{"¿Tests<br/>en verde?"}
  T -->|"sí"| S["Sigo, tranquilo"]
  T -->|"no"| F["Fallo localizado<br/>al instante"]
  F --> R["Arreglo o<br/>deshago"]
  R --> C

Y aquí está la idea que sostiene toda la lección, la que hay que grabar antes de seguir:

El valor de un test no está en la vez que lo escribes y pasa en verde. Está en todas las veces futuras en que un cambio lo pone en rojo y te avisa de que has roto algo sin darte cuenta. Un test es una apuesta sobre el futuro, no una comprobación del presente.

Cuelga de aquí el resto. Cada vez que dudes "¿merece la pena este test?", traduce la pregunta a: "¿me va a avisar de una rotura real que se me escaparía?".

El detalle

1. Qué es realmente un test (y el ciclo AAA)

Un test automático es, despojado de toda ceremonia, un programa que ejecuta tu programa y afirma que el resultado es el esperado. Si la afirmación se cumple, verde; si no, rojo. Nada más.

La estructura canónica se llama AAAArrange, Act, Assert (Preparar, Actuar, Afirmar)— y conviene verla explícita porque un test que la respeta se lee de un vistazo:

public function test_el_carrito_suma_el_iva(): void
{
    // Arrange (Preparar): monta el escenario
    $carrito = new Carrito();
    $carrito->añadir(new Linea('libro', 100_00));

    // Act (Actuar): ejecuta LA acción bajo prueba
    $total = $carrito->totalConIva(0.21);

    // Assert (Afirmar): compara con lo esperado
    $this->assertSame(121_00, $total);
}

Tres bloques, en ese orden. El nombre del test describe el comportamiento, no el método (test_el_carrito_suma_el_iva, no test_totalConIva). Esta separación no es estética: si un test tiene dos bloques Act, es que está probando dos cosas y debería ser dos tests (lo veremos en el punto 3).

Test ≠ assert suelto en el código

Un assert() incrustado en producción comprueba una invariante mientras el programa corre de verdad. Un test vive aparte, se ejecuta bajo demanda con datos que tú controlas, y no llega al usuario. Son primos, no lo mismo. Cuando aquí decimos "test" hablamos de lo segundo: código de prueba que corre en tu máquina y en CI (Integración continua, materia aparte).

2. La pirámide de tests: por qué esa forma y no otra

No todos los tests son iguales. Se clasifican por cuánto del sistema tocan:

  • Unitarios: prueban una pieza pequeña y aislada —una clase, una función— sin base de datos, sin red, sin framework. Milisegundos. Miles de ellos corren en segundos.
  • De integración: prueban que varias piezas encajan: tu repositorio contra una base de datos real, tu servicio contra la cola de mensajes. Más lentos (arrancan cosas), pero pillan los fallos que viven entre componentes, justo donde los unitarios son ciegos.
  • End-to-end (E2E): prueban el sistema entero como lo usa un humano: abren un navegador, rellenan el formulario, pulsan comprar, verifican que llegó el email. Los más realistas y los más lentos, frágiles y caros de mantener.

La recomendación clásica —la pirámide de tests de Mike Cohn— dice: muchos abajo, pocos arriba.

graph TB
  E["<b>E2E</b> · pocos<br/>lentos · frágiles · realistas"]
  I["<b>Integración</b> · algunos<br/>prueban que las piezas encajan"]
  U["<b>Unitarios</b> · muchos<br/>rápidos · aislados · baratos"]
  E --> I --> U
  style U fill:#2d6a4f,color:#fff
  style I fill:#40916c,color:#fff
  style E fill:#74c69d,color:#000

¿Por qué esta forma y no un rectángulo, o una pirámide invertida? Por economía de la señal. Cada nivel tiene un canje distinto:

Unitario Integración E2E
Velocidad ms décimas de s segundos o más
Qué pilla lógica de una pieza acoplamientos entre piezas que el flujo real funciona
Al fallar, ¿dónde está el bug? señalado con precisión en una de pocas piezas "algo en todo el sistema"
Fragilidad baja media alta (un cambio de CSS lo rompe)

La pirámide es la consecuencia de optimizar dos cosas a la vez: velocidad de feedback y precisión del diagnóstico. Un test unitario que falla te dice qué método está mal; un E2E que falla te dice que algo entre el navegador y la base de datos no cuadra, y te toca investigar. Si construyes la pirámide al revés —pocos unitarios, muchos E2E, el "cono de helado"— acabas con una suite que tarda 40 minutos, falla por motivos aleatorios y no señala nada. La gente deja de mirarla, y una suite que nadie mira es peor que no tenerla, porque da falsa confianza.

La pirámide es una guía, no una ley

Las proporciones exactas dependen de qué construyes. Un microservicio que es sobre todo pegamento entre APIs quizá necesite más integración que lógica pura. La forma importa (base ancha, punta estrecha); los porcentajes concretos, menos. No la conviertas en dogma.

3. Qué hace bueno a un test: las cuatro propiedades

Un test malo se cuela con la misma facilidad que uno bueno y hace daño durante años. Un buen test cumple cuatro cosas —regla mnemotécnica en inglés FIRST, pero quédate con las ideas:

Rápido. Si la suite tarda diez minutos, no la ejecutas en cada cambio; la ejecutas "de vez en cuando", y entonces el bug que introdujiste a las 10:00 lo descubres a las 13:00 con veinte cambios encima, sin saber cuál fue. La velocidad no es comodidad, es lo que determina si el test se usa. Un test lento tiende a no ejecutarse, y un test que no se ejecuta no existe.

Determinista (aislado del entorno y del tiempo). Debe dar el mismo resultado siempre: mismas entradas, mismo veredicto. Un test que a veces pasa y a veces falla sin que cambies nada es un test flaky (escamoso), y es peor que no tener test. ¿Por qué peor? Porque entrena a tu equipo a ignorar el rojo: "bah, es ese test flaky, dale otra vez". El día que un flaky en rojo esconda un bug real, nadie lo mirará. Un flaky no solo es inútil: envenena la credibilidad de toda la suite.

Independiente. El test A no depende de que el B corra antes, ni comparten estado. Deben poder ejecutarse en cualquier orden y en paralelo. Si testB solo pasa porque testA dejó una fila en la base de datos, tienes un test fantasma que se caerá el día que reordenes el fichero.

Que falla por una sola razón. Un test debería tener un motivo para existir y un motivo para romperse. Si compruebas cinco cosas no relacionadas en un test, cuando falle no sabrás cuál de las cinco, y cada cambio de cualquiera de ellas lo pondrá en rojo. Un motivo por test = diagnóstico instantáneo.

graph TB
  B["Test que falla"]
  B --> U1["1 solo motivo posible<br/>→ sé al instante qué rompí"]
  B --> M1["5 motivos posibles<br/>→ toca investigar cuál"]
  style U1 fill:#2d6a4f,color:#fff
  style M1 fill:#9d0208,color:#fff

Estas cuatro no son independientes: un test que pega a la base de datos real suele violar las cuatro a la vez (lento, no determinista si hay datos compartidos, dependiente del estado, y falla por mil razones). Por eso el instinto de aislar —que nos lleva a los dobles de prueba, punto 5— nace de aquí.

4. El test como especificación y documentación viva

Aquí hay un cambio de mentalidad que separa a quien "escribe tests porque toca" de quien los usa como herramienta de diseño.

Un buen conjunto de tests es la especificación ejecutable del sistema: describe qué debe hacer, caso a caso, en un lenguaje que no puede mentir. La documentación en un .md se queda obsoleta en cuanto alguien cambia el código y no la actualiza —y nadie la actualiza. Un test, no: si el comportamiento cambia y el test no, el test se pone rojo y te obliga a reconciliarlos. Es documentación que se rompe cuando miente. Por eso se llama documentación viva.

Compara. Un comentario dice:

// El descuento VIP es del 10%, pero solo a partir de 50€

¿Sigue siendo cierto? Ni idea; a lo mejor lo cambiaron el mes pasado. Ahora un test:

public function test_el_vip_no_tiene_descuento_por_debajo_del_umbral(): void
{
    $precio = $this->calculadora->para(new ClienteVip(), 49_99);
    $this->assertSame(49_99, $precio); // sin descuento bajo 50€
}

public function test_el_vip_tiene_diez_por_ciento_desde_el_umbral(): void
{
    $precio = $this->calculadora->para(new ClienteVip(), 50_00);
    $this->assertSame(45_00, $precio); // 10% desde 50€
}

Estos dos tests no pueden estar desactualizados: si alguien cambia el umbral a 60€, el primero se pone rojo. Son la fuente de verdad. Cuando llega un compañero nuevo y pregunta "¿cómo funciona el descuento VIP?", la respuesta honesta es "lee los tests de la calculadora", y es una buena respuesta, porque son ejemplos concretos, ejecutables y verificados.

Nombres de test como frases

Si nombras los tests como afirmaciones de comportamiento (test_el_vip_no_tiene_descuento_por_debajo_del_umbral), la salida de la suite se lee como una lista de especificaciones: "el vip no tiene descuento por debajo del umbral… ✓". Esa lista es la documentación. Nombres como test1 o testCalculo tiran esta ventaja a la basura.

5. TDD: red, verde, refactor

TDD (Test-Driven Development, desarrollo guiado por tests) invierte el orden habitual: escribes el test antes que el código. El ciclo es de tres pasos, y hay que entender la intención de cada uno:

graph LR
  R["🔴 RED<br/>escribe un test que falla<br/>(aún no hay código)"] --> G["🟢 GREEN<br/>el mínimo código<br/>para que pase"]
  G --> RF["🔵 REFACTOR<br/>limpia con la red puesta"]
  RF --> R
  • Red (rojo): escribes un test para algo que aún no existe. Falla, obviamente. Este paso te obliga a decidir qué quieres antes de cómo lo haces: el nombre, la firma, qué entra y qué sale. Estás diseñando la interfaz desde fuera, como cliente de tu propio código.
  • Green (verde): escribes el mínimo para que pase. Sin elegancia, sin generalizar. Feo pero verde. La disciplina de "lo mínimo" evita que te adelantes construyendo cosas que nadie ha pedido todavía.
  • Refactor: ahora que tienes verde, limpias: extraes, renombras, eliminas duplicación. Y aquí es donde TDD paga: refactorizas con la red puesta, porque el test que acabas de escribir te avisa al instante si rompes el comportamiento.

La intuición profunda de TDD no es "los tests salen gratis" (que también). Es que escribir el test primero te fuerza a diseñar código testeable, y el código testeable resulta ser —casi siempre— código con buenas fronteras: dependencias explícitas, funciones con entradas y salidas claras, poco estado oculto. TDD es una herramienta de diseño disfrazada de herramienta de testing.

Cuándo aporta: lógica de negocio con reglas claras (cálculos, validaciones, máquinas de estado), corrección de bugs (escribe primero el test que reproduce el bug —rojo—, luego arréglalo —verde—, y ese bug no vuelve nunca), y APIs donde diseñar la interfaz desde fuera te ahorra rehacerla.

Cuándo no: exploración pura ("no sé aún qué estoy construyendo, estoy probando"), UI muy visual donde el criterio es "¿se ve bien?" (eso no lo afirma un assert), o código throwaway. Hacer TDD a la fuerza cuando aún no sabes qué quieres es escribir tres veces el mismo test mientras cambias de idea. Explora primero con un prototipo, tíralo, y entonces haz TDD sobre lo que aprendiste.

TDD no es obligatorio para tener tests

Confusión habitual: "no hago TDD" ≠ "no escribo tests". Puedes escribir tests después del código y obtener casi todo el valor de red de seguridad. TDD añade el beneficio de diseño y la garantía de que el test puede fallar (lo viste en rojo). Es una técnica valiosa, no un peaje moral. Quien te diga que el código sin TDD es basura, vende algo.

6. Dobles de prueba, y el peligro de sobre-mockear

Para que un test unitario sea rápido y aislado (punto 3), a menudo hay que reemplazar las dependencias reales —la base de datos, la pasarela de pago, el reloj— por sustitutos controlables. Esos sustitutos son los dobles de prueba (test doubles, como los dobles de acción en el cine). Hay una taxonomía —de Gerard Meszaros— que conviene distinguir porque la gente los llama todos "mock" y eso oculta diferencias importantes:

  • Dummy: un objeto que se pasa solo para rellenar un parámetro obligatorio; nunca se usa. Relleno.
  • Stub: devuelve respuestas predefinidas. "Cuando te pregunten el precio, di 100." No verifica nada; solo alimenta al código bajo prueba con datos que tú controlas.
  • Spy: como un stub, pero además registra cómo lo llamaron (cuántas veces, con qué argumentos) para que lo compruebes después.
  • Mock: un doble con expectativas programadas de antemano: "espero que me llamen enviar() exactamente una vez con este email". El propio mock falla el test si no se cumple. Verifica interacción, no resultado.
  • Fake: una implementación real pero simplificada: una base de datos en memoria en vez de MySQL. Funciona de verdad, solo que no sirve para producción.
graph TB
  D["Dobles de prueba"]
  D --> ST["Stub · da respuestas<br/>(verifica ESTADO)"]
  D --> SP["Spy · graba llamadas<br/>para comprobar luego"]
  D --> MK["Mock · exige llamadas<br/>(verifica INTERACCIÓN)"]
  D --> FK["Fake · implementación<br/>simplificada que funciona"]

En PHPUnit los creas así:

// STUB: no me importa cómo se llama, solo qué devuelve
$reloj = $this->createStub(Reloj::class);
$reloj->method('ahora')->willReturn(new DateTimeImmutable('2026-01-01'));

// MOCK: exijo que se llame enviar() una vez con este destinatario
$mailer = $this->createMock(Mailer::class);
$mailer->expects($this->once())
       ->method('enviar')
       ->with($this->equalTo('ana@example.com'));

$servicio = new Bienvenida($reloj, $mailer);
$servicio->registrar('ana@example.com');
// si enviar() NO se llamó, el mock hace fallar el test solo

Nota la diferencia de filosofía: el stub del reloj verifica estado (¿el resultado es el correcto?), el mock del mailer verifica interacción (¿se llamó al colaborador como debía?). Esta distinción es el origen del mayor peligro del testing.

El peligro de sobre-mockear: tests acoplados a la implementación

Cada mock que verifica interacción congela cómo tu código habla con sus colaboradores. Si mockeas todo, tu test deja de comprobar qué hace el código y pasa a comprobar cómo lo hace paso a paso. Resultado: refactorizas sin cambiar el comportamiento y aun así el test se pone rojo, porque cambiaste el orden de dos llamadas internas. Eso es exactamente lo contrario de lo que quieres: el test debía darte libertad para refactorizar, y en su lugar te la quita. Un test sobre-mockeado es un test que se rompe cuando mejoras el código y sigue en verde cuando lo rompes de verdad (porque solo comprueba llamadas, no resultados). La regla: mockea las fronteras (lo lento, lo externo, lo no determinista: red, disco, reloj, pagos), y deja pasar la lógica de verdad. Si para probar una clase necesitas cinco mocks, la señal no es "necesito más mocks", es "esta clase tiene demasiadas dependencias" —un problema de diseño, no de testing.

Un fake suele ser mejor que un montón de mocks cuando la dependencia es esencial: un repositorio en memoria que de verdad guarda y devuelve deja tus tests probando comportamiento real sin tocar MySQL, y no se rompe al refactorizar.

7. Cobertura de código: qué mide y, sobre todo, qué NO mide

La cobertura (code coverage) mide qué porcentaje de tus líneas (o ramas) se ejecutan al correr los tests. Suena a métrica de calidad. Casi no lo es, y confundirlo cuesta caro.

Lo que la cobertura te dice, y es útil: qué código NO está tocado por ningún test. Un 0% en un módulo crítico es una alarma legítima: nadie lo prueba. La cobertura es buena detectando agujeros.

Lo que la cobertura NO te dice —y aquí está el malentendido caro— es si esos tests comprueban algo. Observa este test venenoso:

public function test_calcula(): void
{
    $resultado = $this->calculadora->para(new ClienteVip(), 50_00);
    // ¡NO hay ningún assert!
}

Este test ejecuta el método para(), así que la cobertura de esas líneas sube al 100%. Y no comprueba absolutamente nada: el método podría devolver cualquier cosa, o lanzar una excepción a medias, y mientras no reviente del todo, verde. Cobertura del 100%, valor cero.

La cobertura mide qué código se ejecuta, no qué comportamiento se verifica. Ejecutar no es afirmar.

Por eso perseguir "el 100% de cobertura" como objetivo es una trampa clásica: incentiva a escribir tests que tocan líneas sin comprobarlas, o a testear getters triviales para inflar el número, mientras la lógica de verdad —la que tiene diez ramas y cinco casos límite— puede tener un solo test flojo. El número sube, el riesgo no baja. Peor: da falsa confianza, que es el estado más peligroso, porque tomas decisiones agresivas creyendo que tienes red cuando no la tienes.

Usa la cobertura al revés

No la mires como "cuánto tengo, quiero más". Mírala como un detector de puntos ciegos: "¿qué código importante tiene 0% y debería tener algo?". Un 80% con tests que afirman de verdad vale infinitamente más que un 100% de tests sin assert. La cobertura es un buen sirviente y un pésimo amo. (Existe una técnica, el mutation testing, que mide si tus tests detectan cambios introducidos a propósito en el código —eso sí mide la calidad real de la suite, a cambio de ser lento.)

8. Qué NO merece la pena testear

Escribir tests cuesta tiempo y —esto se olvida— mantenerlos cuesta más. Cada test es código que hay que actualizar cuando el sistema cambia. Un test que no aporta señal pero sí coste de mantenimiento es un pasivo, no un activo. Aprender qué no testear es tan importante como saber qué sí:

  • Código trivial sin lógica: getters, setters, un return $this->nombre;. Si no hay decisión, no hay nada que un test pueda pillar salvo que rompas la sintaxis, y eso lo pilla el compilador.
  • El framework o las librerías de terceros: no testees que Doctrine guarda en la base de datos o que Symfony enruta bien. Eso ya está testeado por sus autores; si desconfías tanto, cambia de librería.
  • Detalles de implementación privados: prueba el comportamiento observable (lo que entra y sale por la interfaz pública), no los métodos privados uno a uno. Si testeas lo privado, cada refactor interno te rompe tests sin que haya cambiado nada para el usuario.
  • Configuración y constantes: un test que afirma IVA === 0.21 solo duplica la constante en dos sitios; el día que cambie, cambias los dos y el test no pilló nada.
  • UI puramente visual: que un botón esté "3px a la izquierda" no lo afirma bien un test automático; eso es revisión visual o snapshot, con sus propios problemas de fragilidad.

La brújula: testea donde haya decisiones, ramas, cálculos, casos límite y reglas de negocio —donde un cambio pueda romper algo sin que se note—. No malgastes tests donde no hay nada que pueda salir mal de forma silenciosa.

9. El coste de los tests frágiles

Un test frágil es el que se rompe por cambios que no rompen el comportamiento: renombras un método privado, reordenas dos llamadas, cambias un texto de un mensaje. Cada rotura falsa cuesta: alguien para lo que hacía, investiga, descubre que "no era nada", y arregla el test en vez del código.

El daño real no es el tiempo puntual. Es que los tests frágiles erosionan la confianza en la suite, igual que los flaky. Cuando la mitad de los rojos son "es que el test es tonto", el equipo desarrolla el reflejo de asumir que el rojo es falso, y ese reflejo mata el propósito entero de tener tests: el día del rojo verdadero, lo ignoran.

Las tres fuentes principales de fragilidad, y su antídoto:

  1. Sobre-mockeo (punto 6) → mockea solo fronteras, verifica estado cuando puedas.
  2. Testear implementación en vez de comportamiento (punto 8) → prueba por la interfaz pública.
  3. Acoplamiento a datos volátiles (fechas de hoy, IDs autogenerados, orden de una lista sin ORDER BY) → inyecta el reloj, no dependas del orden, controla las entradas.

Un test debería romperse cuando y solo cuando cambia el comportamiento que le importa. Ni menos (deja pasar bugs), ni más (frágil). Ese es el filo de la navaja del buen testing.

Cómo se ve en la práctica

Un caso real: hay un bug en producción. El descuento VIP se aplica también a clientes normales que gastan mucho. El flujo correcto —y que aprovecha TDD para bugs— es:

Paso 1 — reproduce el bug con un test (rojo). Antes de tocar nada, escribe el test que debería pasar y no pasa:

final class CalculadoraDescuentoTest extends TestCase
{
    private CalculadoraDescuento $calc;

    protected function setUp(): void
    {
        $this->calc = new CalculadoraDescuento();
    }

    public function test_un_cliente_normal_no_recibe_descuento_vip(): void
    {
        // Arrange
        $cliente = new Cliente(esVip: false);

        // Act
        $precio = $this->calc->aplicar($cliente, 200_00);

        // Assert: cliente normal, precio intacto
        $this->assertSame(200_00, $precio); // 🔴 hoy devuelve 180_00: BUG confirmado
    }
}

Este rojo es oro: prueba que el bug existe y te da el criterio exacto de "arreglado". No es opinión; es un veredicto.

Paso 2 — arregla hasta el verde. Cambias la condición en aplicar(). El test pasa. Y de propina: ese bug concreto no puede volver nunca, porque el test se quedará vigilando esa esquina para siempre. Un bug con test de regresión es un bug que solo puedes cometer una vez.

Paso 3 — cierra los casos límite alrededor. Ya que estás, blindas la frontera:

public function test_el_vip_recibe_su_descuento(): void
{
    $precio = $this->calc->aplicar(new Cliente(esVip: true), 200_00);
    $this->assertSame(180_00, $precio); // el 10% VIP sí aplica
}

public function test_el_descuento_no_deja_precio_negativo(): void
{
    $precio = $this->calc->aplicar(new Cliente(esVip: true), 0);
    $this->assertSame(0, $precio); // caso límite: no bajar de 0
}

Fíjate en lo que hicimos: no probamos "que la calculadora funciona" en abstracto. Probamos decisiones concretas (VIP sí, normal no, suelo en cero), cada una con un assert que afirma de verdad, cada test con un solo motivo para fallar. Esos tres tests son ahora la especificación viva del descuento, y el arnés que deja al siguiente refactorizar la clase sin miedo. Sin este enfoque, "arreglar el bug" es cambiar un if y cruzar los dedos.

Lo que sacrificas / errores comunes

  • Los tests son código, y el código se mantiene. No son gratis: una suite mal diseñada (frágil, lenta, sobre-mockeada) puede costar más de lo que ahorra y frenar al equipo. El objetivo no es "más tests", es la señal correcta al menor coste. Mil tests malos son un lastre; cien buenos, un superpoder.
  • Perseguir cobertura como número. Ya visto: el 100% con tests sin assert es teatro. Optimiza por comportamiento verificado, no por porcentaje.
  • Sobre-mockear hasta acoplar el test a la implementación. El pecado más caro: crees que tienes red y lo que tienes es un cepo que salta cuando refactorizas y calla cuando rompes.
  • Confundir "los tests pasan" con "el código funciona". Los tests solo cubren los casos que imaginaste. El bug que no se te ocurrió no lo cubre ningún test. Verde no es "correcto", es "no he encontrado este conjunto de fallos concretos".
  • Tolerar tests flaky "por ahora". Un flaky sin arreglar entrena a todos a ignorar el rojo. O lo arreglas, o lo borras. Dejarlo es peor que las dos.
  • Testear a nivel equivocado. Probar con un E2E lento lo que un unitario resolvería en 2ms, o al revés, mockear media base de datos para un test que debería ser de integración. El nivel adecuado es el más bajo que pilla el fallo que te importa.
  • Escribir el test después y que "siempre pase". Si nunca lo viste en rojo, no sabes si de verdad puede fallar. Rompe el código a propósito una vez y comprueba que el test lo pilla; si no salta, tu test está muerto.

Resumen

  1. Un test no verifica el presente: es el arnés que te devuelve el permiso para cambiar el código mañana sin miedo. Su valor está en los rojos futuros que te avisan de roturas que no viste venir.
  2. La pirámide —muchos unitarios, algunos de integración, pocos E2E— optimiza velocidad de feedback y precisión del diagnóstico. Invertirla ("cono de helado") da suites lentas, frágiles y que nadie mira.
  3. Un buen test es rápido, determinista, independiente y falla por una sola razón. Un test flaky o frágil es peor que no tener test, porque envenena la confianza en toda la suite.
  4. Los tests son especificación viva: documentación que se pone roja cuando miente. TDD (red-verde-refactor) es sobre todo una herramienta de diseño; aporta en lógica con reglas claras y en bugs, no en exploración pura.
  5. Mockea solo las fronteras (red, disco, reloj, pagos); sobre-mockear acopla el test a la implementación y te quita la libertad de refactorizar. La cobertura detecta puntos ciegos pero no mide si verificas algo: ejecutar no es afirmar.

Ejercicios socráticos

No busques la respuesta fuera. Razónala con el modelo de esta lección. Si te atascas, la pista está en el texto.

  1. Un compañero dice: "tengo el 100% de cobertura, mi código está probado". Escribe un test que suba la cobertura de un método al 100% y no comprueba nada. ¿Qué mide entonces la cobertura, y por qué "100%" no implica "correcto"?
  2. Tienes un test que a veces pasa y a veces falla sin cambiar el código. El equipo dice "dale otra vez, ya pasará". Explica por qué ese test es peor que borrarlo, en términos de qué le hace a la confianza en el resto de la suite.
  3. Para probar una sola clase necesitas crear cinco mocks. La reacción instintiva es "vaya rollo de mocks". ¿Qué te está diciendo en realidad esa necesidad de cinco mocks, y por qué es un problema de diseño y no de testing?
  4. Refactorizas una clase sin cambiar lo que hace de cara al exterior, y tres tests se ponen rojos. ¿Qué te dice eso sobre qué estaban probando esos tests? ¿Eran buenos tests? Justifícalo con la idea de "comportamiento vs implementación".
  5. Vas a arreglar un bug. ¿Por qué conviene escribir primero el test que lo reproduce (y verlo en rojo) antes de tocar el código, en lugar de arreglarlo y luego —si acaso— añadir un test? Piensa en qué te garantiza cada orden.

Repaso espaciado

Pasa estas a Anki (repasos.md). Formato: pregunta que obligue a razonar, no a reconocer.

  • [ ] ¿Por qué se dice que el valor de un test no está en cuando lo escribes y pasa, sino en el futuro? Conéctalo con "refactorizar sin miedo".
  • [ ] Explica la forma de la pirámide de tests: ¿qué dos cosas se optimizan al poner muchos unitarios abajo y pocos E2E arriba, y qué pasa si la inviertes?
  • [ ] ¿Por qué un test flaky es peor que no tener ese test? (Responde en términos de confianza en la suite, no de tiempo perdido.)
  • [ ] Distingue stub de mock: ¿qué verifica cada uno (estado vs interacción) y por qué sobre-mockear acopla el test a la implementación?
  • [ ] ¿Qué mide la cobertura de código y qué NO mide? Da el ejemplo del test con 100% de cobertura y cero valor.

Para seguir tirando del hilo

  • Test-Driven Development: By Example (Kent Beck) — la fuente del ciclo red-verde-refactor, explicado a base de ejemplos pequeños.
  • xUnit Test Patterns (Gerard Meszaros) — la taxonomía de dobles de prueba (dummy/stub/spy/mock/fake) viene de aquí; referencia densa pero definitiva.
  • Growing Object-Oriented Software, Guided by Tests (Freeman & Pryce) — TDD como herramienta de diseño, con la discusión más honesta sobre cuándo mockear y cuándo no.
  • Charla "TDD, Where Did It All Go Wrong?" (Ian Cooper) — antídoto contra el sobre-mockeo y el testear implementación; te reconcilia con la idea de probar comportamiento.
  • Experimento: coge una clase tuya con lógica, rómpela a propósito de cinco formas distintas y comprueba cuántas pilla tu suite. Los fallos que no salten en rojo son el mapa de tus puntos ciegos.