En el vertiginoso mundo del desarrollo de software, la calidad y la mantenibilidad del código son pilares fundamentales. Sin embargo, ¿cuántas veces nos hemos encontrado con bases de código complejas, difíciles de entender o, peor aún, de modificar sin introducir nuevos errores? Aquí es donde el Desarrollo Guiado por Pruebas (TDD) y las katas de código emergen como herramientas poderosas, no solo para mejorar la calidad del software, sino también para pulir nuestras habilidades como desarrolladores. Este post te guiará a través de una kata TDD práctica utilizando Python, desgranando el proceso paso a paso y demostrando cómo esta metodología puede transformar tu enfoque de la programación. Prepárate para sumergirte en el ciclo Rojo-Verde-Refactor y descubrir el valor intrínseco de construir software robusto desde cero.
Entendiendo el TDD y las katas de código
Antes de ensuciarnos las manos con código, es crucial asentar las bases teóricas de estas dos poderosas metodologías que, combinadas, ofrecen un camino excepcional para el aprendizaje y la mejora continua en el desarrollo de software.
¿Qué es el desarrollo guiado por pruebas (TDD)?
El Desarrollo Guiado por Pruebas, o TDD por sus siglas en inglés (Test-Driven Development), es una metodología de desarrollo de software donde se escribe una prueba fallida antes de escribir el código funcional mínimo necesario para que esa prueba pase. No se trata simplemente de "escribir pruebas", sino de usarlas como una especificación de diseño que guía la implementación. El TDD sigue un ciclo simple pero profundo:
- Rojo (Red): Escribe una pequeña prueba que verifique un requisito o un caso de uso. Esta prueba, naturalmente, fallará porque aún no existe el código que la satisfaga. Este paso nos obliga a pensar en la interfaz de nuestro código antes de la implementación interna.
- Verde (Green): Escribe el código más simple y mínimo posible que haga que la prueba que acabas de escribir pase. En esta fase, la prioridad es que la prueba pase, sin preocuparse demasiado por la elegancia o la optimización del código.
- Refactor (Refactor): Una vez que la prueba está en verde, es el momento de mejorar la calidad del código, reorganizarlo, eliminar duplicidades o aplicar patrones de diseño, todo ello sin alterar su comportamiento ni hacer que las pruebas fallen. Las pruebas existentes actúan como una red de seguridad, asegurando que los cambios no introduzcan regresiones.
Este ciclo se repite continuamente, construyendo el software pieza por pieza, con una suite de pruebas creciente que documenta el comportamiento esperado y protege contra futuros errores. Personalmente, encuentro que el TDD no solo ayuda a reducir los errores, sino que también fomenta un diseño de software más modular y cohesionado, ya que nos obliga a pensar en las responsabilidades de cada componente desde el principio. Para una inmersión más profunda en el tema, recomiendo este excelente recurso sobre TDD: Martin Fowler sobre TDD.
¿Qué es una kata de código?
El término "kata" proviene de las artes marciales japonesas, donde se refiere a una secuencia de movimientos o formas que se practican repetidamente para perfeccionar una técnica. En el contexto del desarrollo de software, una "kata de código" es un ejercicio de programación pequeño y bien definido que se practica con el objetivo de mejorar las habilidades de codificación. No se trata de resolver un problema una vez, sino de resolverlo múltiples veces, experimentando con diferentes enfoques, patrones y herramientas.
Las katas son excelentes para:
- Practicar TDD y otras metodologías de desarrollo.
- Familiarizarse con un nuevo lenguaje o framework.
- Mejorar la capacidad de diseño de software.
- Aumentar la velocidad y la eficiencia al codificar.
- Desarrollar la habilidad de refactorizar de manera efectiva.
No hay un "resultado final" único en una kata; el valor reside en el proceso y en el aprendizaje. Son como el gimnasio mental del programador. Si quieres explorar más sobre las katas, echa un vistazo a CodeKata de Dave Thomas.
Preparando el terreno para nuestra kata
Con una comprensión clara de TDD y las katas, es hora de elegir el ejercicio que nos permitirá aplicar estos principios en un entorno práctico.
Eligiendo la kata: la máquina expendedora simple
Para nuestra kata, trabajaremos con una versión simplificada de una "máquina expendedora". Este problema es lo suficientemente sencillo como para abordarlo en un tiempo razonable, pero lo bastante complejo como para ilustrar los beneficios de TDD y el ciclo Rojo-Verde-Refactor en la construcción incremental de funcionalidad.
Los requisitos iniciales para nuestra máquina expendedora serán:
- Aceptar monedas y mostrar el crédito acumulado.
- Permitir seleccionar un producto.
- Dispensar el producto y devolver el cambio si hay suficiente crédito.
- Manejar casos de crédito insuficiente o producto agotado.
Simplificaremos las cosas: solo aceptaremos unos pocos tipos de monedas (por ejemplo, 5, 10, 25 céntimos, 1 euro) y tendremos un inventario limitado de productos.
Herramientas necesarias
Para esta kata, solo necesitaremos lo siguiente:
- Python 3: Idealmente, una versión reciente (3.8+).
unittest: El módulo de pruebas unitarias integrado en Python. No necesitamos instalar nada adicional, lo cual es perfecto para empezar.- Un editor de código o IDE: Visual Studio Code, PyCharm, Sublime Text o incluso un editor de texto simple.
Crearemos dos archivos: maquina_expendedora.py para nuestra lógica de negocio y test_maquina_expendedora.py para nuestras pruebas.
Iniciando la kata: el ciclo rojo-verde-refactor en acción
Comencemos con el primer requisito: aceptar monedas y sumar el crédito.
Paso 1: Rojo (Escribir una prueba que falle)
Nuestro primer objetivo es que la máquina pueda aceptar una moneda y que su crédito se actualice. Necesitamos una manera de "insertar" una moneda y luego "verificar" el crédito actual.
Creemos el archivo test_maquina_expendedora.py:
import unittest
from maquina_expendedora import MaquinaExpendedora
class TestMaquinaExpendedora(unittest.TestCase):
def setUp(self):
self.maquina = MaquinaExpendedora()
def test_maquina_inicia_con_cero_credito(self):
self.assertEqual(self.maquina.obtener_credito(), 0)
def test_insertar_moneda_suma_al_credito(self):
self.maquina.insertar_moneda(25) # Suponemos 25 céntimos
self.assertEqual(self.maquina.obtener_credito(), 25)
if __name__ == '__main__':
unittest.main()
Ahora, si intentamos ejecutar estas pruebas (python -m unittest test_maquina_expendedora.py), veremos que fallan. ¿Por qué? Porque aún no hemos definido la clase MaquinaExpendedora ni sus métodos obtener_credito e insertar_moneda. Es precisamente esto lo que esperamos en la fase "Rojo". Las pruebas están definiendo la interfaz que nuestra clase debe tener. La frustración inicial de ver una prueba fallar es, de hecho, una señal de progreso en TDD, ya que nos está indicando el camino a seguir.
Paso 2: Verde (Escribir el código mínimo para pasar la prueba)
Ahora, nuestro objetivo es hacer que las pruebas pasen, escribiendo el código más simple posible. Creamos maquina_expendedora.py:
class MaquinaExpendedora:
def __init__(self):
self._credito = 0
def obtener_credito(self):
return self._credito
def insertar_moneda(self, valor):
self._credito += valor
Si ejecutamos las pruebas de nuevo, ¡deberían pasar! Hemos llegado a la fase "Verde". Nuestro código es mínimo, hace lo que se le pide y nada más.
Paso 3: Refactor (Mejorar el código sin cambiar el comportamiento)
En este punto, el código funciona, pero ¿podemos mejorarlo? Por ejemplo, ¿qué pasa si queremos manejar diferentes tipos de monedas y no solo un valor numérico arbitrario? Podríamos definir las monedas aceptadas y sus valores.
class MaquinaExpendedora:
MONEDAS_ACEPTADAS = {
'5c': 5,
'10c': 10,
'25c': 25,
'1e': 100
}
def __init__(self):
self._credito = 0
def obtener_credito(self):
return self._credito
def insertar_moneda(self, tipo_moneda):
if tipo_moneda in self.MONEDAS_ACEPTADAS:
self._credito += self.MONEDAS_ACEPTADAS[tipo_moneda]
else:
# Podríamos lanzar una excepción o devolver la moneda
pass # Por ahora, ignoramos monedas no válidas
Necesitamos adaptar nuestra prueba test_insertar_moneda_suma_al_credito para usar los tipos de moneda definidos:
def test_insertar_moneda_suma_al_credito(self):
self.maquina.insertar_moneda('25c') # Ahora insertamos el tipo de moneda
self.assertEqual(self.maquina.obtener_credito(), 25)
self.maquina.insertar_moneda('1e')
self.assertEqual(self.maquina.obtener_credito(), 125)
Al refactorizar, ejecutamos las pruebas nuevamente para asegurarnos de que no hemos roto nada. El refactor es a menudo la parte más subestimada del ciclo TDD, pero es crucial. Nos permite mantener el código limpio y adaptable sin el miedo de introducir regresiones, gracias a nuestra suite de pruebas.
Desarrollando la funcionalidad: un paso a la vez
Continuamos con el siguiente requisito: la selección de productos.
Añadiendo productos y selección
Ahora nuestra máquina expendedora necesita tener productos en inventario y la capacidad de seleccionarlos.
Paso 1: Rojo (Pruebas para productos)
Añadimos un método obtener_inventario y seleccionar_producto a nuestra clase MaquinaExpendedora. Primero, las pruebas:
def test_maquina_inicia_con_inventario(self):
self.assertGreater(len(self.maquina.obtener_inventario()), 0)
self.assertIn('Coca-Cola', self.maquina.obtener_inventario())
def test_seleccionar_producto_con_suficiente_credito(self):
self.maquina.insertar_moneda('1e') # 100 centimos
resultado = self.maquina.seleccionar_producto('Coca-Cola') # Suponemos Coca-Cola cuesta 75
self.assertEqual(resultado['producto'], 'Coca-Cola')
self.assertEqual(resultado['cambio'], 25)
self.assertEqual(self.maquina.obtener_credito(), 0) # El crédito debe resetearse
def test_seleccionar_producto_con_credito_insuficiente(self):
self.maquina.insertar_moneda('25c') # 25 centimos
resultado = self.maquina.seleccionar_producto('Coca-Cola')
self.assertIsNone(resultado) # No se dispensa nada
self.assertEqual(self.maquina.obtener_credito(), 25) # El crédito se mantiene
Paso 2: Verde (Implementar productos)
Ahora, hacemos que estas pruebas pasen. Necesitamos un inventario y la lógica para seleccionar un producto.
class MaquinaExpendedora:
MONEDAS_ACEPTADAS = {
'5c': 5,
'10c': 10,
'25c': 25,
'1e': 100
}
INVENTARIO_INICIAL = {
'Coca-Cola': {'precio': 75, 'cantidad': 5},
'Agua': {'precio': 50, 'cantidad': 3},
'Snack': {'precio': 120, 'cantidad': 2}
}
def __init__(self):
self._credito = 0
self._inventario = self.INVENTARIO_INICIAL.copy() # Copia para que cada instancia tenga su propio inventario
def obtener_credito(self):
return self._credito
def obtener_inventario(self):
return {nombre: data['cantidad'] for nombre, data in self._inventario.items()}
def insertar_moneda(self, tipo_moneda):
if tipo_moneda in self.MONEDAS_ACEPTADAS:
self._credito += self.MONEDAS_ACEPTADAS[tipo_moneda]
return True
return False
def seleccionar_producto(self, nombre_producto):
if nombre_producto not in self._inventario:
return None # Producto no existe
producto = self._inventario[nombre_producto]
precio = producto['precio']
cantidad_disponible = producto['cantidad']
if cantidad_disponible == 0:
return None # Agotado
if self._credito >= precio:
cambio = self._credito - precio
self._credito = 0 # Reiniciar crédito después de la compra
producto['cantidad'] -= 1
return {'producto': nombre_producto, 'cambio': cambio}
else:
return None # Crédito insuficiente
Ejecutamos las pruebas. Deberían estar en verde. Si hay errores, volvemos a depurar con el código mínimo. La documentación oficial del módulo unittest puede ser muy útil para entender más a fondo las aserciones: Documentación de unittest.
Gestionando el cambio
La lógica para devolver el cambio ya está incluida en el método seleccionar_producto. Sin embargo, es buena idea asegurar que el cálculo sea robusto, especialmente cuando trabajamos con números flotantes, aunque en nuestro caso estamos usando céntimos como enteros para evitar esos problemas.
Podríamos añadir una prueba específica para un caso de cambio más complejo:
def test_seleccionar_producto_y_devolver_cambio_exacto(self):
self.maquina.insertar_moneda('1e') # 100
self.maquina.insertar_moneda('25c') # 25 -> total 125
resultado = self.maquina.seleccionar_producto('Snack') # Snack cuesta 120
self.assertEqual(resultado['producto'], 'Snack')
self.assertEqual(resultado['cambio'], 5)
self.assertEqual(self.maquina.obtener_credito(), 0)
Si esta prueba pasa, nuestra lógica de cambio está confirmada. Mi opinión aquí es que este tipo de pruebas detalladas, aunque parezcan redundantes, son increíblemente valiosas para los casos límite y para entender a fondo las implicaciones de cada decisión de diseño. Nos fuerza a pensar en todos los escenarios posibles.
Probando estados y errores
Una máquina expendedora debe manejar no solo los escenarios felices, sino también los errores y estados inesperados.
Validando entradas inválidas
¿Qué pasa si alguien intenta insertar una "moneda" que no reconocemos?
Paso 1: Rojo (Prueba para moneda no válida)
def test_insertar_moneda_no_valida_no_suma_al_credito(self):
credito_inicial = self.maquina.obtener_credito()
self.maquina.insertar_moneda('moneda_falsa')
self.assertEqual(self.maquina.obtener_credito(), credito_inicial) # El crédito no debe cambiar
Paso 2: Verde (Manejo de moneda no válida)
Nuestro método insertar_moneda ya maneja esto, devolviendo False si la moneda no es válida y no modificando el crédito. La prueba pasará directamente.
def insertar_moneda(self, tipo_moneda):
if tipo_moneda in self.MONEDAS_ACEPTADAS:
self._credito += self.MONEDAS_ACEPTADAS[tipo_moneda]
return True # Moneda válida
return False # Moneda no válida
Probando la reposición de productos
¿Qué ocurre si un producto se agota? Ya tenemos una prueba básica, pero podemos ser más específicos.
Paso 1: Rojo (Prueba para producto agotado)
def test_seleccionar_producto_agotado_devuelve_none(self):
# Primero, agotamos un producto para la prueba
self.maquina._inventario['Agua']['cantidad'] = 0
self.maquina.insertar_moneda('1e') # Suficiente crédito
resultado = self.maquina.seleccionar_producto('Agua')
self.assertIsNone(resultado)
self.assertEqual(self.maquina.obtener_credito(), 100) # El crédito se mantiene
Paso 2: Verde (Confirmación de lógica de agotamiento)
Nuestra implementación actual ya maneja esto:
if cantidad_disponible == 0:
return None # Agotado
La prueba pasará. Esto demuestra cómo TDD no solo te ayuda a construir nuevas funcionalidades, sino también a validar el comportamiento existente y asegurar que tu código sea robusto frente a diferentes estados. Al seguir el ciclo Rojo-Verde-Refactor, estamos aplicando implícitamente principios de buen diseño como la encapsulación y la responsabilidad única, que son la base de los Principios SOLID.
Más allá de la kata: lecciones aprendidas
Hemos completado una pequeña kata, construyendo una máquina expendedora básica paso a paso, guiados por TDD. Pero las lecciones van mucho más allá del simple código.
Beneficios palpables de TDD en la práctica
La práctica de TDD, incluso en una kata pequeña, revela una serie de beneficios tangibles:
- Confianza en el código: Cada línea de código que escribimos está respaldada por una prueba. Esto genera una enorme confianza al refactorizar o añadir nuevas características, sabiendo que cualquier ruptura será detectada inmediatamente.
- Diseño mejorado: Al pensar en las pruebas primero, nos vemos obligados a considerar cómo se usará nuestro código. Esto conduce a interfaces más limpias, responsabilidades claras y, en general, un diseño más modular y fácil de mantener.
- Documentación viva: La suite de pruebas se convierte en una documentación ejecutable del comportamiento de nuestro sistema. Cualquiera puede entender rápidamente qué hace el código leyendo las pruebas.
- Menos errores: Es casi imposible introducir errores de regresión si la cobertura de pruebas es buena y se ejecutan consistentemente.
- Reduce la complejidad mental: En lugar de tratar de resolver todo el problema de una vez, TDD nos anima a dividirlo en pequeñas piezas manejables, reduciendo la carga cognitiva.
La importancia de la práctica constante
Las katas de código son una herramienta invaluable para cualquier desarrollador que quiera perfeccionar su arte. No se trata solo de aprender TDD, sino de internalizar los hábitos de buen diseño, la escritura de código limpio y la capacidad de refactorizar con confianza. Al igual que un músico practica escalas o un atleta entrena movimientos básicos, los programadores se benefician e