Módulo 5: Migración de Frameworks y Lenguajes

Migration Testing — Verificar Equivalencia

Migration Testing — Verificar Equivalencia

Descripción de la cápsula

El principio más importante de toda migración es: el código nuevo debe hacer exactamente lo mismo que el código viejo. No "más o menos lo mismo" — exactamente. La forma de verificar esto es con migration tests: tests que llaman al endpoint viejo y al nuevo con los mismos inputs y comparan los outputs.

En esta cápsula vas a aprender a escribir tres tipos de migration tests: tests de equivalencia (old==new), tests de contrato (la API responde con la estructura esperada), y tests de rollback (puedes volver atrás si algo falla). Claude Code puede generar todos estos tests analizando el código viejo y el nuevo.


Tests de Equivalencia

El principio

# Para cualquier input válido:
flask_response = flask_app.handle(request)
fastapi_response = fastapi_app.handle(request)
assert flask_response == fastapi_response  # Deben ser iguales

Implementación con pytest

# tests/test_equivalence.py
import pytest
from flask_app import app as flask_app
from fastapi.testclient import TestClient
from fastapi_app import app as fastapi_app

flask_client = flask_app.test_client()
fastapi_client = TestClient(fastapi_app)

class TestEquivalence:
    """Verifica que Flask y FastAPI producen las mismas respuestas."""
    
    @pytest.mark.parametrize("path", [
        "/health",
        "/users",
        "/products",
    ])
    def test_get_endpoints_equivalent(self, path):
        flask_r = flask_client.get(path)
        fastapi_r = fastapi_client.get(path)
        
        assert flask_r.status_code == fastapi_r.status_code
        assert flask_r.get_json() == fastapi_r.json()
    
    def test_create_user_equivalent(self):
        data = {"name": "Test User", "email": "test@example.com"}
        
        flask_r = flask_client.post("/users", json=data)
        fastapi_r = fastapi_client.post("/users", json=data)
        
        assert flask_r.status_code == fastapi_r.status_code
        
        # Comparar estructura (IDs pueden diferir)
        flask_body = flask_r.get_json()
        fastapi_body = fastapi_r.json()
        assert flask_body["name"] == fastapi_body["name"]
        assert flask_body["email"] == fastapi_body["email"]
    
    @pytest.mark.parametrize("bad_data,expected_status", [
        ({"name": ""}, 400),
        ({"email": "bad"}, 400),
        ({}, 400),
    ])
    def test_validation_errors_equivalent(self, bad_data, expected_status):
        flask_r = flask_client.post("/users", json=bad_data)
        fastapi_r = fastapi_client.post("/users", json=bad_data)
        
        # Ambas deben rechazar con el mismo status code
        assert flask_r.status_code // 100 == fastapi_r.status_code // 100
        # Nota: Flask retorna 400, FastAPI puede retornar 422
        # Comparamos la "clase" de error (4xx), no el código exacto

Qué comparar y qué ignorar

CompararIgnorar
Status code (o clase 4xx)Headers del framework
Body structureIDs auto-generados
Field names y typesTimestamps exactos
Error messages (semántica)Error format (detail vs error)
Business logic resultsFramework metadata

Tests de Contrato

Verificar que la nueva API cumple el contrato

class TestFastAPIContract:
    """Verifica que FastAPI cumple el contrato de la API."""
    
    def test_create_user_returns_required_fields(self):
        data = {"name": "Ana", "email": "ana@test.com"}
        response = fastapi_client.post("/users", json=data)
        
        body = response.json()
        assert "id" in body
        assert "name" in body
        assert "email" in body
        assert isinstance(body["id"], int)
        assert isinstance(body["name"], str)
    
    def test_create_user_returns_201(self):
        data = {"name": "Ana", "email": "ana@test.com"}
        response = fastapi_client.post("/users", json=data)
        assert response.status_code == 201
    
    def test_invalid_input_returns_4xx(self):
        response = fastapi_client.post("/users", json={})
        assert 400 <= response.status_code < 500
    
    def test_not_found_returns_404(self):
        response = fastapi_client.get("/users/999999")
        assert response.status_code == 404

