Módulo 4: Refactoring Multi-File Coordinado

Tests de Regresión para Refactoring

Tests de Regresión para Refactoring

Descripción de la cápsula

Esta cápsula formaliza lo que las cápsulas anteriores asumieron: el ciclo de tests que hace refactoring seguro. "Tests primero, refactoring después" no es un eslogan — es una metodología con pasos concretos. Vas a aprender a escribir tests de regresión específicamente para refactoring: tests que capturan el comportamiento actual antes de cambiar algo, y verifican que ese comportamiento se preserva después.

La diferencia con tests normales es la intención. Un test unitario verifica que una función hace lo correcto. Un test de regresión para refactoring verifica que una función sigue haciendo exactamente lo mismo que antes — sin importar si "lo correcto" es debatible. Si la función tiene un bug y todos dependen de ese bug, el test de regresión captura el bug como comportamiento esperado. Arreglar el bug es otro refactoring separado.

Esta cápsula conecta directamente con la guía #7 (Testing with Claude Code) y refuerza las técnicas de spec-first y TDD que aprendiste ahí. Aquí las aplicamos específicamente al contexto de refactoring.


El Ciclo de Refactoring Seguro

Los 6 pasos

1. IDENTIFICAR — Qué vas a refactorizar y por qué
2. CAPTURAR — Escribir tests que documentan el comportamiento actual
3. VERIFICAR — Ejecutar tests y confirmar que todos pasan (green)
4. REFACTORIZAR — Hacer el cambio de estructura
5. VERIFICAR — Ejecutar tests y confirmar que siguen pasando (green)
6. LIMPIAR — Refactorizar los tests si es necesario (rename, etc.)

La regla: si el paso 5 falla, el refactoring rompió algo. No publiques, no sigas adelante — investiga y arregla.

El error que todos cometen

# Lo que hacen los developers (PELIGROSO):
1. Refactorizar ←──── saltar directamente aquí
2. "Probar manualmente"
3. "Parece que funciona"
4. Push

# Lo que deberías hacer (SEGURO):
1. Escribir tests que capturen comportamiento actual
2. Verificar que pasan
3. Refactorizar
4. Verificar que siguen pasando
5. Push con confianza

Escribiendo Tests de Regresión con Claude Code

Paso 1: Pedir a Claude Code que capture el comportamiento

# Prompt:
> "Antes de refactorizar create_order() en order_service.py,
   escribe tests de regresión que capturen su comportamiento
   actual. Incluye:
   1. Happy path: orden creada exitosamente
   2. Cada validación que hace (qué inputs rechaza)
   3. El formato exacto del return value
   4. Side effects: ¿qué se guarda en DB? ¿qué emails se envían?
   
   Los tests deben pasar con el código ACTUAL, antes de
   cualquier cambio."

# Claude Code genera:
# tests/test_order_service_regression.py

import pytest
from services.order_service import create_order

class TestCreateOrderRegression:
    """Tests de regresión: capturan el comportamiento actual
    de create_order() ANTES del refactoring."""
    
    def test_happy_path_returns_order_with_correct_fields(self):
        """El resultado tiene id, user_id, total, status."""
        result = create_order(valid_order_data)
        assert "id" in result
        assert result["user_id"] == 123
        assert result["status"] == "pending"
        assert isinstance(result["total"], float)
    
    def test_empty_items_raises_value_error(self):
        """Items vacíos produce ValueError."""
        with pytest.raises(ValueError, match="No items"):
            create_order({**valid_order_data, "items": []})
    
    def test_missing_user_id_raises_value_error(self):
        """Sin user_id produce ValueError."""
        data = {k: v for k, v in valid_order_data.items() if k != "user_id"}
        with pytest.raises(ValueError, match="No user"):
            create_order(data)
    
    def test_total_includes_tax_and_shipping(self):
        """Total = subtotal + tax (16%) + shipping."""
        result = create_order(order_with_known_prices)
        expected_subtotal = 100.0  # 2 items × $50
        expected_tax = 16.0        # 16%
        expected_shipping = 0.0    # free (subtotal >= 50)
        assert result["total"] == expected_subtotal + expected_tax + expected_shipping
    
    def test_coupon_reduces_total(self):
        """Cupón de 10% reduce el subtotal antes de tax."""
        result = create_order(order_with_coupon)
        # subtotal=100, coupon=10%, so subtotal=90, tax=14.4
        assert result["total"] == 104.4
    
    def test_order_saved_to_database(self, db_session):
        """La orden se persiste en la DB."""
        result = create_order(valid_order_data)
        order = db_session.query(Order).get(result["id"])
        assert order is not None
        assert order.user_id == valid_order_data["user_id"]

