Una kata TDD (incluye código) en Java

El desarrollo de software es un viaje constante de aprendizaje y perfeccionamiento. En este viaje, uno de los desafíos más persistentes es el de escribir código que no solo funcione, sino que sea robusto, fácil de entender, mantener y modificar. Demasiado a menudo, nos encontramos con sistemas donde la adición de una pequeña funcionalidad o la corrección de un error se convierte en una operación de alto riesgo, con el temor latente de introducir nuevos fallos en cascada. Es en este escenario donde metodologías como el Desarrollo Guiado por Pruebas (TDD, por sus siglas en inglés) emergen como un faro, ofreciendo una disciplina que promete no solo reducir errores, sino también mejorar el diseño de nuestro software de manera significativa.

Hoy, nos embarcaremos en una "kata TDD" utilizando Java. Las katas, tomadas del arte marcial, son ejercicios de repetición diseñados para afinar una habilidad fundamental. En el contexto del desarrollo de software, una kata TDD es una práctica intencionada para dominar el ciclo de Red-Green-Refactor, construyendo una funcionalidad pequeña y bien definida paso a paso. No se trata solo de ver el código final funcionando, sino de comprender el proceso, la mentalidad y los beneficios de una disciplina que, en mi opinión, es transformadora para cualquier desarrollador que busca la excelencia. A través de este ejercicio, construiremos una solución para un problema común, demostrando cómo TDD puede guiar el diseño, asegurar la calidad y proporcionar una confianza inquebrantable en nuestro código.

¿Qué es una kata TDD?

Laptop displaying code editor on a desk with a coffee mug beside it, suggesting a workspace or home office setting.

Antes de sumergirnos en el código, es crucial entender qué implican los términos "kata" y "TDD" cuando se juntan. Una "kata" en el desarrollo de software es un ejercicio de programación repetitivo y generalmente pequeño, diseñado para practicar una habilidad específica. El objetivo no es tanto resolver un problema complejo de una vez, sino internalizar un conjunto de prácticas a través de la repetición y la reflexión. Así como un músico practica escalas o un karateka repite movimientos básicos, un desarrollador practica katas para mejorar su destreza.

Por otro lado, el Desarrollo Guiado por Pruebas (TDD) es una metodología de desarrollo de software donde se escribe una prueba fallida antes de escribir el código funcional que la satisface. Su ciclo es simple pero poderoso:

  1. Rojo (Red): Escribir una prueba unitaria para una pequeña porción de funcionalidad deseada. Esta prueba debe fallar al principio, ya sea porque la funcionalidad aún no existe o porque no se comporta como se espera.
  2. Verde (Green): Escribir la mínima cantidad de código de producción necesaria para que la prueba recién escrita pase. El objetivo aquí es solo hacer que la prueba sea "verde", sin preocuparse demasiado por la elegancia o la optimización.
  3. Refactorizar (Refactor): Una vez que la prueba pasa, y solo entonces, se puede mejorar el código de producción. Esto puede implicar reorganizar la estructura, eliminar duplicaciones, mejorar la legibilidad o aplicar patrones de diseño, todo ello con la seguridad de que las pruebas existentes garantizan que la funcionalidad no se ha roto.

Este ciclo se repite continuamente, construyendo el software en pequeños incrementos y asegurando que cada pieza de funcionalidad esté cubierta por pruebas. Los beneficios son múltiples: un diseño de código más limpio y modular (ya que el código es diseñado para ser fácilmente "testeable"), una suite de regresión robusta que previene la introducción de nuevos errores, y una mayor confianza en el sistema. Personalmente, encuentro que TDD obliga a una claridad de pensamiento sobre los requisitos antes de implementar, lo cual es invaluable. Si quieres profundizar más en los principios de TDD, te recomiendo este artículo de Martin Fowler sobre Test-Driven Development: Test-Driven Development.

Preparando el entorno para nuestra kata