Tests de Rollback

Verificar que puedes volver atrás

class TestRollback:
    """Verifica que la app Flask sigue funcional (rollback viable)."""
    
    def test_flask_still_serves_all_endpoints(self):
        """Flask sigue respondiendo correctamente."""
        assert flask_client.get("/health").status_code == 200
        assert flask_client.get("/users").status_code == 200
        assert flask_client.post("/users", json=valid_data).status_code == 201
    
    def test_flask_database_still_consistent(self):
        """La DB compartida es consistente desde Flask."""
        flask_client.post("/users", json=valid_data)
        response = flask_client.get("/users")
        assert len(response.get_json()) > 0

Generando Migration Tests con Claude Code

# Prompt:
> "Analiza flask_app.py y fastapi_app.py. Genera tests
   de migración completos:
   1. Tests de equivalencia para cada endpoint
   2. Tests de contrato para FastAPI
   3. Tests de rollback para Flask
   
   Usa pytest con parametrize donde aplique.
   Incluye happy paths y error cases."

Conexión con Proyecto

En el Proyecto del Módulo (cápsula 06), los tests de equivalencia son parte del entregable. Cada endpoint migrado debe tener un test que verifica old==new.


Troubleshooting

Problema 1: Flask retorna 400, FastAPI retorna 422

Causa: Pydantic validation errors son 422 por defecto.

Solución: Compara la clase de error (4xx), no el código exacto:

assert flask_r.status_code // 100 == fastapi_r.status_code // 100

Problema 2: JSON structure difiere ligeramente

Causa: Flask wrappea en {"error": "..."}, FastAPI en {"detail": "..."}.

Solución: Compara el significado, no la key:

assert "error" in flask_r.get_json() or "detail" in flask_r.get_json()

Problema 3: Orden de campos JSON diferente

Causa: JSON no garantiza orden de keys.

Solución: Compara como dict (Python ignora orden) o usa json.dumps(sort_keys=True).


Ejercicios

Ejercicio 1: Escribir test de equivalencia parametrizado (Fácil)

Escribe un test parametrizado que verifica equivalencia de GET para 5 rutas diferentes.

Ver solución
@pytest.mark.parametrize("path", [
    "/health",
    "/users",
    "/products",
    "/orders",
    "/categories",
])
def test_get_equivalence(self, path):
    flask_r = flask_client.get(path)
    fastapi_r = fastapi_client.get(path)
    assert flask_r.status_code == fastapi_r.status_code
    if flask_r.status_code == 200:
        assert flask_r.get_json() == fastapi_r.json()

Ejercicio 2: Test de equivalencia para POST con edge cases (Medio)

Escribe tests de equivalencia para POST /orders que incluya: happy path, items vacíos, user inexistente, y cantidad negativa.

Ver solución
class TestCreateOrderEquivalence:
    def test_happy_path(self):
        data = {"user_id": 1, "items": [{"product_id": 1, "qty": 2}]}
        flask_r = flask_client.post("/orders", json=data)
        fastapi_r = fastapi_client.post("/orders", json=data)
        assert flask_r.status_code == fastapi_r.status_code
    
    def test_empty_items(self):
        data = {"user_id": 1, "items": []}
        flask_r = flask_client.post("/orders", json=data)
        fastapi_r = fastapi_client.post("/orders", json=data)
        assert flask_r.status_code // 100 == fastapi_r.status_code // 100
    
    def test_invalid_user(self):
        data = {"user_id": 99999, "items": [{"product_id": 1, "qty": 1}]}
        flask_r = flask_client.post("/orders", json=data)
        fastapi_r = fastapi_client.post("/orders", json=data)
        assert flask_r.status_code == fastapi_r.status_code
    
    def test_negative_quantity(self):
        data = {"user_id": 1, "items": [{"product_id": 1, "qty": -1}]}
        flask_r = flask_client.post("/orders", json=data)
        fastapi_r = fastapi_client.post("/orders", json=data)
        assert flask_r.status_code // 100 == fastapi_r.status_code // 100