Paso 2: Ejecutar y confirmar green

> "Ejecuta los tests de regresión que acabas de escribir.
   TODOS deben pasar. Si alguno falla, el test tiene un
   bug — no el código."

# Output esperado:
# ✅ 6 tests passed, 0 failed
# Los tests reflejan correctamente el comportamiento actual

Paso 3: Refactorizar con confianza

> "Ahora extrae la lógica de cálculo de create_order()
   a calculate_order_total(). [El refactoring que quieras]"

Paso 4: Verificar que los tests siguen green

> "Ejecuta los tests de regresión. ¿Siguen pasando todos?"

# Si pasan: ✅ Refactoring exitoso
# Si fallan: ❌ El refactoring cambió comportamiento — investigar

Tipos de Tests de Regresión

1. Tests de input/output (los más comunes)

Capturan qué retorna la función para inputs específicos:

def test_calculate_returns_correct_total(self):
    """Captura el cálculo exacto del código actual."""
    result = calculate_total(items=[
        {"price": 25.00, "quantity": 2},
        {"price": 10.00, "quantity": 1}
    ])
    # El código actual retorna 69.6 (subtotal=60, tax=9.6)
    assert result == 69.6

2. Tests de side effects

Capturan qué pasa además del return value:

def test_create_order_sends_confirmation_email(self, mock_email):
    """Captura que se envía email después de crear orden."""
    create_order(valid_data)
    mock_email.send.assert_called_once_with(
        to=valid_data["email"],
        subject="Order Confirmation",
        # Nota: no verificamos el body exacto porque puede cambiar
    )

def test_create_order_publishes_event(self, mock_events):
    """Captura que se publica evento OrderCreated."""
    result = create_order(valid_data)
    mock_events.publish.assert_called_once_with(
        "order_created",
        {"order_id": result["id"]}
    )

3. Tests de error handling

Capturan cómo la función maneja errores:

def test_invalid_email_raises_validation_error(self):
    """Captura que email inválido produce ValidationError."""
    with pytest.raises(ValidationError):
        create_user(name="Ana", email="not-an-email")

def test_payment_failure_returns_402(self, mock_stripe):
    """Captura que pago fallido retorna HTTP 402."""
    mock_stripe.charge.side_effect = stripe.CardError("declined")
    response = client.post("/api/orders", json=valid_data)
    assert response.status_code == 402

4. Tests de integración (para refactoring de estructura)

Cuando mueves archivos o reorganizas módulos, tests de integración verifican que las piezas siguen encajando:

def test_full_order_flow_still_works(self):
    """El flujo completo de orden funciona después del refactoring."""
    # Crear usuario
    user = create_user("Ana", "ana@test.com")
    
    # Crear orden
    order = create_order(user_id=user.id, items=test_items)
    
    # Verificar estado
    assert order.status == "pending"
    
    # Procesar pago
    payment = process_payment(order.id, "tok_test")
    
    # Verificar que todo está conectado
    assert payment.order_id == order.id
    assert Order.get(order.id).status == "paid"

Claude Code como Generador de Tests de Regresión