Para llevar a cabo nuestra kata en Java, necesitaremos algunas herramientas estándar en el ecosistema:

  • Java Development Kit (JDK): Cualquier versión moderna (Java 11 o superior es ideal).
  • Un gestor de dependencias: Maven o Gradle son las opciones más comunes. Usaremos Maven para este ejemplo.
  • Un IDE: IntelliJ IDEA, Eclipse o VS Code con las extensiones de Java son excelentes opciones.
  • JUnit 5: El framework de pruebas unitarias más popular en Java.

Comenzaremos creando un proyecto Maven simple. Si estás usando IntelliJ, puedes ir a "File > New > Project..." y seleccionar "Maven". Si prefieres la línea de comandos, puedes usar:

mvn archetype:generate -DgroupId=com.mycompany.katatdd -DartifactId=coffee-machine -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false

Esto generará una estructura básica de proyecto. Lo más importante será nuestro archivo pom.xml, donde declararemos las dependencias de JUnit 5.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.mycompany.katatdd</groupId>
    <artifactId>coffee-machine</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <junit.jupiter.version>5.10.0</junit.jupiter.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-engine</artifactId>
            <version>${junit.jupiter.version}</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.0.0-M5</version>
            </plugin>
        </plugins>
    </build>

</project>

He configurado el proyecto para Java 17 y la última versión estable de JUnit Jupiter. El plugin maven-surefire-plugin es crucial para ejecutar las pruebas. Puedes encontrar más detalles sobre JUnit 5 en su documentación oficial: JUnit 5 User Guide.

La kata del día: La máquina de café

Para nuestra kata, construiremos una sencilla máquina de café. Los requisitos iniciales son los siguientes:

  1. La máquina debe poder recibir dinero (monedas).
  2. Debe ofrecer diferentes tipos de bebidas (café, té, chocolate caliente) con precios fijos.
  3. Debe poder servir la bebida si el saldo introducido es suficiente.
  4. Debe calcular y devolver el cambio restante al usuario.
  5. Debe manejar la situación donde el saldo es insuficiente para una bebida.

Este problema es lo suficientemente simple como para ser abordado en un tiempo razonable, pero lo suficientemente complejo como para permitirnos practicar varias facetas del TDD. Lo abordaremos de forma incremental, añadiendo funcionalidad poco a poco, guiados por nuestras pruebas.

Implementación paso a paso con TDD

Crearemos nuestra clase principal CoffeeMachine.java en src/main/java/com/mycompany/katatdd y su clase de pruebas CoffeeMachineTest.java en src/test/java/com/mycompany/katatdd.

Primer test: Verificar el estado inicial

Empezaremos con lo más básico: ¿cómo se inicializa nuestra máquina de café? Esperamos que, al crearla, su saldo interno sea cero.

Rojo: Escribimos el test.

// src/test/java/com/mycompany/katatdd/CoffeeMachineTest.java
package com.mycompany.katatdd;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class CoffeeMachineTest {

    private CoffeeMachine machine;

    @BeforeEach
    void setUp() {
        machine = new CoffeeMachine();
    }

    @Test
    @DisplayName("Debería inicializar la máquina con saldo cero")
    void shouldInitializeWithZeroBalance() {
        assertEquals(0, machine.getCurrentBalance());
    }
}

Al ejecutar esta prueba (mvn test o desde tu IDE), debería fallar con un error de compilación porque la clase CoffeeMachine y el método getCurrentBalance() no existen. ¡Perfecto, está en "rojo"!

Verde: Ahora, creamos la clase y el método mínimo para que pase la prueba.

// src/main/java/com/mycompany/katatdd/CoffeeMachine.java
package com.mycompany.katatdd;

public class CoffeeMachine {

    private int currentBalance; // Usaremos céntimos para evitar problemas de punto flotante

    public CoffeeMachine() {
        this.currentBalance = 0;
    }

    public int getCurrentBalance() {
        return currentBalance;
    }
}

Ejecutamos las pruebas de nuevo. ¡Debería estar en "verde"!

Refactorizar: En este punto, no hay mucho que refactorizar. El código es simple y claro. El uso de int para el saldo, representando céntimos, es una buena práctica para evitar imprecisiones con double o float al manejar dinero.

