3. Pruebas de caracterización para código existente sin cobertura

3.1 Objetivo del tema

Muchas veces necesitamos refactorizar código que no tiene pruebas. Antes de cambiar su estructura, debemos entender y capturar qué hace hoy. Las pruebas de caracterización sirven precisamente para eso: documentan el comportamiento actual del sistema, incluso si ese comportamiento todavía no está perfectamente diseñado.

En este tema crearemos pruebas sobre código Python heredado. No intentaremos corregir reglas de negocio ni mejorar nombres de inmediato. Primero vamos a observar entradas, salidas, casos borde y efectos visibles para construir una red de seguridad.

Objetivo práctico: escribir pruebas que describan el comportamiento actual de un módulo antes de refactorizarlo.

3.2 Qué es una prueba de caracterización

Una prueba de caracterización verifica lo que el código hace actualmente. Su propósito principal no es demostrar que el diseño sea correcto, sino protegernos contra cambios accidentales durante el refactoring.

Este tipo de prueba responde preguntas concretas:

  • ¿Qué devuelve esta función con datos normales?
  • ¿Qué ocurre con listas vacías, valores nulos o datos incompletos?
  • ¿Qué mensajes se imprimen por consola?
  • ¿Qué excepciones aparecen en ciertos casos?
  • ¿Qué comportamiento extraño existe y todavía debemos conservar?

3.3 Diferencia con una prueba de especificación

Una prueba de especificación describe lo que el sistema debería hacer según una regla conocida. Una prueba de caracterización describe lo que el sistema hace hoy. La diferencia es importante cuando trabajamos con código heredado.

Si el comportamiento actual parece raro, no debemos corregirlo durante el primer paso. Primero lo capturamos. Luego, cuando tengamos pruebas y entendimiento suficiente, podremos decidir si ese comportamiento es una regla válida o un bug que debe corregirse en una tarea separada.

En refactoring, primero protegemos el comportamiento. Después mejoramos la estructura. Si hay que cambiar reglas, lo hacemos en otro paso.

3.4 Código heredado de ejemplo

Trabajaremos con un módulo que calcula el estado de una factura. El código mezcla reglas, textos, descuentos y salida por consola.

Crea el archivo src/facturas.py dentro del proyecto preparado en el tema anterior:

def procesar_factura(factura):
    total = 0
    for item in factura["items"]:
        total = total + item["precio"] * item["cantidad"]

    if factura["cliente"] == "vip":
        total = total * 0.85
    elif factura["cliente"] == "frecuente":
        total = total * 0.95

    if factura["provincia"] == "CABA":
        total = total + total * 0.02
    elif factura["provincia"] == "BA":
        total = total + total * 0.03

    if factura["paga_envio"]:
        total = total + 1200

    if total >= 10000:
        estado = "aprobada"
    else:
        estado = "pendiente"

    print("Factura", factura["numero"], estado, round(total, 2))
    return {"numero": factura["numero"], "estado": estado, "total": round(total, 2)}

Este código se puede mejorar de muchas maneras, pero todavía no lo haremos. Primero vamos a caracterizarlo.

3.5 Primera prueba de caracterización

Crea el archivo tests/test_facturas.py con una prueba para un caso normal:

from facturas import procesar_factura


def test_procesa_factura_vip_de_caba_con_envio(capsys):
    factura = {
        "numero": "A-100",
        "cliente": "vip",
        "provincia": "CABA",
        "paga_envio": True,
        "items": [
            {"precio": 4000, "cantidad": 2},
            {"precio": 1000, "cantidad": 1},
        ],
    }

    resultado = procesar_factura(factura)

    assert resultado == {
        "numero": "A-100",
        "estado": "pendiente",
        "total": 9003.0,
    }
    assert capsys.readouterr().out == "Factura A-100 pendiente 9003.0\n"

La prueba captura dos comportamientos visibles: el valor retornado y el texto impreso por consola. Si más adelante separamos cálculo y presentación, esta prueba nos avisará si cambiamos algo sin querer.

3.6 Ejecutar la prueba

Desde la raíz del proyecto ejecuta:

python -m pytest tests/test_facturas.py

Si la prueba pasa, ya tenemos documentado un primer comportamiento. Si falla, revisa el número esperado, la salida por consola y que el módulo esté dentro de src.

3.7 Agregar casos representativos

Una sola prueba rara vez alcanza. Necesitamos cubrir combinaciones que representen decisiones distintas del código: tipos de cliente, provincias, envío y umbral de aprobación.

def test_procesa_factura_frecuente_de_ba_sin_envio(capsys):
    factura = {
        "numero": "B-200",
        "cliente": "frecuente",
        "provincia": "BA",
        "paga_envio": False,
        "items": [
            {"precio": 5000, "cantidad": 2},
        ],
    }

    resultado = procesar_factura(factura)

    assert resultado == {
        "numero": "B-200",
        "estado": "pendiente",
        "total": 9785.0,
    }
    assert capsys.readouterr().out == "Factura B-200 pendiente 9785.0\n"