El prompt maestro

> "Necesito refactorizar [función/clase/módulo]. Antes de
   hacer cualquier cambio, genera tests de regresión que
   capturen completamente su comportamiento actual:
   
   1. Para cada input válido conocido, captura el output exacto
   2. Para cada input inválido, captura la excepción exacta
   3. Para cada side effect (DB, email, eventos), captura
      que ocurre
   4. Para cada edge case (null, vacío, extremos), captura
      el comportamiento
   
   Los tests deben:
   - Pasar con el código actual (sin cambios)
   - Usar pytest
   - Tener nombres descriptivos que expliquen qué capturan
   - Usar fixtures para setup compartido
   
   NO optimices ni corrijas el comportamiento — captura
   exactamente lo que el código hace HOY, incluyendo bugs
   conocidos si los hay."

Cuándo Claude Code es especialmente valioso

# Escenario: función de 200 líneas sin tests
# Manualmente: 2-3 horas escribir tests
# Con Claude Code: 10-15 minutos

> "La función process_invoice() en billing_service.py tiene
   200 líneas y 0 tests. Analiza la función, identifica
   todos los paths de ejecución (happy paths, error paths,
   edge cases), y genera tests de regresión completos.
   Ejecuta los tests para confirmar que pasan."

# Claude Code:
# 1. Lee la función
# 2. Identifica 12 paths de ejecución
# 3. Genera 15 tests
# 4. Ejecuta y confirma green
# Tiempo: ~10 minutos

Patrones Comunes

Patrón 1: Snapshot testing para outputs complejos

Cuando el output es complejo (JSON largo, HTML), captura un snapshot:

def test_generate_report_matches_snapshot(self):
    """El reporte generado debe coincidir con el snapshot."""
    result = generate_report(test_data)
    
    # Primera vez: guarda el snapshot
    # Siguientes veces: compara con el snapshot guardado
    expected = load_snapshot("report_output.json")
    assert result == expected

Patrón 2: Property-based testing para invariants

Cuando el refactoring debe preservar una propiedad, no un valor específico:

def test_total_is_always_positive(self):
    """El total siempre es positivo, sin importar los items."""
    for items in [single_item, multiple_items, discounted_items]:
        result = calculate_total(items)
        assert result > 0, f"Total negativo con items: {items}"

def test_total_increases_with_quantity(self):
    """Más quantity siempre produce mayor total."""
    result_1 = calculate_total([{"price": 10, "quantity": 1}])
    result_2 = calculate_total([{"price": 10, "quantity": 2}])
    assert result_2 > result_1

Patrón 3: Before/After comparison

Para refactoring de extract, verifica que old y new producen lo mismo:

def test_extracted_function_matches_original(self):
    """La función extraída produce el mismo resultado."""
    # Llama al código original (antes del extract)
    original_result = original_create_order(test_data)
    
    # Llama a la nueva versión (después del extract)
    new_result = new_create_order(test_data)
    
    assert original_result == new_result

Cuánto Testing es Suficiente

La regla del 80/20

No necesitas 100% coverage para refactorizar con confianza. Necesitas:

  • ✅ Happy path — el flujo principal funciona
  • ✅ Validaciones — los inputs inválidos siguen siendo rechazados
  • ✅ Edge cases conocidos — null, vacío, extremos
  • ✅ Side effects críticos — DB saves, emails, eventos

Lo que NO necesitas para refactoring

  • ❌ Tests de performance (el refactoring no cambia performance)
  • ❌ Tests de UI/visual (si no estás cambiando UI)
  • ❌ Tests de configuración (si no estás cambiando config)
  • ❌ 100% branch coverage (los branches que no tocas no necesitan test)

La pregunta clave

"Si este test falla después del refactoring, ¿eso significa que rompí algo?"

  • Si la respuesta es SÍ → el test es necesario
  • Si la respuesta es NO → el test es ruido

