En el dinámico mundo del desarrollo de software, donde la agilidad y la calidad son pilares fundamentales, el desarrollo dirigido por pruebas (TDD por sus siglas en inglés, Test-Driven Development) emerge como una metodología robusta que promete justamente eso. Pero, ¿cómo se adquiere la fluidez necesaria para aplicar TDD de manera efectiva en proyectos reales? Aquí es donde entran en juego las "katas de código", pequeños ejercicios de programación que, al igual que los movimientos en las artes marciales, se repiten y perfeccionan para dominar una técnica. Hoy, nos sumergiremos en una de estas katas clásicas utilizando Python: la creación de un conversor de números arábigos a romanos, todo ello bajo el paraguas de TDD. Prepárense para ensuciarse las manos con código, pruebas y, por supuesto, una buena dosis de refactorización.
¿Qué es una kata TDD?
Una kata de código es un ejercicio de programación diseñado para mejorar las habilidades de un desarrollador a través de la práctica y la repetición. El término proviene de las artes marciales, donde las "katas" son secuencias de movimientos que se practican una y otra vez para dominar una técnica. Aplicado al desarrollo de software, una kata TDD implica resolver un problema de programación siguiendo estrictamente el ciclo de TDD: Rojo, Verde, Refactorizar. No se trata tanto de llegar a la solución final de la forma más rápida, sino de entrenar la disciplina, el pensamiento incremental y la confianza en las pruebas.
Este enfoque nos permite experimentar con nuevas herramientas, patrones de diseño o, como en este caso, consolidar nuestra comprensión de TDD. Es una oportunidad segura para cometer errores, aprender de ellos y refinar nuestro estilo de codificación sin la presión de un proyecto de producción. Además, las katas a menudo presentan problemas bien definidos con una complejidad manejable, lo que las hace ideales para concentrarse en la forma de construir la solución, más que en la complejidad del problema en sí.
Beneficios de las katas
Practicar katas de código ofrece una multitud de beneficios para cualquier desarrollador, independientemente de su nivel de experiencia. En primer lugar, mejoran la disciplina y el enfoque al forzarnos a seguir un proceso estructurado. Nos ayudan a pensar en los requisitos de manera más granular, lo que a menudo lleva a diseños más limpios y módulos más pequeños y cohesivos. Otro beneficio crucial es el desarrollo de un código de mayor calidad. Al escribir pruebas antes que el código de producción, nos vemos obligados a considerar la facilidad de prueba del diseño, lo que a menudo resulta en un código más desacoplado y fácil de mantener.
Personalmente, encuentro que las katas son una excelente manera de mantener mis habilidades afiladas y de explorar nuevos lenguajes o frameworks sin la curva de aprendizaje empinada que un proyecto real podría implicar. También fomentan la confianza en el propio código. Saber que cada fragmento de funcionalidad está respaldado por pruebas robustas proporciona una tranquilidad inestimable. Además, el constante ciclo de refactorización de TDD, inherentemente promovido por las katas, nos enseña a buscar mejoras continuas en la estructura interna de nuestro código sin alterar su comportamiento externo, una habilidad invaluable en cualquier equipo de desarrollo.
El ciclo de vida TDD: Rojo, verde, refactorizar
El corazón de TDD late en un ciclo simple pero poderoso de tres pasos:
- Rojo (Red): Escribe un pequeño test que falle. Este test debe expresar una nueva funcionalidad o un nuevo aspecto de una funcionalidad existente. El hecho de que falle es crucial: confirma que el test realmente está probando algo y que aún no hemos implementado la característica. Este paso nos obliga a pensar en la interfaz de la funcionalidad antes de la implementación, promoviendo diseños más utilizables.
- Verde (Green): Escribe el código de producción mínimo indispensable para que el test que acaba de fallar, y todos los tests anteriores, pasen. La clave aquí es "mínimo". No se trata de escribir la solución perfecta o más elegante en este momento, sino simplemente de hacer que las pruebas pasen. La velocidad es esencial en esta fase.
- Refactorizar (Refactor): Una vez que todos los tests están en verde, es el momento de mejorar la calidad del código. Esto puede implicar eliminar duplicidades, mejorar la legibilidad, cambiar nombres de variables o funciones, o aplicar patrones de diseño para hacer el código más robusto y mantenible. Lo crucial es que, al tener un conjunto de pruebas que pasan, podemos realizar estos cambios con la confianza de que no estamos introduciendo nuevos errores. Después de la refactorización, volvemos al paso uno para añadir el siguiente test.
Este ciclo constante garantiza que siempre haya una red de seguridad de pruebas y que el código esté continuamente mejorando. Para una inmersión más profunda en los principios de TDD, recomiendo encarecidamente la lectura de recursos como los artículos de Martin Fowler sobre el tema, que ofrecen una perspectiva clara y autorizada: Desarrollo Dirigido por Pruebas de Martin Fowler.
Preparando el entorno para nuestra kata
Antes de sumergirnos en el código, necesitamos configurar un entorno de desarrollo mínimo que nos permita ejecutar Python y nuestras pruebas. La simplicidad es clave aquí, ya que queremos centrarnos en la kata, no en la configuración.
Herramientas necesarias
Para esta kata, necesitaremos principalmente dos cosas:
- Python 3: Si no lo tienes instalado, puedes descargarlo desde el sitio oficial de Python. Es el lenguaje en el que escribiremos tanto el conversor como sus pruebas. Descargar Python.
- pytest: Es un framework de pruebas muy popular en Python, conocido por su sintaxis concisa y su gran extensibilidad. Lo instalaremos a través de
pip, el gestor de paquetes de Python.
Una vez que tengas Python, abre tu terminal o línea de comandos y ejecuta:
pip install pytest
Con esto, estamos listos para empezar.
Creando el proyecto
Para mantener todo organizado, crearemos una estructura de directorios simple:
mkdir roman_converter_kata
cd roman_converter_kata
touch converter.py
touch test_converter.py
converter.py contendrá nuestro código de producción (el conversor), y test_converter.py albergará nuestras pruebas. Esta convención de nombres (test_*.py) es automáticamente reconocida por pytest.
La kata: Conversor de números romanos
Nuestra tarea es desarrollar una función que convierta un número entero positivo (arábigo) a su representación en números romanos. Esta es una kata clásica que presenta una dificultad incremental adecuada para practicar TDD.
Descripción del problema
El objetivo es escribir una función, digamos to_roman(number), que tome un entero como entrada y devuelva una cadena de texto representando el número romano equivalente. Por ejemplo:
to_roman(1)debería devolver"I"to_roman(5)debería devolver"V"to_roman(10)debería devolver"X"to_roman(4)debería devolver"IV"to_roman(9)debería devolver"IX"to_roman(1994)debería devolver"MCMXCIV"
Reglas para los números romanos
Para la conversión, debemos recordar las reglas básicas de los números romanos:
- Se utilizan siete símbolos:
I(1),V(5),X(10),L(50),C(100),D(500),M(1000). - Los números se forman sumando el valor de los símbolos de izquierda a derecha. Por ejemplo,
VIes 5 + 1 = 6. - Existe la regla de la sustracción: un símbolo de menor valor colocado antes de uno de mayor valor indica una resta. Las combinaciones válidas son:
IV(4)IX(9)XL(40)XC(90)CD(400)CM(900)
- No se permite que un símbolo se repita más de tres veces consecutivas.
- El número más grande que se puede representar de forma estándar es 3999 (
MMMCMXCIX). Nos enfocaremos en números hasta este límite.
Con estas reglas en mente, podemos empezar a escribir nuestros tests.
Desarrollando con TDD: Paso a paso
Ahora es el momento de aplicar el ciclo Rojo, Verde, Refactorizar. Empezaremos con los casos más simples y avanzaremos incrementalmente.
Primer test: números básicos (I, V, X)
Comenzaremos con los números romanos más elementales. Queremos asegurarnos de que nuestra función puede manejar las unidades y las decenas simples.
En test_converter.py, añadimos nuestro primer test:
import pytest
from converter import to_roman
def test_single_numerals():
assert to_roman(1) == "I"
assert to_roman(5) == "V"
assert to_roman(10) == "X"
assert to_roman(50) == "L"
assert to_roman(100) == "C"
assert to_roman(500) == "D"
assert to_roman(1000) == "M"
Ahora, ejecutamos pytest en la terminal:
pytest
Como era de esperar, ¡falla! Recibimos un ImportError porque to_roman no existe en converter.py. Esto es nuestro "Rojo".
Ahora, el "Verde". En converter.py, creamos la función más simple posible para pasar estos tests. Sabemos que hay un mapeo directo para estos números, así que podemos usar un diccionario.
# converter.py
ROMAN_MAP = {
1: "I",
5: "V",
10: "X",
50: "L",
100: "C",
500: "D",
1000: "M"
}
def to_roman(number: int) -> str:
if number in ROMAN_MAP:
return ROMAN_MAP[number]
return "" # Esto fallará para números no mapeados, pero ahora no tenemos esos tests.
Ejecutamos pytest de nuevo. ¡Debería pasar! Todos los tests están en verde.
Finalmente, el "Refactorizar". En este punto, nuestro código es bastante simple. Podríamos argumentar que el return "" no es ideal, pero no hay tests que fallen por eso todavía. La tabla ROMAN_MAP es clara. Por ahora, no hay mucho que refactorizar aquí. Continuamos con el siguiente test.
Segundo test: números combinados y sustracción (IV, IX, XIV)
Los números romanos no son solo sumas simples; la regla de la sustracción es fundamental. Probaremos esto con 4 y 9.
En test_converter.py, añadimos un nuevo test:
# test_converter.py (continuación)
def test_subtractive_numerals():
assert to_roman(4) == "IV"
assert to_roman(9) == "IX"
assert to_roman(14) == "XIV" # Combina suma y resta
assert to_roman(40) == "XL"
assert to_roman(90) == "XC"
assert to_roman(400) == "CD"
assert to_roman(900) == "CM"
Ejecutamos pytest. ¡Rojo! Nuestros tests fallan para 4, 9, etc. Esto es bueno; significa que el test es válido.
Ahora, a por el "Verde". Necesitamos una estrategia para la conversión. Un enfoque común es trabajar con los símbolos romanos de mayor a menor, restando su valor del número original hasta que este llegue a cero. Para manejar la sustracción, podemos incluir las combinaciones sustractivas en nuestro mapeo de valores, asegurándonos de que se procesen antes que sus componentes individuales.
En converter.py, actualizamos la lógica:
# converter.py
# Ordenamos el mapa de mayor a menor valor para facilitar el algoritmo.
# Incluimos los casos sustractivos aquí.
ROMAN_MAP_ORDERED = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")
]
def to_roman(number: int) -> str:
roman_numeral = []
# Manejo de entrada no válida (opcional, pero buena práctica)
if not 0 number 4000:
raise ValueError("El número debe estar entre 1 y 3999.")
for value, numeral in ROMAN_MAP_ORDERED:
while number >= value:
roman_numeral.append(numeral)
number -= value
return "".join(roman_numeral)
Ejecutamos pytest de nuevo. ¡Verde! Todos los tests pasan, incluidos los básicos y los sustractivos.
"Refactorizar". La lógica ahora es más compleja. ¿Es legible? ¿Hay duplicación? El ROMAN_MAP_ORDERED es clave para la eficiencia del algoritmo. Se procesa desde el valor más grande al más pequeño, manejando primero las excepciones de sustracción. Parece un buen diseño por ahora. Una mejora podría ser añadir type hints a la función to_roman y una docstring, lo cual ya he hecho de forma proactiva. Para más información sobre buenas prácticas de codificación en Python, la documentación oficial es un recurso excelente: Documentación de Python sobre pruebas y documentación.
Tercer test: sumas y números más complejos (III, VI, LVIII)
Probemos combinaciones de sumas y números que requieren varias operaciones.
En test_converter.py:
# test_converter.py (continuación)
def test_additive_numerals():
assert to_roman(2) == "II"
assert to_roman(3) == "III"
assert to_roman(6) == "VI"
assert to_roman(7) == "VII"
assert to_roman(8) == "VIII"
assert to_roman(58) == "LVIII" # L + V + III
assert to_roman(1994) == "MCMXCIV" # Un ejemplo complejo
Ejecutamos pytest. ¡Verde! Nuestros tests pasan. Esto es una señal de que el algoritmo de nuestro paso anterior es robusto. No necesitamos modificar el código de producción por ahora, lo cual es genial porque demuestra la solidez de la implementación incremental.
"Refactorizar". Dado que el código pasó sin cambios, es una buena oportunidad para revisar la legibilidad. ¿Los nombres de las variables son claros? number y roman_numeral son bastante autoexplicativos. El ROMAN_MAP_ORDERED es la parte central y su estructura está bien definida. En mi opinión, el código está bastante limpio y directo para lo que hace. Podríamos considerar añadir un test para el caso de ValueError, pero como es un error de entrada, no una conversión fallida, puede tratarse en un test aparte.
Cuarto test: validación de entradas y límites
Es importante probar los límites y casos de entrada no válidos para asegurar la robustez de la función. El problema nos dice que los números están entre 1 y 3999.
En test_converter.py:
# test_converter.py (continuación)
def test_invalid_input_numbers():
with pytest.raises(ValueError):
to_roman(0)
with pytest.raises(ValueError):
to_roman(4000)
with pytest.raises(ValueError):
to_roman(-1)
def test_edge_cases():
assert to_roman(1) == "I"
assert to_roman(3999) == "MMMCMXCIX"
Ejecutamos pytest. ¡Verde! Nuestros tests ya manejan esto debido a la validación de entrada que añadimos de forma anticipada. Si no la hubiéramos añadido antes, ahora tendríamos un test rojo y sabríamos exactamente qué añadir.
"Refactorizar". En este punto, la función to_roman parece completa. Podríamos pensar en optimización, pero para números hasta 3999, la eficiencia no es una preocupación principal. La claridad y la corrección son más importantes. Podríamos extraer la lógica de mapeo a una clase si esperásemos diferentes sistemas de numeración, pero para esta kata, el enfoque actual es perfectamente adecuado.
Refactorización y mejora
Con todos los tests en verde, es el momento de mirar nuestro código de producción (converter.py) con ojos críticos y buscar oportunidades de mejora.
# converter.py - Versión final después de refactorización
ROMAN_NUMERALS_MAP = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I")
]
def to_roman(number: int) -> str:
"""
Convierte un número entero arábigo a su representación en números romanos.
Args:
number (int): El número entero a convertir (de 1 a 3999).
Returns:
str: La representación del número en romano.
Raises:
ValueError: Si el número está fuera del rango permitido (1-3999).
"""
if not isinstance(number, int):
raise TypeError("La entrada debe ser un número entero.")
if not 0 number 4000:
raise ValueError("El número debe estar entre 1 y 3999.")
roman_parts = []
current_number = number
for value, numeral in ROMAN_NUMERALS_MAP:
while current_number >= value:
roman_parts.append(numeral)
current_number -= value
return "".join(roman_parts)
Aquí están algunas de las mejoras que he aplicado o considerado durante este proceso de refactorización:
- Claridad del nombre de la variable: He cambiado
ROMAN_MAP_ORDEREDaROMAN_NUMERALS_MAPpara ser más descriptivo. - Manejo de tipo de entrada: He añadido una comprobación inicial para asegurar que la entrada es realmente un
int. Aunque los tests actuales no cubren esto directamente, es una buena práctica defensiva. Si quisiera probarlo, añadiría untest_invalid_type_inputconpytest.raises(TypeError). - Docstrings: Una docstring clara explica la función, sus argumentos, lo que retorna y las excepciones que puede lanzar. Esto es crucial para la mantenibilidad y la colaboración.
- Nombres de variables dentro de la función:
current_numberes más descriptivo que simplementenumbersi estamos modificando su valor.roman_partses más claro queroman_numeral