def test_procesa_factura_regular_aprobada_sin_impuesto_de_provincia(capsys):
    factura = {
        "numero": "C-300",
        "cliente": "regular",
        "provincia": "Mendoza",
        "paga_envio": False,
        "items": [
            {"precio": 6000, "cantidad": 2},
        ],
    }

    resultado = procesar_factura(factura)

    assert resultado == {
        "numero": "C-300",
        "estado": "aprobada",
        "total": 12000,
    }
    assert capsys.readouterr().out == "Factura C-300 aprobada 12000\n"

Estas pruebas no cubren todo, pero empiezan a encerrar las reglas visibles.

3.8 Probar casos borde

Los casos borde son importantes porque suelen romperse durante un refactoring. Por ejemplo, una factura sin items tiene un comportamiento actual específico.

def test_procesa_factura_sin_items_con_envio(capsys):
    factura = {
        "numero": "D-400",
        "cliente": "regular",
        "provincia": "CABA",
        "paga_envio": True,
        "items": [],
    }

    resultado = procesar_factura(factura)

    assert resultado == {
        "numero": "D-400",
        "estado": "pendiente",
        "total": 1200,
    }
    assert capsys.readouterr().out == "Factura D-400 pendiente 1200\n"

Observa que el impuesto provincial no afecta cuando el subtotal es cero, pero el envío sí se suma. La prueba deja ese comportamiento escrito.

3.9 Capturar comportamientos incómodos

A veces el código heredado tiene decisiones discutibles. Por ejemplo, si falta una clave esperada en el diccionario, se produce un KeyError. Puede ser un mal diseño, pero antes de cambiarlo podemos caracterizarlo.

import pytest


def test_falla_si_falta_la_clave_items():
    factura = {
        "numero": "E-500",
        "cliente": "regular",
        "provincia": "CABA",
        "paga_envio": False,
    }

    with pytest.raises(KeyError):
        procesar_factura(factura)

Más adelante podríamos decidir validar datos y devolver un error más claro. Pero ese cambio sería modificación de comportamiento, no solo refactoring.

3.10 Evitar pruebas demasiado frágiles

Una prueba de caracterización debe proteger comportamiento relevante, pero no debería atarse a detalles innecesarios. Por ejemplo, si solo nos importa el total, no hace falta comprobar el texto de consola en todas las pruebas.

Podemos usar capsys.readouterr() para limpiar la salida sin afirmarla cuando no es el foco:

def test_procesa_factura_vip_sin_envio():
    factura = {
        "numero": "F-600",
        "cliente": "vip",
        "provincia": "Mendoza",
        "paga_envio": False,
        "items": [
            {"precio": 10000, "cantidad": 1},
        ],
    }

    resultado = procesar_factura(factura)

    assert resultado["total"] == 8500.0
    assert resultado["estado"] == "pendiente"

La decisión depende del riesgo. Si la salida por consola es parte importante del comportamiento, conviene probarla. Si solo es ruido temporal, podemos concentrarnos en el retorno.

3.11 Parametrizar casos similares

Cuando varios casos tienen la misma estructura, podemos usar pytest.mark.parametrize para evitar duplicación en las pruebas.

import pytest


@pytest.mark.parametrize(
    "cliente,total_esperado",
    [
        ("vip", 8500.0),
        ("frecuente", 9500.0),
        ("regular", 10000),
    ],
)
def test_aplica_descuento_segun_tipo_de_cliente(cliente, total_esperado):
    factura = {
        "numero": "G-700",
        "cliente": cliente,
        "provincia": "Mendoza",
        "paga_envio": False,
        "items": [
            {"precio": 10000, "cantidad": 1},
        ],
    }

    resultado = procesar_factura(factura)

    assert resultado["total"] == total_esperado

La parametrización es útil cuando mejora la lectura. Si la tabla crece demasiado o mezcla muchas reglas, es preferible separar pruebas.

3.12 Usar nombres que describan comportamiento

Los nombres de las pruebas son parte de la documentación. Un nombre como test_1 no ayuda durante un refactoring. En cambio, un nombre como test_aplica_descuento_vip_antes_de_impuesto_provincial explica una regla concreta.

Una buena prueba de caracterización debería permitir entender qué caso protege sin tener que leer todo el cuerpo de la prueba.

Durante el refactoring, una prueba que falla debe orientar. Si el nombre es claro, encontrar el problema lleva menos tiempo.

3.13 Crear una pequeña tabla de comportamiento

Antes de escribir muchas pruebas, puede ayudar anotar una tabla con entradas y salidas esperadas. Esa tabla no reemplaza a las pruebas, pero ayuda a elegir casos.

