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.
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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:
"email".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
Antes de continuar, verifica que puedes explicar estos puntos:
capsys para capturar salida por consola.pytest.raises para documentar excepciones actuales.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.