Este tema integra las técnicas del curso en un caso práctico completo. Partiremos de un módulo Python que funciona, pero mezcla entrada, reglas, persistencia simulada, salida, condicionales y errores silenciosos.
El objetivo será mejorar su diseño sin cambiar su comportamiento observable. Para lograrlo usaremos pruebas de caracterización, extracciones pequeñas, nombres claros, separación de responsabilidades e inyección de dependencias.
El proyecto procesa inscripciones a cursos. Recibe filas de datos, valida campos, calcula el importe final, guarda inscripciones válidas y devuelve un resumen para mostrar al usuario.
El código actual está todo en una función. Esto dificulta probar reglas aisladas y modificar descuentos, validaciones o almacenamiento.
Crea el archivo src/inscripciones.py:
INSCRIPCIONES = []
def procesar_inscripciones(filas):
aceptadas = 0
rechazadas = 0
total = 0
mensajes = []
for fila in filas:
try:
nombre = fila["nombre"].strip()
email = fila["email"].strip()
curso = fila["curso"].strip()
precio = float(fila["precio"])
becado = fila.get("becado", "no") == "si"
except Exception:
rechazadas += 1
mensajes.append("fila inválida")
continue
if nombre == "" or "@" not in email or curso == "":
rechazadas += 1
mensajes.append(f"inscripción rechazada: {email}")
continue
if becado:
importe = precio * 0.5
elif precio >= 10000:
importe = precio * 0.9
else:
importe = precio
inscripcion = {
"nombre": nombre,
"email": email,
"curso": curso,
"importe": importe,
}
INSCRIPCIONES.append(inscripcion)
aceptadas += 1
total += importe
mensajes.append(f"inscripción aceptada: {email}")
return {
"aceptadas": aceptadas,
"rechazadas": rechazadas,
"total": total,
"mensajes": mensajes,
}
El módulo tiene estado global, captura cualquier excepción y mezcla demasiadas decisiones en un solo lugar.
Antes de refactorizar, identificamos qué puede romperse:
Estas zonas deben quedar protegidas por pruebas antes de cambiar la estructura.
Crea tests/test_inscripciones.py:
from src.inscripciones import INSCRIPCIONES, procesar_inscripciones
def test_procesa_inscripciones_mixtas():
INSCRIPCIONES.clear()
filas = [
{
"nombre": "Ana",
"email": "ana@example.com",
"curso": "Python",
"precio": "12000",
"becado": "no",
},
{
"nombre": "Luis",
"email": "luis@example.com",
"curso": "Testing",
"precio": "8000",
"becado": "si",
},
{
"nombre": "",
"email": "sin-nombre@example.com",
"curso": "Python",
"precio": "5000",
},
]
resumen = procesar_inscripciones(filas)
assert resumen == {
"aceptadas": 2,
"rechazadas": 1,
"total": 14800.0,
"mensajes": [
"inscripción aceptada: ana@example.com",
"inscripción aceptada: luis@example.com",
"inscripción rechazada: sin-nombre@example.com",
],
}
assert INSCRIPCIONES == [
{"nombre": "Ana", "email": "ana@example.com", "curso": "Python", "importe": 10800.0},
{"nombre": "Luis", "email": "luis@example.com", "curso": "Testing", "importe": 4000.0},
]
python -m pytest tests/test_inscripciones.py
El except Exception actual convierte datos incompletos en una fila rechazada. Lo caracterizamos antes de mejorarlo:
def test_rechaza_fila_con_datos_incompletos():
INSCRIPCIONES.clear()
filas = [{"nombre": "Ana", "email": "ana@example.com"}]
resumen = procesar_inscripciones(filas)
assert resumen == {
"aceptadas": 0,
"rechazadas": 1,
"total": 0,
"mensajes": ["fila inválida"],
}
assert INSCRIPCIONES == []
python -m pytest tests/test_inscripciones.py
El plan será incremental:
except Exception por errores esperados.Primero damos nombre a la conversión de datos externos:
def parsear_fila(fila):
return {
"nombre": fila["nombre"].strip(),
"email": fila["email"].strip(),
"curso": fila["curso"].strip(),
"precio": float(fila["precio"]),
"becado": fila.get("becado", "no") == "si",
}
Después de reemplazar el bloque original por esta función, ejecutamos las pruebas.
python -m pytest tests/test_inscripciones.py
Ahora podemos capturar errores concretos del parseo:
def parsear_fila_segura(fila):
try:
return parsear_fila(fila)
except (KeyError, TypeError, ValueError):
return None
Esta versión todavía conserva el comportamiento observable de rechazar la fila como inválida, pero deja de ocultar cualquier error inesperado del programa.
La regla que decide si una inscripción es válida queda más clara como función:
def inscripcion_es_valida(inscripcion):
return (
inscripcion["nombre"] != ""
and "@" in inscripcion["email"]
and inscripcion["curso"] != ""
)
Esta función es pura y puede probarse sin estado global.
El cálculo de beca y descuento también puede aislarse:
def calcular_importe(precio, becado):
if becado:
return precio * 0.5
if precio >= 10000:
return precio * 0.9
return precio
El orden es importante: la beca tiene prioridad sobre el descuento por precio alto.
Agrega pruebas enfocadas para las reglas centrales:
from src.inscripciones import calcular_importe, inscripcion_es_valida
def test_calcula_importe_becado():
assert calcular_importe(12000, True) == 6000
def test_calcula_importe_con_descuento_por_precio_alto():
assert calcular_importe(12000, False) == 10800
def test_valida_inscripcion_con_datos_correctos():
inscripcion = {
"nombre": "Ana",
"email": "ana@example.com",
"curso": "Python",
}
assert inscripcion_es_valida(inscripcion) is True
python -m pytest tests/test_inscripciones.py
El estado global puede quedar detrás de un repositorio simple:
class RepositorioInscripciones:
def __init__(self, almacenamiento):
self.almacenamiento = almacenamiento
def guardar(self, inscripcion):
self.almacenamiento.append(inscripcion)
Esto permite que el caso de uso reciba un repositorio falso durante las pruebas y conserve el almacenamiento global en la función pública.
Ahora coordinamos parseo, validación, cálculo y guardado:
def procesar_inscripciones_con_repositorio(filas, repositorio):
aceptadas = 0
rechazadas = 0
total = 0
mensajes = []
for fila in filas:
inscripcion = parsear_fila_segura(fila)
if inscripcion is None:
rechazadas += 1
mensajes.append("fila inválida")
continue
if not inscripcion_es_valida(inscripcion):
rechazadas += 1
mensajes.append(f"inscripción rechazada: {inscripcion['email']}")
continue
importe = calcular_importe(inscripcion["precio"], inscripcion["becado"])
inscripcion_guardada = {
"nombre": inscripcion["nombre"],
"email": inscripcion["email"],
"curso": inscripcion["curso"],
"importe": importe,
}
repositorio.guardar(inscripcion_guardada)
aceptadas += 1
total += importe
mensajes.append(f"inscripción aceptada: {inscripcion['email']}")
return {
"aceptadas": aceptadas,
"rechazadas": rechazadas,
"total": total,
"mensajes": mensajes,
}
La función original puede seguir existiendo y delegar en el nuevo caso de uso:
def procesar_inscripciones(filas):
repositorio = RepositorioInscripciones(INSCRIPCIONES)
return procesar_inscripciones_con_repositorio(filas, repositorio)
Las pruebas de caracterización siguen llamando al punto de entrada original, por lo que confirman que no rompimos la interfaz pública.
INSCRIPCIONES = []
class RepositorioInscripciones:
def __init__(self, almacenamiento):
self.almacenamiento = almacenamiento
def guardar(self, inscripcion):
self.almacenamiento.append(inscripcion)
def parsear_fila(fila):
return {
"nombre": fila["nombre"].strip(),
"email": fila["email"].strip(),
"curso": fila["curso"].strip(),
"precio": float(fila["precio"]),
"becado": fila.get("becado", "no") == "si",
}
def parsear_fila_segura(fila):
try:
return parsear_fila(fila)
except (KeyError, TypeError, ValueError):
return None
def inscripcion_es_valida(inscripcion):
return (
inscripcion["nombre"] != ""
and "@" in inscripcion["email"]
and inscripcion["curso"] != ""
)
def calcular_importe(precio, becado):
if becado:
return precio * 0.5
if precio >= 10000:
return precio * 0.9
return precio
def procesar_inscripciones_con_repositorio(filas, repositorio):
aceptadas = 0
rechazadas = 0
total = 0
mensajes = []
for fila in filas:
inscripcion = parsear_fila_segura(fila)
if inscripcion is None:
rechazadas += 1
mensajes.append("fila inválida")
continue
if not inscripcion_es_valida(inscripcion):
rechazadas += 1
mensajes.append(f"inscripción rechazada: {inscripcion['email']}")
continue
importe = calcular_importe(inscripcion["precio"], inscripcion["becado"])
inscripcion_guardada = {
"nombre": inscripcion["nombre"],
"email": inscripcion["email"],
"curso": inscripcion["curso"],
"importe": importe,
}
repositorio.guardar(inscripcion_guardada)
aceptadas += 1
total += importe
mensajes.append(f"inscripción aceptada: {inscripcion['email']}")
return {
"aceptadas": aceptadas,
"rechazadas": rechazadas,
"total": total,
"mensajes": mensajes,
}
def procesar_inscripciones(filas):
repositorio = RepositorioInscripciones(INSCRIPCIONES)
return procesar_inscripciones_con_repositorio(filas, repositorio)
Ahora podemos probar el flujo usando un repositorio falso:
from src.inscripciones import procesar_inscripciones_con_repositorio
class RepositorioFalso:
def __init__(self):
self.inscripciones = []
def guardar(self, inscripcion):
self.inscripciones.append(inscripcion)
def test_procesa_con_repositorio_inyectado():
repositorio = RepositorioFalso()
filas = [{
"nombre": "Ana",
"email": "ana@example.com",
"curso": "Python",
"precio": "1000",
}]
resumen = procesar_inscripciones_con_repositorio(filas, repositorio)
assert resumen["aceptadas"] == 1
assert repositorio.inscripciones == [{
"nombre": "Ana",
"email": "ana@example.com",
"curso": "Python",
"importe": 1000.0,
}]
Al terminar, ejecuta una verificación completa:
python -m pytest tests/test_inscripciones.py
python -m coverage run -m pytest tests/test_inscripciones.py
python -m coverage report -m
python -m ruff check src tests
python -m black src tests
Si agregaste type hints al módulo, también puedes ejecutar:
python -m mypy src/inscripciones.py
Después del refactoring, el comportamiento público se mantiene, pero el diseño cambió:
No convertimos todo en clases, no agregamos una base de datos real y no cambiamos el formato del resumen. Esas decisiones podrían tener sentido en otro contexto, pero no eran necesarias para este objetivo.
Un buen refactoring mejora el diseño suficiente para el próximo cambio, sin convertir una práctica acotada en una reescritura completa.
Aplica la misma estrategia sobre otro módulo pequeño:
python -m pytest después de cada paso.Antes de cerrar el curso, verifica que puedes explicar estos puntos:
En este curso trabajamos refactoring como una disciplina práctica: cambiar estructura sin cambiar comportamiento. Vimos cómo avanzar con pruebas, nombres claros, extracciones, simplificación de condicionales, separación de responsabilidades, inyección de dependencias, manejo explícito de errores y herramientas de verificación.
La idea central es simple: el código mejora cuando cada cambio es pequeño, verificable y orientado a hacer más claro el próximo mantenimiento.