Conexión con Proyecto

En el Proyecto del Módulo (cápsula 06), el primer paso antes de cualquier refactoring es escribir tests de regresión. El proyecto evalúa que:

  1. Escribes tests ANTES de refactorizar
  2. Los tests capturan el comportamiento relevante
  3. Los tests pasan antes Y después del refactoring
  4. Si un test falla, investigas en vez de eliminar el test

Troubleshooting

Problema 1: El código actual no tiene tests y es difícil de testear

Causa: Dependencias hardcodeadas, global state, no dependency injection.

Solución: Usa el approach de Michael Feathers (Working Effectively with Legacy Code):

> "La función process_payment() tiene dependencias
   hardcodeadas a Stripe y a la DB que hacen difícil
   testearla. Crea un test que use monkey-patching
   o mocking mínimo para capturar el comportamiento
   sin necesitar conexiones reales."

Problema 2: Tests de regresión fallan ANTES del refactoring

Causa: Los tests tienen un bug, o el código tiene comportamiento no determinístico.

Solución: Los tests de regresión deben pasar con el código actual. Si no pasan, el test está mal:

> "Este test de regresión falla con el código actual.
   Eso significa que el test no captura correctamente
   el comportamiento. ¿Qué retorna la función realmente
   para este input? Ajusta el test."

Problema 3: Demasiados tests para escribir

Causa: La función tiene demasiados paths.

Solución: Prioriza los paths más frecuentes:

> "La función tiene 20 branches. Escribe tests de regresión
   solo para los 8 paths más importantes: el happy path,
   las 3 validaciones principales, los 2 error handlers
   más comunes, y los 2 side effects críticos."

Problema 4: Test pasa antes pero falla después — ¿es bug del refactoring?

Causa: Puede ser bug del refactoring O un test frágil.

Solución: Investiga qué cambió:

> "El test test_total_calculation falla después del
   extract. Compara el valor que retornaba antes (69.6)
   con el que retorna ahora (69.60000000000001).
   ¿Es un cambio real o un issue de floating point?"

Ejercicios

Ejercicio 1: Identificar qué testear (Fácil)

Para esta función, lista qué tests de regresión escribirías:

def register_user(name, email, password):
    if len(password) < 8:
        raise ValueError("Password too short")
    if "@" not in email:
        raise ValueError("Invalid email")
    user = User(name=name, email=email)
    user.set_password(password)
    db.session.add(user)
    db.session.commit()
    send_welcome_email(email)
    return {"id": user.id, "name": name, "email": email}
Ver solución

Tests necesarios:

  1. Happy path: retorna dict con id, name, email
  2. Password corto: ValueError "Password too short"
  3. Email sin @: ValueError "Invalid email"
  4. Side effect: usuario se guarda en DB
  5. Side effect: se envía welcome email
  6. Return format: el dict tiene exactamente las keys id, name, email
def test_happy_path_returns_user_dict(self):
    result = register_user("Ana", "ana@test.com", "secure123")
    assert "id" in result
    assert result["name"] == "Ana"
    assert result["email"] == "ana@test.com"

def test_short_password_raises_error(self):
    with pytest.raises(ValueError, match="Password too short"):
        register_user("Ana", "ana@test.com", "short")

def test_invalid_email_raises_error(self):
    with pytest.raises(ValueError, match="Invalid email"):
        register_user("Ana", "not-email", "secure123")

def test_user_saved_to_db(self, db_session):
    result = register_user("Ana", "ana@test.com", "secure123")
    user = db_session.query(User).get(result["id"])
    assert user is not None

def test_welcome_email_sent(self, mock_email):
    register_user("Ana", "ana@test.com", "secure123")
    mock_email.assert_called_once_with("ana@test.com")

Ejercicio 2: Escribir prompt de tests de regresión (Medio)

Escribe el prompt completo para Claude Code que genere tests de regresión para una función process_payment(order_id, token) que: valida la orden, cobra con Stripe, actualiza el status, y envía email.