Errores Comunes en Migration Testing

Error 1: Comparar respuestas byte-a-byte

Síntoma: El 80% de tus tests fallan porque Flask retorna {"id": 1, "name": "x"} y FastAPI retorna {"name": "x", "id": 1}. JSON no garantiza orden.

Por qué pasa: Comparas strings JSON literales en lugar de comparar como diccionarios. Python compara dicts ignorando orden, pero strings no.

Cómo corregir: Siempre parsea a dict antes de comparar:

assert flask_r.get_json() == fastapi_r.json()  # ✅ compara como dict
# NO: assert flask_r.text == fastapi_r.text   # ❌ falla por orden

Error 2: Asumir que mismos status codes = mismos errores

Síntoma: Tu test dice assert flask.status == fastapi.status, pasa con 200, pero los bodies son completamente distintos.

Por qué pasa: Status code igual no implica equivalencia. La equivalencia incluye status + body + side effects (DB writes, emails enviados, etc.).

Cómo corregir: Verifica las tres dimensiones:

  1. Status code (o clase 4xx/5xx)
  2. Body structure (con campos comparables)
  3. Side effects (¿se creó el registro? ¿se envió el email?)

Error 3: Tests de equivalencia sin cleanup entre runs

Síntoma: Los tests pasan la primera vez, fallan la segunda. POST /users crea un usuario en Flask, otro en FastAPI, los IDs colisionan o la DB se llena.

Por qué pasa: No hay rollback de la DB entre tests. Cada POST acumula state.

Cómo corregir: Usa transactional fixtures de pytest o reset la DB entre tests. Si usas la misma DB para Flask y FastAPI, asegura que el cleanup limpia ambos paths:

@pytest.fixture(autouse=True)
def reset_db():
    yield
    db.session.rollback()
    db.drop_all()
    db.create_all()

Error 4: Solo testear happy paths

Síntoma: Migraste, todos los tests verdes, en producción los errores 4xx tienen formatos distintos y rompen los clientes.

Por qué pasa: Los happy paths suelen ser fáciles de migrar. Los error cases son donde Flask y FastAPI difieren más (Flask retorna 400 con texto, FastAPI 422 con JSON estructurado).

Cómo corregir: Para cada endpoint, escribe al menos:

  • 1 happy path
  • 1 validation error (input inválido)
  • 1 not found (recurso inexistente)
  • 1 unauthorized (si aplica)
  • 1 edge case del dominio

Los ejercicios de esta cápsula muestran el patrón.

Error 5: No correr los tests durante el cutover

Síntoma: Cutover de Flask, todo se siente bien, descubres en producción que un endpoint sutil dejó de funcionar.

Por qué pasa: Una vez "terminada" la migración, los tests de equivalencia se sienten redundantes. Pero son tu última línea de defensa.

Cómo corregir: Mantén los tests de equivalencia hasta el cutover. Córrelos como parte del CI hasta el día que eliminas Flask. El día del cutover los puedes archivar (no eliminar — son referencia histórica).


Resumen

  • Tests de equivalencia verifican que old==new para el mismo input
  • Tests de contrato verifican que la nueva API cumple la estructura esperada
  • Tests de rollback verifican que la app vieja sigue funcional
  • Comparar significado, no formato exacto (400 vs 422, "error" vs "detail")
  • Claude Code genera todos estos tests analizando ambas apps
  • Las 3 dimensiones de equivalencia: status + body + side effects
  • Tests cubren error cases, no solo happy paths

Próxima cápsula: Proyecto — Migración Flask→FastAPI completa.


Recursos Adicionales

  1. pytest - Parametrize - Tests parametrizados para equivalencia
  2. FastAPI TestClient - Testing en FastAPI
  3. Flask Testing - Testing en Flask
  4. Contract Testing - Pact - Framework de contract testing
  5. API Compatibility Testing - OpenAPI para validar contratos
  6. Hypothesis - Property-Based Testing - Generar inputs automáticamente