Recibir dinero

El siguiente paso es permitir que la máquina reciba dinero.

Rojo: Escribimos una prueba para aceptar monedas.

// Dentro de CoffeeMachineTest.java
    @Test
    @DisplayName("Debería aceptar monedas correctamente")
    void shouldAcceptCoinsCorrectly() {
        machine.insertCoin(50); // Insertamos 50 céntimos
        assertEquals(50, machine.getCurrentBalance());

        machine.insertCoin(100); // Insertamos 1 euro (100 céntimos)
        assertEquals(150, machine.getCurrentBalance());
    }

Esta prueba fallará porque el método insertCoin() no existe.

Verde: Implementamos insertCoin().

// Dentro de CoffeeMachine.java
    public void insertCoin(int amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("La cantidad de la moneda debe ser positiva.");
        }
        this.currentBalance += amount;
    }

He añadido una pequeña validación para que la cantidad sea positiva. Ejecutamos las pruebas. ¡Verde!

Refactorizar: El código es bastante simple. Podríamos considerar si el amount debe ser un tipo enumerado de moneda (Coin.FIFTY_CENTS, Coin.ONE_EURO) en un sistema más complejo, pero para esta kata, un int es suficiente y claro.

Seleccionar una bebida

Ahora, la máquina debe poder servir bebidas. Necesitamos definir las bebidas y sus precios.

Primero, creamos un enum para representar las bebidas y sus precios.

// src/main/java/com/mycompany/katatdd/Drink.java
package com.mycompany.katatdd;

public enum Drink {
    COFFEE(100), // 1 euro
    TEA(120),    // 1.20 euros
    HOT_CHOCOLATE(150); // 1.50 euros

    private final int price;

    Drink(int price) {
        this.price = price;
    }

    public int getPrice() {
        return price;
    }
}

Rojo: Test para servir una bebida con saldo suficiente.

// Dentro de CoffeeMachineTest.java
    @Test
    @DisplayName("Debería servir café si hay saldo suficiente")
    void shouldServeCoffeeIfBalanceIsSufficient() {
        machine.insertCoin(200); // 2 euros
        String message = machine.selectDrink(Drink.COFFEE);
        assertEquals("Aquí tienes tu café. Tu cambio es: 100 céntimos.", message);
        assertEquals(0, machine.getCurrentBalance()); // El saldo debe ser cero después de la transacción
    }

Este test fallará porque selectDrink() no existe.

Verde: Implementamos selectDrink().

// Dentro de CoffeeMachine.java
    public String selectDrink(Drink drink) {
        if (currentBalance >= drink.getPrice()) {
            currentBalance -= drink.getPrice();
            int change = currentBalance; // El cambio es lo que queda
            currentBalance = 0; // Resetear saldo después de la transacción
            return "Aquí tienes tu " + drink.name().toLowerCase() + ". Tu cambio es: " + change + " céntimos.";
        } else {
            return "Saldo insuficiente. Te faltan " + (drink.getPrice() - currentBalance) + " céntimos.";
        }
    }

Ejecutamos las pruebas. ¡Verde! ¡Todos los tests pasan!

Rojo: Ahora, ¿qué pasa si el saldo es insuficiente?

// Dentro de CoffeeMachineTest.java
    @Test
    @DisplayName("Debería rechazar bebida por falta de saldo")
    void shouldRejectDrinkDueToInsufficientBalance() {
        machine.insertCoin(50); // 50 céntimos
        String message = machine.selectDrink(Drink.COFFEE); // Café cuesta 100
        assertEquals("Saldo insuficiente. Te faltan 50 céntimos.", message);
        assertEquals(50, machine.getCurrentBalance()); // El saldo no debe haber cambiado
    }

Ejecutamos esta prueba. Ya debería estar en verde gracias a la lógica que añadimos previamente. Esto es un buen ejemplo de cómo el TDD a veces nos lleva a cubrir escenarios adicionales con la misma implementación inicial. Sin embargo, es buena práctica escribir el test antes para asegurarnos de que la lógica es correcta para ese caso específico.

