Pruebas y calidad de software

TDD en Java: un kata completo y una valoración honesta

Intermedio9 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

El TDD es una disciplina de diseño que, de paso, deja tests por el camino. Esta guía recorre un problema completo, desde la primera aserción que falla hasta un diseño refactorizado, y después analiza qué le hace realmente la práctica a la estructura del código, cómo tratar a los colaboradores, dónde funciona mal y qué dice de verdad la evidencia.

Lo que aprenderás

  • Recorrer el ciclo red-green-refactor con la disciplina que lo hace útil
  • Hacer crecer un diseño pequeño a partir de los tests, eligiendo con criterio el siguiente test
  • Elegir entre outside-in e inside-out para una funcionalidad concreta
  • Reconocer las situaciones en las que el test-first encaja mal y explicar por qué
  • Responder a las objeciones habituales al TDD con algo mejor que entusiasmo

El desarrollo guiado por pruebas es fácil de describir y difícil de hacer bien. Escribes un test que falla, escribes el mínimo código que lo hace pasar y luego mejoras el diseño mientras el test sigue en verde. Todo lo interesante de la práctica está en lo que esa restricción te obliga a hacer: no puedes escribir código imposible de invocar, no puedes inventar una abstracción antes de tener un uso para ella y siempre sabes si lo que acabas de escribir funciona.

También se vende a menudo por encima de sus posibilidades. El TDD no es una estrategia de testing (una suite surgida del TDD a nivel unitario cubre muy poco riesgo de integración) ni sustituye a pensar en el diseño. Lo que aporta de forma fiable es un ciclo de feedback muy corto y código fácil de instanciar, y resulta que esas dos cosas importan muchísimo. Esta guía desarrolla un ejemplo completo y después examina con honestidad dónde la práctica se gana el sueldo y dónde no.

El ciclo, con precisión

Red. Escribe un test para un comportamiento que no existe. Ejecútalo. Tiene que fallar, y por el motivo que esperas: un test que falla por una errata en la preparación no te enseña nada. Un test que pasa a la primera significa que no has identificado ningún comportamiento nuevo.

Green. Escribe el código más simple que haga pasar el test. «Más simple» se entiende al pie de la letra, incluido devolver una constante. La primera vez parece absurdo, y es precisamente el mecanismo que obliga a que el siguiente test trate sobre algo real.

Refactor. Con todos los tests en verde, mejora la estructura: elimina duplicación, renombra, extrae, simplifica. Es el paso que la gente se salta, y saltárselo convierte el TDD en una forma de producir código mal diseñado con buena cobertura de tests.

Dos hábitos hacen que el ciclo funcione. Limita cada vuelta a un par de minutos: si llevas un cuarto de hora en rojo, el paso era demasiado grande; deshaz y da uno más pequeño. Y trata el código de test como código de producción: se va a leer muchas más veces de las que se escribe.

Un kata, de cero a un diseño

El problema: un conversor RomanNumerals, de int a String, para valores de 1 a 3999. Es lo bastante pequeño para mostrarlo entero y lo bastante rico para tener una decisión de diseño real a mitad de camino.

El primer test es la declaración de intenciones más pequeña posible.

class RomanNumeralsTest {

    @Test
    void one() {
        assertThat(RomanNumerals.of(1)).isEqualTo("I");
    }
}

No compila, y eso cuenta como rojo. Lo más simple que lo pone en verde:

public final class RomanNumerals {
    public static String of(int value) {
        return "I";
    }
}

Ahora un segundo test obliga al código a distinguir dos casos.

@Test
void two() {
    assertThat(RomanNumerals.of(2)).isEqualTo("II");
}

La corrección obvia, un if, es peor que la general, y este es el momento en que aparece el diseño:

public static String of(int value) {
    return "I".repeat(value);
}

Eso pasa con 1, 2 y 3, y falla con 4. En lugar de escribir cuatro tests separados, expresa los casos como una tabla, que es justo el terreno de un test parametrizado:

@ParameterizedTest(name = "{0} -> {1}")
@CsvSource({"1,I", "3,III", "4,IV", "9,IX", "14,XIV", "40,XL", "90,XC",
            "400,CD", "900,CM", "1994,MCMXCIV", "3999,MMMCMXCIX"})