cliente     provincia   envio     subtotal   total esperado
vip         CABA        si        9000       9003.0
frecuente   BA          no        10000      9785.0
regular     Mendoza     no        12000      12000
regular     CABA        si        0          1200

Esta tabla se construye observando el comportamiento actual. Si descubrimos algo extraño, lo registramos para discutirlo luego, no para corregirlo de inmediato.

3.14 Primer refactoring después de caracterizar

Con varias pruebas pasando, podemos hacer un cambio muy pequeño. Por ejemplo, extraer el cálculo del subtotal sin cambiar el resto de la función.

def calcular_subtotal(items):
    total = 0
    for item in items:
        total = total + item["precio"] * item["cantidad"]
    return total


def procesar_factura(factura):
    total = calcular_subtotal(factura["items"])

    if factura["cliente"] == "vip":
        total = total * 0.85
    elif factura["cliente"] == "frecuente":
        total = total * 0.95

    if factura["provincia"] == "CABA":
        total = total + total * 0.02
    elif factura["provincia"] == "BA":
        total = total + total * 0.03

    if factura["paga_envio"]:
        total = total + 1200

    if total >= 10000:
        estado = "aprobada"
    else:
        estado = "pendiente"

    print("Factura", factura["numero"], estado, round(total, 2))
    return {"numero": factura["numero"], "estado": estado, "total": round(total, 2)}

Después de este cambio ejecuta:

python -m pytest tests/test_facturas.py

Si las pruebas pasan, el primer refactoring quedó protegido por la caracterización previa.

3.15 Cuándo detener la caracterización

No necesitamos escribir pruebas para cada combinación posible antes de tocar una línea. Debemos cubrir lo suficiente para el cambio que queremos hacer.

Una regla práctica es escribir pruebas alrededor de:

  • La zona exacta que vamos a modificar.
  • Las ramas condicionales que podrían romperse.
  • Los casos borde que el código maneja de forma especial.
  • Los errores o excepciones que forman parte del comportamiento actual.

3.16 Ejercicio propuesto

Crea el archivo src/puntajes.py con este código heredado:

def calcular(usuario):
    p = 0
    if usuario["activo"]:
        p = p + 100
    if usuario["compras"] > 5:
        p = p + 40
    if usuario["compras"] > 20:
        p = p + 80
    if usuario["pais"] == "AR":
        p = p + 10
    print(usuario["email"], p)
    return p

Realiza estas tareas:

  • Escribe una prueba para un usuario activo con pocas compras.
  • Escribe una prueba para un usuario con más de 20 compras.
  • Verifica la salida por consola en al menos una prueba.
  • Caracteriza qué ocurre si falta la clave "email".
  • Después de tener pruebas, extrae una función para calcular los puntos por compras.

3.17 Una posible solución

Una parte de las pruebas podría escribirse así:

import pytest

from puntajes import calcular


def test_calcula_puntaje_de_usuario_activo_con_pocas_compras(capsys):
    usuario = {
        "email": "ana@example.com",
        "activo": True,
        "compras": 2,
        "pais": "AR",
    }

    assert calcular(usuario) == 110
    assert capsys.readouterr().out == "ana@example.com 110\n"


def test_calcula_puntaje_de_usuario_con_mas_de_veinte_compras():
    usuario = {
        "email": "luis@example.com",
        "activo": False,
        "compras": 25,
        "pais": "UY",
    }

    assert calcular(usuario) == 120


def test_falla_si_falta_email():
    usuario = {
        "activo": True,
        "compras": 2,
        "pais": "AR",
    }

    with pytest.raises(KeyError):
        calcular(usuario)

Luego de ejecutar las pruebas, puedes extraer una función sin cambiar el comportamiento:

def calcular_puntos_por_compras(cantidad_compras):
    puntos = 0
    if cantidad_compras > 5:
        puntos = puntos + 40
    if cantidad_compras > 20:
        puntos = puntos + 80
    return puntos

3.18 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué problema resuelven las pruebas de caracterización.
  • Por qué no conviene corregir reglas mientras estamos caracterizando.
  • Cómo usar capsys para capturar salida por consola.
  • Cómo usar pytest.raises para documentar excepciones actuales.
  • Cuándo conviene parametrizar pruebas similares.
  • Cómo decidir cuántas pruebas escribir antes de refactorizar.

3.19 Conclusión

En este tema aprendimos a escribir pruebas de caracterización para código existente sin cobertura. Estas pruebas nos ayudan a capturar el comportamiento actual antes de cambiar la estructura interna del programa.

En el próximo tema trabajaremos el ciclo de refactoring con más disciplina: hacer un cambio pequeño, ejecutar pruebas y confirmar avances antes de continuar.