22. Refactoring guiado por herramientas: pytest, coverage, ruff, black y mypy

22.1 Objetivo del tema

Refactorizar con seguridad no depende solo de leer bien el código. También necesitamos herramientas que nos avisen rápido cuando rompemos comportamiento, dejamos código sin cubrir, introducimos errores de estilo o usamos tipos de manera incoherente.

En este tema usaremos pytest, coverage, ruff, black y mypy como apoyo práctico para refactorizar un módulo Python en pasos pequeños.

Objetivo práctico: armar una rutina de verificación automatizada antes, durante y después de cada refactoring.

22.2 Herramientas y función de cada una

  • pytest: ejecuta pruebas y confirma comportamiento.
  • coverage: muestra qué partes del código fueron ejercitadas por las pruebas.
  • ruff: detecta problemas de linting, imports, complejidad simple y errores frecuentes.
  • black: formatea el código para reducir discusiones de estilo.
  • mypy: revisa coherencia de tipos cuando usamos type hints.

Estas herramientas no reemplazan el criterio de diseño, pero hacen visible el riesgo.

22.3 Preparar un entorno mínimo

En un proyecto real conviene declarar dependencias de desarrollo. Para esta práctica, puedes instalarlas en tu entorno virtual:

python -m pip install pytest coverage ruff black mypy

La instalación no cambia el código de producción. Solo agrega herramientas para trabajar con más seguridad.

22.4 Código inicial para refactorizar

Crea el archivo src/reportes_herramientas.py:

def generar_reporte(ventas):
    total = 0
    cantidad = 0
    clientes = []
    lineas = []

    for venta in ventas:
        if venta["estado"] == "cancelada":
            continue
        total = total + venta["importe"]
        cantidad = cantidad + 1
        if venta["cliente"] not in clientes:
            clientes.append(venta["cliente"])

    if cantidad == 0:
        promedio = 0
    else:
        promedio = total / cantidad

    lineas.append("REPORTE DE VENTAS")
    lineas.append(f"Total: {total}")
    lineas.append(f"Cantidad: {cantidad}")
    lineas.append(f"Promedio: {promedio}")
    lineas.append(f"Clientes: {', '.join(clientes)}")
    return "\n".join(lineas)

El módulo funciona, pero mezcla filtrado, cálculo y formato. Lo refactorizaremos sin perder comportamiento.

22.5 Crear pruebas de base con pytest

Crea tests/test_reportes_herramientas.py:

from src.reportes_herramientas import generar_reporte


def test_genera_reporte_de_ventas_activas():
    ventas = [
        {"cliente": "Ana", "importe": 1000, "estado": "pagada"},
        {"cliente": "Luis", "importe": 500, "estado": "cancelada"},
        {"cliente": "Ana", "importe": 3000, "estado": "pagada"},
    ]

    reporte = generar_reporte(ventas)

    assert reporte == (
        "REPORTE DE VENTAS\n"
        "Total: 4000\n"
        "Cantidad: 2\n"
        "Promedio: 2000.0\n"
        "Clientes: Ana"
    )


def test_genera_reporte_sin_ventas_activas():
    ventas = [{"cliente": "Ana", "importe": 1000, "estado": "cancelada"}]

    reporte = generar_reporte(ventas)

    assert reporte == (
        "REPORTE DE VENTAS\n"
        "Total: 0\n"
        "Cantidad: 0\n"
        "Promedio: 0\n"
        "Clientes: "
    )
python -m pytest tests/test_reportes_herramientas.py

22.6 Medir cobertura antes de cambiar

La cobertura no garantiza calidad, pero ayuda a detectar zonas que podrían romperse sin aviso:

python -m coverage run -m pytest tests/test_reportes_herramientas.py
python -m coverage report -m

Si una rama importante no está cubierta, conviene agregar una prueba antes de refactorizarla.

22.7 Usar ruff para detectar problemas rápidos

ruff puede encontrar imports sin uso, variables innecesarias, comparaciones problemáticas y otros detalles que complican el mantenimiento:

python -m ruff check src tests

Algunos problemas pueden corregirse automáticamente:

python -m ruff check src tests --fix

Después de aplicar correcciones automáticas, ejecuta nuevamente las pruebas.

22.8 Usar black para estabilizar formato

Antes de tocar lógica, es útil dejar el formato estable. Así los cambios del refactoring quedan separados de los cambios cosméticos:

python -m black src tests

Luego confirma que el formato no cambió comportamiento:

python -m pytest

22.9 Agregar type hints para guiar el cambio

Los tipos ayudan a documentar expectativas. Podemos empezar con alias simples:

from typing import TypedDict


class Venta(TypedDict):
    cliente: str
    importe: float
    estado: str


def generar_reporte(ventas: list[Venta]) -> str:
    ...

No necesitamos tipar todo el proyecto de una vez. Es suficiente comenzar por el módulo que vamos a refactorizar.

22.10 Revisar tipos con mypy

Una vez agregados type hints, ejecutamos la revisión de tipos:

python -m mypy src/reportes_herramientas.py

Si mypy marca muchos errores, no los corrijas todos sin criterio. Prioriza los que ayudan a entender contratos reales del módulo.

22.11 Primer refactoring: extraer ventas activas

Extraemos el filtrado para darle nombre a la regla:

def obtener_ventas_activas(ventas: list[Venta]) -> list[Venta]:
    return [venta for venta in ventas if venta["estado"] != "cancelada"]

Después del cambio, ejecutamos la verificación mínima:

python -m pytest tests/test_reportes_herramientas.py
python -m ruff check src tests

22.12 Segundo refactoring: extraer resumen

El cálculo de total, cantidad, promedio y clientes puede ir a una función separada:

def calcular_resumen(ventas: list[Venta]) -> dict[str, object]:
    total = sum(venta["importe"] for venta in ventas)
    cantidad = len(ventas)
    promedio = total / cantidad if cantidad else 0
    clientes = []

    for venta in ventas:
        if venta["cliente"] not in clientes:
            clientes.append(venta["cliente"])

    return {
        "total": total,
        "cantidad": cantidad,
        "promedio": promedio,
        "clientes": clientes,
    }

Esta extracción permite probar el cálculo sin depender del texto final del reporte.

22.13 Tercer refactoring: extraer formato

El formato del reporte también puede tener una función propia:

def formatear_reporte(resumen: dict[str, object]) -> str:
    return "\n".join(
        [
            "REPORTE DE VENTAS",
            f"Total: {resumen['total']}",
            f"Cantidad: {resumen['cantidad']}",
            f"Promedio: {resumen['promedio']}",
            f"Clientes: {', '.join(resumen['clientes'])}",
        ]
    )

Luego el punto de entrada queda como una coordinación simple.

22.14 Código refactorizado completo

from typing import TypedDict


class Venta(TypedDict):
    cliente: str
    importe: float
    estado: str


class ResumenVentas(TypedDict):
    total: float
    cantidad: int
    promedio: float
    clientes: list[str]


def obtener_ventas_activas(ventas: list[Venta]) -> list[Venta]:
    return [venta for venta in ventas if venta["estado"] != "cancelada"]


def calcular_resumen(ventas: list[Venta]) -> ResumenVentas:
    total = sum(venta["importe"] for venta in ventas)
    cantidad = len(ventas)
    promedio = total / cantidad if cantidad else 0
    clientes: list[str] = []

    for venta in ventas:
        if venta["cliente"] not in clientes:
            clientes.append(venta["cliente"])

    return {
        "total": total,
        "cantidad": cantidad,
        "promedio": promedio,
        "clientes": clientes,
    }


def formatear_reporte(resumen: ResumenVentas) -> str:
    return "\n".join(
        [
            "REPORTE DE VENTAS",
            f"Total: {resumen['total']}",
            f"Cantidad: {resumen['cantidad']}",
            f"Promedio: {resumen['promedio']}",
            f"Clientes: {', '.join(resumen['clientes'])}",
        ]
    )