void converts(int value, String expected) {
    assertThat(RomanNumerals.of(value)).isEqualTo(expected);
}

Basta con añadir 4,IV para romper repeat. La tentación es encadenar condicionales; el paso de refactorización es lo que lo impide. La idea clave —que las formas sustractivas IV, IX, XL no son más que símbolos adicionales en una tabla ordenada— surge porque la duplicación en los condicionales la hace visible:

public final class RomanNumerals {

    private record Symbol(int value, String glyph) {}

    private static final List<Symbol> SYMBOLS = List.of(
            new Symbol(1000, "M"), new Symbol(900, "CM"), new Symbol(500, "D"), new Symbol(400, "CD"),
            new Symbol(100, "C"),  new Symbol(90, "XC"),  new Symbol(50, "L"),  new Symbol(40, "XL"),
            new Symbol(10, "X"),   new Symbol(9, "IX"),   new Symbol(5, "V"),   new Symbol(4, "IV"),
            new Symbol(1, "I"));

    private RomanNumerals() {}

    public static String of(int value) {
        if (value < 1 || value > 3999) {
            throw new IllegalArgumentException("Roman numerals cover 1..3999, got " + value);
        }
        StringBuilder out = new StringBuilder();
        int remaining = value;
        for (Symbol symbol : SYMBOLS) {
            while (remaining >= symbol.value()) {
                out.append(symbol.glyph());
                remaining -= symbol.value();
            }
        }
        return out.toString();
    }
}

La cláusula de guarda salió de su propio test en rojo, escrito cuando la tabla ya estaba en verde:

@ParameterizedTest
@ValueSource(ints = {0, -1, 4000, Integer.MAX_VALUE})
void rejectsValuesOutsideTheRange(int value) {
    assertThatThrownBy(() -> RomanNumerals.of(value))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("1..3999");
}

Fíjate en lo que ha producido la secuencia. La tabla no es una implementación que casualmente funcionó; es la estructura hacia la que empujaron los tests, porque cada condicional que forzaban era duplicación con la misma forma. Fíjate también en lo que no se construyó: ni RomanNumeralFactory, ni interfaz de estrategia, ni configuración. Nada los pedía.

Lo que los tests le hacen al diseño

El TDD ejerce cuatro tipos de presión, y son el verdadero producto de la práctica.

Todo tiene que poder construirse. Una clase cuyo constructor lee un singleton estático, abre una conexión o lanza un pool de threads no se puede crear de forma barata en un test. Con TDD lo notas en el primer minuto y pasas la dependencia desde fuera, que es la misma conclusión a la que llega la inyección de dependencias por el otro lado.

Todo tiene que ser observable. Si el único efecto de un método es una línea de log o la modificación de un campo privado, el test no puede comprobar nada. Esa presión empuja a devolver valores en lugar de mutar estado oculto, y a concentrar los efectos secundarios en unos pocos puntos explícitos.

Las unidades se mantienen pequeñas. Un método con ocho ramas necesita ocho tests o más, con una preparación laboriosa. El dolor llega antes de que el método salga a producción, no en una revisión tres semanas después.

Las abstracciones llegan tarde. El paso de refactorización introduce una interfaz cuando existe una segunda implementación, no cuando alguien la anticipa. La generalidad especulativa es el tipo de desperdicio más caro de un código base, y el test-first hace que sea incómodo crearla.

Nada de esto es automático. Un equipo puede producir código procedimental muy testeado, con un servicio que lo hace todo y un centenar de mocks. La disciplina crea la presión; quien diseña sigue teniendo que responder a ella.

Outside-in e inside-out

Hay dos maneras de abordar una funcionalidad, y cada una encaja en situaciones distintas.

Inside-out (a veces llamada clásica) empieza por el objeto de dominio del centro —el cálculo, la máquina de estados, la regla de parseo— y avanza hacia fuera hasta el controlador. Los colaboradores son objetos reales siempre que sean baratos. Los tests son robustos porque comprueban valores en lugar de interacciones, y rara vez se rompen al refactorizar. El riesgo es construir un núcleo magníficamente testeado que no encaja con la API que al final necesitas.

