33. TDD en código existente: caracterización antes de cambiar comportamiento

33.1 Objetivo del tema

En este tema veremos cómo aplicar TDD cuando el código ya existe y no tiene pruebas. Antes de cambiar comportamiento, necesitamos entender y proteger lo que el sistema hace hoy.

Para eso usaremos pruebas de caracterización: pruebas que describen el comportamiento actual, incluso cuando el diseño interno todavía no es bueno.

33.2 Qué es una prueba de caracterización

Una prueba de caracterización captura cómo se comporta el código existente. No necesariamente afirma que ese comportamiento sea ideal; afirma que ese es el comportamiento actual.

Primero protegemos lo que existe. Después refactorizamos. Recién entonces cambiamos reglas con pruebas nuevas.

33.3 Código existente del ejemplo

Supongamos que encontramos esta función en producción. Calcula el total de una factura, pero mezcla reglas, descuentos, recargos y redondeo.

Archivo existente: src/facturacion.py

def calcular_total(cliente, items, envio):
    total = 0

    for item in items:
        total += item["precio"] * item["cantidad"]

    if cliente["tipo"] == "premium":
        total = total * 0.90

    if envio == "express":
        total += 500
    else:
        total += 200

    if total > 10000:
        total = total * 0.95

    return round(total, 2)

Antes de refactorizar esta función, necesitamos pruebas que nos avisen si rompemos algo.

33.4 Primera caracterización: cliente normal

Elegimos un caso simple y escribimos lo que la función devuelve actualmente.

Archivo a crear: tests/test_facturacion_caracterizacion.py

from facturacion import calcular_total


def test_cliente_normal_con_envio_estandar():
    cliente = {"tipo": "normal"}
    items = [
        {"precio": 1000, "cantidad": 2},
        {"precio": 500, "cantidad": 1},
    ]

    total = calcular_total(cliente, items, envio="estandar")

    assert total == 2700

Ejecutamos python -m pytest. Si pasa, ya tenemos un primer punto de seguridad.

33.5 Segunda caracterización: cliente premium

Agregamos un caso que cubra el descuento por tipo de cliente.

Archivo a modificar: tests/test_facturacion_caracterizacion.py

def test_cliente_premium_recibe_descuento():
    cliente = {"tipo": "premium"}
    items = [
        {"precio": 1000, "cantidad": 2},
    ]

    total = calcular_total(cliente, items, envio="estandar")

    assert total == 2000

El subtotal es 2000, el descuento premium deja 1800 y el envío estándar suma 200.

33.6 Tercera caracterización: envío express

Cubrimos otro camino de la función.

Archivo a modificar: tests/test_facturacion_caracterizacion.py

def test_envio_express_agrega_recargo():
    cliente = {"tipo": "normal"}
    items = [
        {"precio": 1000, "cantidad": 1},
    ]

    total = calcular_total(cliente, items, envio="express")

    assert total == 1500

Esta prueba protege el recargo de envío express.

33.7 Cuarta caracterización: descuento por monto alto

Cubrimos el descuento que aparece cuando el total supera 10000.

Archivo a modificar: tests/test_facturacion_caracterizacion.py

def test_total_mayor_a_diez_mil_recibe_descuento_adicional():
    cliente = {"tipo": "normal"}
    items = [
        {"precio": 10000, "cantidad": 1},
    ]

    total = calcular_total(cliente, items, envio="express")

    assert total == 9975

El subtotal 10000 suma envío express 500. Luego el total 10500 recibe 5% de descuento y queda 9975.

33.8 No corregir todavía

Tal vez pensemos que el descuento por monto alto debería aplicarse antes del envío. Puede ser cierto, pero si el sistema actual aplica el descuento después del envío, primero lo caracterizamos.

Una prueba de caracterización no decide cómo debería ser el negocio. Describe cómo se comporta hoy el código.

33.9 Refactor seguro: extraer subtotal

Con varias pruebas pasando, podemos mejorar la estructura sin cambiar comportamiento.

Archivo a modificar: src/facturacion.py

def calcular_total(cliente, items, envio):
    total = calcular_subtotal(items)

    if cliente["tipo"] == "premium":
        total = total * 0.90

    if envio == "express":
        total += 500
    else:
        total += 200

    if total > 10000:
        total = total * 0.95

    return round(total, 2)


def calcular_subtotal(items):
    total = 0

    for item in items:
        total += item["precio"] * item["cantidad"]

    return total

Ejecutamos toda la suite. Si las pruebas pasan, el refactor mantuvo el comportamiento.

33.10 Refactor seguro: extraer reglas

Seguimos con pasos pequeños.

Archivo a modificar: src/facturacion.py

def calcular_total(cliente, items, envio):
    total = calcular_subtotal(items)
    total = aplicar_descuento_cliente(total, cliente)
    total = agregar_costo_envio(total, envio)
    total = aplicar_descuento_monto_alto(total)

    return round(total, 2)

Esta versión deja visible el orden actual de las reglas. No lo cambia.

33.11 Funciones extraídas