Refactorizar: La lógica en selectDrink() es clara. La forma en que manejamos el cambio y reseteamos el saldo es explícita. Podríamos pensar en una interfaz DrinkMachine si tuviéramos diferentes tipos de máquinas, pero para esta kata, la clase actual es adecuada. La construcción de mensajes de string es un poco tosca, pero funcional. Podríamos usar String.format o builders para mayor elegancia si los mensajes fueran más complejos, pero no es crítico aquí.

Calcular y devolver cambio

Actualmente, el cambio se devuelve como parte del mensaje de la bebida. Sin embargo, ¿y si el usuario quiere cancelar la operación y recuperar su dinero antes de elegir una bebida?

Rojo: Creamos una prueba para devolver el cambio explícitamente.

// Dentro de CoffeeMachineTest.java
    @Test
    @DisplayName("Debería devolver el cambio restante al cancelar")
    void shouldReturnChangeWhenCancelled() {
        machine.insertCoin(200); // 2 euros
        int change = machine.returnChange();
        assertEquals(200, change);
        assertEquals(0, machine.getCurrentBalance()); // El saldo debe resetearse
    }

    @Test
    @DisplayName("Debería devolver cero si no hay saldo para devolver")
    void shouldReturnZeroIfNoBalanceToReturn() {
        int change = machine.returnChange();
        assertEquals(0, change);
        assertEquals(0, machine.getCurrentBalance());
    }

Estas pruebas fallarán porque returnChange() no existe.

Verde: Implementamos returnChange().

// Dentro de CoffeeMachine.java
    public int returnChange() {
        int changeToReturn = currentBalance;
        currentBalance = 0; // Resetear el saldo
        return changeToReturn;
    }

Ejecutamos las pruebas. ¡Verde!

Refactorizar: La máquina ahora tiene un método dedicado para devolver el cambio. Esto separa las responsabilidades: seleccionar una bebida y gestionar el cambio. Este tipo de refactorización guiada por la necesidad (o un nuevo test) es una de las mayores fortalezas del TDD.

Casos límite y refactorización continua

Aunque hemos cubierto los casos principales, una buena práctica de TDD es pensar en los "bordes" o "casos límite".

  • ¿Qué pasa si se introduce una moneda negativa o cero? Ya lo hemos cubierto en insertCoin().
  • ¿Y si se selecciona una bebida inexistente? Nuestro enum Drink lo previene.
  • ¿Qué pasa si el saldo es exactamente el precio de la bebida? El test shouldServeCoffeeIfBalanceIsSufficient cubre esto implícitamente si insertamos 100 céntimos y pedimos café.

A medida que el proyecto crece, el refactoring se vuelve más importante. Por ejemplo, la máquina de café podría empezar a tener complejidades adicionales:

  • Máquina sin existencias de una bebida.
  • Gestión de un inventario de monedas para devolver el cambio exacto (no solo la suma total).
  • Interfaz de usuario (CLI o GUI).
  • Múltiples usuarios.

Cada una de estas características sería una nueva serie de ciclos Rojo-Verde-Refactor. Por ejemplo, para la gestión de existencias de bebidas, podríamos tener un nuevo test:

    @Test
    @DisplayName("Debería indicar que una bebida está agotada")
    void shouldIndicateDrinkIsOutOfStock() {
        machine.insertCoin(200);
        // Suponiendo que el café está agotado
        machine.setDrinkStock(Drink.COFFEE, 0); // Nuevo método a implementar
        String message = machine.selectDrink(Drink.COFFEE);
        assertEquals("Lo sentimos, el café está agotado.", message);
        assertEquals(200, machine.getCurrentBalance()); // El saldo no debe cambiar
    }

Este test nos forzaría a añadir un mecanismo de stock y modificar el método selectDrink. El proceso sería el mismo: ver fallar el test, implementar lo mínimo, y lu

Diario Tecnología