Outside-in (mockista, escuela de Londres) empieza en la frontera: un test de aceptación para el comportamiento visible por el usuario, luego un test del controlador con un servicio mockeado, luego el servicio con un repositorio mockeado, descubriendo cada interfaz en el momento en que hace falta. El diseño sale cohesionado porque cada colaborador se inventó para satisfacer a quien lo llama. El coste son tests acoplados a las interacciones: cambia cómo habla un servicio con su repositorio y el test falla aunque el comportamiento sea idéntico.

En la práctica, la mayoría de los equipos con experiencia trabajan outside-in a nivel de funcionalidad para descubrir su forma, y después inside-out dentro de cada unidad que contiene lógica real. La guía de BDD describe el ciclo más externo de este esquema: un escenario que falla y sigue en rojo mientras dentro de él se ejecutan varios ciclos a nivel unitario.

Colaboradores, sin ahogarse en mocks

El trabajo con test-first se atasca en cuanto el objeto bajo prueba necesita algo lento. Tres respuestas, por orden de preferencia:

  1. Saca la dependencia fuera. Si un método necesita la fecha actual, que reciba un Clock. Si necesita un total que sale de un repositorio, que reciba el total como parámetro y deja que lo obtenga quien llama. Una función que solo depende de sus argumentos es trivial de testear.
  2. Usa un fake. Una implementación en memoria de una interfaz de repositorio pequeña hace que una docena de tests se lean como código normal y detecta errores de ordenación que los stubs no pueden detectar.
  3. Usa mocks con moderación. Resérvalos para las fronteras de verdad —una pasarela de pago, un servicio de envío de correo—, donde la interacción es el comportamiento que merece la pena comprobar.

Cuando un test necesita más de unas tres llamadas stubbeadas para ejecutarse, el diseño suele estar pidiendo un objeto más pequeño. Esa es una de las señales más fiables que ofrece la práctica.

Dónde cuesta el test-first

Código legacy. No puedes guiar código que no puedes instanciar. Envuelve primero el comportamiento existente en un test de caracterización de grano grueso —registra lo que hace ahora, sea correcto o no—, después introduce una costura y trabaja con test-first dentro de ella.

Algoritmos cuya implementación no es fácil de adivinar. En una rutina numérica o un algoritmo de grafos, los tests pequeños no te llevarán al enfoque correcto; primero hay que entender el algoritmo. El test-first sigue ayudando, pero como forma de fijar la especificación y los casos límite, no de descubrir el método.

Interfaces de usuario. El diseño visual y la interacción se juzgan a ojo. Guía la lógica de presentación con test-first y mantén la capa de renderizado lo bastante fina como para que su corrección sea evidente.

Exploración. Cuando estás aprendiendo una API desconocida, escribe un spike sin tests, aprende lo que necesitabas, bórralo y empieza de nuevo con test-first. Intentar hacer TDD en terreno desconocido produce tests para conjeturas.

Código muy concurrente. Un test que pasa noventa y nueve veces de cada cien es peor que no tener test. La concurrencia requiere diseños deterministas, pruebas de estrés y herramientas especializadas, no el ciclo habitual.

Las objeciones, respondidas con honestidad

«Duplica la cantidad de código». Más o menos, sí: el código de test suele tener un tamaño comparable al de producción. La cuestión es si ese código se amortiza en menos depuración, refactorizaciones más seguras y documentación viva. En un código base que se mantendrá durante años, normalmente sí; en un script que se ejecuta una vez, no.

«La evidencia no es concluyente». Cierto. Los estudios empíricos que comparan test-first con test-after informan de efectos que van desde una reducción significativa de defectos hasta ninguna diferencia apreciable, con resultados igual de dispares en cuanto al esfuerzo. Buena parte del beneficio medido podría venir de escribir unidades pequeñas y muchos tests, más que del orden. Es un motivo para ser modesto en lo que se afirma, no para dejar de testear.