def generar_reporte(ventas: list[Venta]) -> str:
    ventas_activas = obtener_ventas_activas(ventas)
    resumen = calcular_resumen(ventas_activas)
    return formatear_reporte(resumen)

22.15 Ajustar pruebas después de extraer funciones

Las pruebas originales deben seguir pasando. Además, podemos sumar pruebas enfocadas para las nuevas funciones:

from src.reportes_herramientas import calcular_resumen, obtener_ventas_activas


def test_obtiene_ventas_activas():
    ventas = [
        {"cliente": "Ana", "importe": 1000.0, "estado": "pagada"},
        {"cliente": "Luis", "importe": 500.0, "estado": "cancelada"},
    ]

    assert obtener_ventas_activas(ventas) == [
        {"cliente": "Ana", "importe": 1000.0, "estado": "pagada"}
    ]


def test_calcula_resumen_de_ventas():
    ventas = [
        {"cliente": "Ana", "importe": 1000.0, "estado": "pagada"},
        {"cliente": "Ana", "importe": 3000.0, "estado": "pagada"},
    ]

    assert calcular_resumen(ventas) == {
        "total": 4000.0,
        "cantidad": 2,
        "promedio": 2000.0,
        "clientes": ["Ana"],
    }

22.16 Ejecutar una verificación completa

Al terminar una refactorización, ejecuta una secuencia completa:

python -m black src tests
python -m ruff check src tests
python -m mypy src
python -m coverage run -m pytest
python -m coverage report -m

El orden puede variar, pero la idea es combinar formato, linting, tipos, pruebas y cobertura.

22.17 Automatizar con un script simple

Para no olvidar pasos, puedes crear un archivo verificar.ps1 en Windows:

python -m black src tests
python -m ruff check src tests
python -m mypy src
python -m coverage run -m pytest
python -m coverage report -m

El script no debe reemplazar el criterio del desarrollador, pero reduce omisiones mecánicas.

22.18 Interpretar resultados sin obedecer ciegamente

Una herramienta puede señalar un problema real o un caso que requiere decisión. Por ejemplo, ruff puede sugerir simplificar una expresión, pero si la versión explícita ayuda al alumno o al equipo, puede ser razonable mantenerla.

La regla práctica es simple: corrige lo que reduzca riesgo o aumente claridad. No hagas cambios automáticos que no entiendes.

22.19 Errores comunes

  • Ejecutar herramientas solo al final, cuando ya hay demasiados cambios mezclados.
  • Confundir cobertura alta con pruebas útiles.
  • Aplicar formato y refactoring lógico en el mismo cambio sin necesidad.
  • Ignorar fallos de tipos porque el programa parece funcionar.
  • Hacer cambios automáticos sin volver a ejecutar pruebas.

22.20 Ejercicio propuesto

Toma un módulo pequeño de un proyecto Python y realiza esta secuencia:

  • Escribe o completa pruebas de comportamiento.
  • Ejecuta cobertura e identifica una rama sin cubrir.
  • Aplica black y ruff.
  • Agrega type hints a la función principal.
  • Ejecuta mypy.
  • Realiza una extracción pequeña y vuelve a ejecutar toda la verificación.

22.21 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué aporta cada herramienta durante un refactoring.
  • Por qué conviene ejecutar pruebas después de cambios pequeños.
  • Qué diferencia hay entre cobertura y calidad de pruebas.
  • Cómo separar cambios de formato de cambios de comportamiento.
  • Cómo usar type hints para aclarar contratos del código.

22.22 Conclusión

En este tema usamos herramientas para refactorizar con más seguridad. Las pruebas protegieron comportamiento, la cobertura mostró zonas de riesgo, el formateo estabilizó el estilo, el linting detectó problemas comunes y los tipos hicieron más explícitos los contratos.

En el próximo tema trabajaremos estrategias para refactorizar código heredado en pasos pequeños.