Una kata TDD con Python: construyendo un conversor de números romanos

Una kata TDD con Python: construyendo un conversor de números romanos

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?

a computer on a desk 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: 1. **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. 2. **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. 3. **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: 1. **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. 2. **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, `VI` es 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: 1. **Claridad del nombre de la variable:** He cambiado `ROMAN_MAP_ORDERED` a `ROMAN_NUMERALS_MAP` para ser más descriptivo. 2. **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 un `test_invalid_type_input` con `pytest.raises(TypeError)`. 3. **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. 4. **Nombres de variables dentro de la función:** `current_number` es más descriptivo que simplemente `number` si estamos modificando su valor. `roman_parts` es más claro que `roman_numeral` para una lista que contendrá partes de la cadena final. 5. **Coherencia:** Asegurarse de que el estilo de código es consistente con PEP 8, el estándar de estilo de código para Python, que incluye el uso de espacios, líneas en blanco y longitud de línea. La refactorización no es solo "embellecer" el código; se trata de mejorar su diseño interno sin cambiar su comportamiento externo. Nuestros tests nos garantizan que, después de todos estos cambios, la función sigue produciendo los resultados correctos. Este es el verdadero poder de TDD. La confianza para hacer cambios profundos en el código base, sabiendo que una red de seguridad te protegerá, es algo que personalmente valoro muchísimo.

Reflexiones finales sobre la kata y TDD

Al completar esta kata, hemos experimentado de primera mano cómo TDD guía el proceso de desarrollo. No se trata solo de escribir pruebas; es una metodología que influye en cómo pensamos sobre el diseño, la funcionalidad y la calidad del código. Durante la kata del conversor de números romanos, observamos varios puntos clave: * **Diseño Incremental:** Empezamos con la lógica más simple y construimos la complejidad paso a paso. Esto evita la sobre-ingeniería inicial y permite que el diseño emerja orgánicamente. * **Claridad de Requisitos:** Cada test nos forzó a clarificar un pequeño requisito, asegurando que no pasáramos por alto ningún detalle de las reglas de los nú
Diario Tecnología