Implementamos las funciones auxiliares preservando el comportamiento.

Archivo a modificar: src/facturacion.py

def aplicar_descuento_cliente(total, cliente):
    if cliente["tipo"] == "premium":
        return total * 0.90

    return total


def agregar_costo_envio(total, envio):
    if envio == "express":
        return total + 500

    return total + 200


def aplicar_descuento_monto_alto(total):
    if total > 10000:
        return total * 0.95

    return total

Volvemos a ejecutar python -m pytest. El diseño está mejor, pero el comportamiento sigue caracterizado.

33.12 Ahora sí: cambiar comportamiento

Supongamos que el negocio confirma una regla nueva: el descuento por monto alto debe aplicarse antes del costo de envío.

Una vez confirmado el cambio, escribimos una prueba nueva que describe el comportamiento deseado.

33.13 Prueba para el nuevo comportamiento

Esta prueba ya no caracteriza el comportamiento viejo. Ahora guía un cambio.

Archivo a crear: tests/test_facturacion_reglas.py

from facturacion import calcular_total


def test_descuento_por_monto_alto_se_aplica_antes_del_envio():
    cliente = {"tipo": "normal"}
    items = [
        {"precio": 10000, "cantidad": 1},
    ]

    total = calcular_total(cliente, items, envio="express")

    assert total == 10500

Esta prueba espera 10000 sin descuento adicional porque no supera 10000 antes del envío. Luego se suma el envío express de 500.

33.14 Actualizar o retirar caracterizaciones obsoletas

La prueba de caracterización anterior esperaba 9975. Si el negocio cambió, esa expectativa ya no corresponde.

No la borramos sin pensar: la reemplazamos por una prueba que documente la nueva regla o la movemos fuera de la suite activa si solo servía para el refactor previo.

33.15 Implementar el cambio de orden

Con el código refactorizado, cambiar el orden es simple.

Archivo a modificar: src/facturacion.py

def calcular_total(cliente, items, envio):
    total = calcular_subtotal(items)
    total = aplicar_descuento_cliente(total, cliente)
    total = aplicar_descuento_monto_alto(total)
    total = agregar_costo_envio(total, envio)

    return round(total, 2)

Ejecutamos toda la suite y ajustamos las pruebas que representaban el comportamiento anterior.

33.16 Técnica: cubrir antes de tocar

Cuando tenemos que modificar código existente, una secuencia segura es:

  1. Identificar la zona que vamos a cambiar.
  2. Escribir pruebas de caracterización del comportamiento actual.
  3. Ejecutar la suite y confirmar verde.
  4. Refactorizar para hacer el cambio más fácil.
  5. Escribir una prueba nueva para el comportamiento deseado.
  6. Implementar el cambio.

33.17 Cuando el resultado actual parece incorrecto

A veces una prueba de caracterización revela un comportamiento extraño. No conviene corregirlo de inmediato si no sabemos si alguien depende de él.

Caracterizar no significa aprobar. Significa entender antes de cambiar.

33.18 Qué caracterizar primero

  • Los caminos que la modificación va a tocar.
  • Los casos que ya fallaron en producción.
  • Los bordes de reglas difíciles de interpretar.
  • Las entradas típicas usadas por clientes reales.
  • Los comportamientos que no podemos explicar con seguridad.

33.19 Qué evitar

  • Intentar cubrir todo el sistema antes de hacer cualquier cambio.
  • Refactorizar código sin ninguna prueba alrededor.
  • Mezclar caracterización, refactor y nueva regla en un solo paso.
  • Actualizar expectativas sin distinguir comportamiento viejo y nuevo.
  • Convertir detalles internos en contratos permanentes innecesarios.

33.20 Ejercicio práctico

Tomá esta función existente y agregá pruebas de caracterización antes de refactorizar.

def calcular_envio(peso, zona):
    costo = 300
    if peso > 1:
        costo += (peso - 1) * 120
    if zona == "lejana":
        costo += 500
    if costo > 1000:
        costo *= 0.90
    return round(costo, 2)
  1. Caracterizá zona cercana con peso 1.
  2. Caracterizá zona lejana con peso 1.
  3. Caracterizá peso mayor a 1.
  4. Caracterizá el descuento cuando supera 1000.
  5. Refactorizá en funciones pequeñas manteniendo la suite en verde.

33.21 Checklist del tema

  • Antes de cambiar código existente, caracterizamos comportamiento actual.
  • Las pruebas de caracterización protegen refactors iniciales.
  • Los cambios de regla se introducen con pruebas nuevas.
  • Las expectativas viejas se actualizan solo cuando el negocio confirma el cambio.
  • El refactor se hace en pasos pequeños con la suite en verde.

33.22 Conclusión

TDD también puede aplicarse en código existente, pero el primer paso cambia: antes de guiar nuevo comportamiento, necesitamos caracterizar lo que ya sucede. Esa red de seguridad permite refactorizar, comprender el diseño y modificar reglas con menos riesgo.

En el próximo tema realizaremos un ejercicio integrador: un gestor de tareas desarrollado con TDD.