Ver solución
> "Genera tests de regresión para process_payment(order_id, token)
   en src/services/payment_service.py. La función valida la orden,
   cobra con Stripe, actualiza status, y envía email de confirmación.
   
   Tests necesarios:
   1. Happy path: pago exitoso, retorna payment_id
   2. Orden no existe: ¿qué error lanza?
   3. Orden ya pagada: ¿qué error lanza?
   4. Stripe card declined: ¿qué error lanza? ¿se hace rollback?
   5. Stripe timeout: ¿retry o error?
   6. Side effect: order.status cambia a 'paid' en DB
   7. Side effect: email de confirmación se envía
   8. Side effect: evento 'payment_completed' se publica
   
   Usa mocking para Stripe y email. Los tests deben pasar
   con el código ACTUAL. Ejecuta y confirma green."

Ejercicio 3: Diagnosticar test que falla post-refactoring (Medio)

Después de extraer calculate_tax() de create_order(), este test falla:

def test_total_with_tax(self):
    result = create_order(items=[{"price": 100, "quantity": 1}])
    assert result["total"] == 116.0  # 100 + 16% tax
# Actual: 116.00000000000001

¿Cómo lo diagnosticas y resuelves?

Ver solución

Diagnóstico: Es un issue de floating point, no un bug del refactoring. El cálculo probablemente cambió de 100 * 1.16 a 100 + (100 * 0.16), que producen resultados ligeramente diferentes en floating point.

Solución: Usa pytest.approx():

def test_total_with_tax(self):
    result = create_order(items=[{"price": 100, "quantity": 1}])
    assert result["total"] == pytest.approx(116.0, abs=0.01)

Regla: Para valores monetarios, usa pytest.approx() o Decimal. Floating point imprecision no es un bug del refactoring.

Ejercicio 4: Coverage mínimo para refactoring (Difícil)

Una función tiene 15 branches. Solo vas a refactorizar los primeros 5 (extract a nueva función). ¿Qué 8 tests escribes?

Ver solución
# Tests para los 5 branches que vas a refactorizar:
1. Happy path del branch 1
2. Happy path del branch 2
3. Error path del branch 3
4. Edge case del branch 4
5. Happy path del branch 5

# Tests de "no regresión" para los 10 que NO tocas:
6. Un test que pasa por branch 6 (el más común de los 10)
7. Un test que pasa por branch 10 (el más edge case)
8. Un test de integración que verifica el flujo completo
   (pasa por múltiples branches)

Principio: coverage profundo en lo que cambias, coverage mínimo en lo que no cambias, un test de integración que verifica que todo sigue conectado.


Resumen

En esta cápsula aprendiste:

  • El ciclo seguro de refactoring: identificar → tests → verify → refactor → verify → limpiar
  • Tests de regresión capturan comportamiento actual — no lo que debería ser, sino lo que ES
  • 4 tipos de tests de regresión: input/output, side effects, error handling, integración
  • Claude Code genera tests de regresión analizando la función y sus paths
  • La regla del 80/20: happy path + validaciones + edge cases + side effects críticos
  • Si el test falla post-refactoring: investiga, no elimines el test

Próxima cápsula: Proyecto — Refactoring Coordinado. Vas a aplicar rename, extract, move, e interface changes en un codebase real con tests de regresión en cada paso.


Recursos Adicionales

  1. Working Effectively with Legacy Code - Michael Feathers - El libro sobre introducir tests en código sin tests
  2. pytest Documentation - Referencia completa de pytest
  3. pytest-snapshot - Plugin de snapshot testing para pytest
  4. Characterization Tests - Martin Fowler - El concepto formal de tests de regresión para refactoring
  5. Approval Tests - Framework de testing que captura output y lo compara con versiones aprobadas
  6. Coverage.py - Herramienta de code coverage para Python