«Mis tests se rompen cada vez que refactorizo». Entonces los tests comprueban estructura en lugar de comportamiento: normalmente demasiadas llamadas a verify, o tests escritos contra métodos privados que se han hecho visibles a nivel de paquete. Comprueba lo que puede observar quien llama.

«No tenemos tiempo». El tiempo se gasta de todas formas; la elección es entre escribir tests y volver a comprobar el comportamiento a mano cada vez que algo cambia. La versión honesta de esta objeción suele tener que ver con la fecha de entrega de un prototipo, y para un prototipo que de verdad es desechable es un intercambio razonable, siempre que el prototipo se tire de verdad.

«La cobertura ya es alta, así que estamos bien». La cobertura mide ejecución, no aserciones. Una suite puede ejecutar todas las líneas sin comprobar casi nada. Lo que produce el TDD no es cobertura, sino la garantía de que cada comportamiento tuvo un test que fallaba mientras ese comportamiento no existía, una propiedad mucho más fuerte y que la guía de estrategia de automatización defiende anteponer a un objetivo de cobertura.

Ideas clave

  • El ciclo es red, green, refactor, y el paso de refactorización es el que convierte una suite de tests en una práctica de diseño en lugar de una simple red de seguridad.
  • Escribe el código más simple que pase, constantes incluidas; el siguiente test es lo que obliga a generalizar, y la duplicación que genera es lo que revela la estructura correcta.
  • La presión del test-first hace que el código sea construible, observable y pequeño, y retrasa las abstracciones hasta que un segundo caso de uso las justifica.
  • Elige outside-in para descubrir interfaces en la frontera de una funcionalidad e inside-out para las unidades con lógica real; la mayoría de los equipos usan ambas en la misma funcionalidad.
  • Saca las dependencias fuera, prefiere fakes a mocks y trata «este test necesita cuatro stubs» como un problema de diseño, no de preparación.
  • Sé honesto con los límites: el código legacy necesita primero costuras, los algoritmos necesitan primero comprensión, y la evidencia empírica respalda afirmaciones modestas, no grandilocuentes.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Es el TDD más lento que escribir primero el código?
Funcionalidad a funcionalidad, escribir la primera versión suele ser algo más lento, porque pagas los tests por adelantado en lugar de no pagarlos nunca. A lo largo de la vida de un código base la comparación cambia: el tiempo de depuración cae en picado porque los fallos aparecen segundos después de introducirse, y refactorizar deja de ser arriesgado. Los estudios controlados muestran efectos dispares sobre la densidad de defectos y el esfuerzo, así que desconfía de quien prometa aceleraciones espectaculares y júzgalo en tu propio código.
¿Tengo que escribir un test antes de cada línea?
No. La disciplina consiste en que no se añade ningún comportamiento de producción sin que un test que falla lo exija, y eso no equivale a un test por línea. El trabajo exploratorio, los spikes y los prototipos se hacen mejor sin tests y luego se tiran o se reescriben con test-first. La configuración, el código generado y la delegación trivial rara vez salen ganando. Ser dogmático con esto es justo lo que lleva a los equipos a escribir tests para los getters.
¿Qué diferencia hay entre la escuela de Londres y la de Chicago?
La escuela de Londres (mockista, outside-in) empieza en la frontera del sistema, sustituye por un mock cada colaborador según lo descubre y guía el diseño de arriba abajo a través de interfaces. La escuela de Chicago (clásica, inside-out) empieza por los objetos de dominio, usa colaboradores reales siempre que sean baratos y crece hacia fuera. Londres da un feedback más rápido sobre la estructura y tests más frágiles; Chicago da tests más robustos y descubre las interfaces más tarde. La mayoría de los equipos combinan ambas.
¿Se puede aplicar TDD a código legacy sin tests?
No directamente, porque el código legacy normalmente no se puede instanciar en un test sin arrastrar media aplicación. El camino habitual es añadir primero un test de caracterización alrededor del comportamiento existente, en la frontera más gruesa a la que se pueda llegar, usarlo como red de seguridad para introducir una costura (seam) —extraer una interfaz, añadir un parámetro al constructor, romper una dependencia estática— y después trabajar con test-first dentro de esa costura. La red crece grieta a grieta.