Docenas de pruebas de backend en un PR: nuestra estrategia para FastAPI
La descripción del pull request decía: «Añadir conjunto de pruebas: 52 pruebas de autenticación, capa de datos y API». Se integró tres meses después de que el backend FastAPI que cubría entrara en producción. Así no debería hacerse, y no es motivo de celebración. Pero merece un análisis honesto, porque «añadir pruebas tarde» es una situación sustancialmente distinta de «no añadirlas nunca», y esa diferencia es precisamente la que separa una base de código que se puede mantener de otra que acaba reescribiéndose.
Este artículo describe la arquitectura de pruebas que adoptamos, los patrones concretos que resultaron sostenibles en un proyecto FastAPI real y lo que contiene el PR más allá del número de pruebas.
El contexto del proyecto
El backend es un servicio FastAPI que gestiona operaciones orientadas al cliente para un operador de comercio electrónico: gestión de pedidos, consultas de inventario, reglas de precios e integración con una API de logística de terceros. La base de datos es PostgreSQL, mediante SQLAlchemy con soporte asíncrono. La autenticación se basa en JWT y los tokens se validan contra un servicio de autenticación independiente. Durante el funcionamiento normal se realizan llamadas a tres API externas: la del proveedor logístico, un servicio de conversión de divisas y una fuente de datos de productos.
Tres meses en producción significaban tres meses de correcciones, nuevas funcionalidades y cambios incrementales, todo ello sin pruebas. La base de código sí se podía probar. Se había diseñado para ello: dependencias inyectadas, ausencia de estado global y sesiones de base de datos creadas por solicitud mediante el sistema de inyección de dependencias de FastAPI. Simplemente, las pruebas nunca se habían escrito. Un PR de alcance definido era la opción adecuada, no porque añadir toda la cobertura de golpe sea una práctica recomendable, sino porque «un PR con pruebas» se revisa, se integra y se mantiene. «Añadir pruebas progresivamente» suele permanecer indefinidamente en la lista de tareas pendientes.
La jerarquía de fixtures
El sistema de fixtures de pytest funciona mejor con un diseño deliberado. La jerarquía natural de un proyecto FastAPI con una base de datos real tiene tres ámbitos: sesión, módulo y función; esta suite utiliza principalmente los ámbitos de sesión y función.
Las fixtures con ámbito de sesión se ejecutan una vez por ejecución del conjunto de pruebas. Aquí corresponden la creación de la base de datos, la creación del esquema (el ejemplo siguiente utiliza Base.metadata.create_all; las migraciones de Alembic corresponden a esa misma etapa) y la carga inicial de las tablas con datos de referencia que no cambian entre pruebas. El motor de base de datos con ámbito de sesión se comparte entre todas las pruebas, de modo que el costo de crear el esquema se asume una sola vez, no una vez por archivo de pruebas.
Las fixtures con ámbito de función proporcionan a cada prueba un estado aislado. La fixture de reversión de transacciones es fundamental: abre una transacción al comienzo de cada prueba y la revierte al terminar. Todas las escrituras de la prueba —crear usuarios, insertar pedidos o actualizar precios— se deshacen automáticamente. Las pruebas quedan aisladas sin vaciar las tablas entre ejecuciones.
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
@pytest_asyncio.fixture(scope="session")
async def engine():
engine = create_async_engine(settings.TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(engine):
"""Each test gets a transaction that rolls back on completion."""
async with engine.begin() as conn:
await conn.begin_nested()
session_factory = sessionmaker(
conn, class_=AsyncSession, expire_on_commit=False
)
async with session_factory() as session:
yield session
await session.rollback()
La llamada a begin_nested() crea un punto de guardado dentro de la transacción externa. La intención es que el código de prueba pueda confirmar cambios libremente en su sesión y que una reversión de la transacción externa al finalizar deshaga todo. La fixture mostrada no lo consigue por completo: engine.begin() confirma la transacción cuando su bloque termina normalmente, y session.rollback() solo revierte el trabajo de la propia sesión. Para lograr un aislamiento completo, abra la conexión con engine.connect(), inicie la transacción externa con conn.begin(), vincule la sesión con join_transaction_mode="create_savepoint" y revierta explícitamente la transacción externa después de la prueba. Con esta configuración, las llamadas a session.commit() en el código de la aplicación solo liberan puntos de guardado, y la reversión externa sigue deshaciendo esos cambios.
Simulación de API externas
Tres API externas significan tres posibles fuentes de resultados inestables en pruebas que acceden a la red. La solución habitual, simular el cliente HTTP, es sencilla con respx, que intercepta las solicitudes de httpx en la capa de transporte.
import respx
import httpx
import pytest
@pytest.mark.asyncio
async def test_shipping_estimate_returns_carrier_data(test_client, valid_token):
mock_response = {
"carrier": "DHL",
"estimated_days": 3,
"cost_eur": 8.50
}
with respx.mock:
respx.post("https://api.logistics-provider.com/v2/rates").mock(
return_value=httpx.Response(200, json=mock_response)
)
response = await test_client.post(
"/api/v1/shipping/estimate",
json={"destination_zip": "10115", "weight_kg": 2.5},
headers={"Authorization": f"Bearer {valid_token}"}
)
assert response.status_code == 200
assert response.json()["estimated_days"] == 3
El gestor de contexto respx.mock dirige todas las solicitudes httpx salientes a través de la capa de simulación. Cualquier solicitud no registrada explícitamente genera un error, que es el comportamiento predeterminado correcto. Las pruebas que acceden accidentalmente a API de producción constituyen un tipo de error difícil de detectar y costoso cuando provoca efectos secundarios.
Para el servicio de conversión de divisas, al que se llama en cada pedido denominado en una moneda distinta de EUR, una fixture compartida proporciona un enrutador respx preconfigurado con tipos de cambio estándar. Las pruebas de la lógica de divisas sustituyen pares de cambio específicos; las que cubren comportamientos ajenos que activan esa dependencia usan los valores predeterminados de la fixture sin tener que ocuparse de ellos. Esta separación mantiene clara la intención: las pruebas de envíos tratan sobre envíos, no sobre el tipo de cambio que esté configurado.
Pruebas de los flujos de autenticación
El sistema de inyección de dependencias de FastAPI es una de sus mayores ventajas para las pruebas. En este proyecto, la autenticación es una dependencia: cada ruta protegida declara current_user: User = Depends(get_current_user), donde get_current_user valida un JWT y devuelve un objeto de usuario. En las pruebas, esta dependencia se sustituye a nivel de aplicación:
from app.main import app
from app.dependencies import get_current_user
from app.models import User
def make_user(role: str = "operator") -> User:
return User(id="test-user-id", email="[email protected]", role=role)
@pytest.fixture
def operator_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("operator")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
@pytest.fixture
def admin_client(db_session):
app.dependency_overrides[get_current_user] = lambda: make_user("admin")
client = TestClient(app)
yield client
app.dependency_overrides.clear()
Usar fixtures independientes para cada rol hace explícitas las pruebas de los límites de permisos. Una prueba que verifica una respuesta 403 cuando un operador intenta realizar una acción de administrador se lee con tanta claridad como la regla de negocio que aplica. Si las fixtures se llaman operator_client y admin_client, la intención de la prueba se entiende sin leer su cuerpo.
Un pequeño conjunto de pruebas ejercita directamente la ruta de validación de JWT: tokens vencidos, malformados o con declaraciones de audiencia incorrectas. Estas pruebas no usan la fixture de sustitución; pasan los tokens sin modificar al cliente de pruebas y verifican las respuestas 401 o 403. La separación es deliberada: la mayoría de las pruebas se ocupa del comportamiento posterior a la autenticación, por lo que usa la sustitución. Las pruebas de autenticación se ocupan de la propia lógica de autenticación. Mezclar ambas dificulta entender qué cubre cada prueba.
Probar deliberadamente los casos de error
Las pruebas del funcionamiento normal son fáciles de escribir y suelen ser las únicas que se escriben bajo presión de tiempo. Los errores que llegan a producción rara vez están en esa ruta. En esta base de código, el comportamiento sin probar de mayor impacto estaba en la gestión de errores: ¿qué ocurre cuando la API logística devuelve 503? ¿Qué devuelve la API cuando faltan campos obligatorios en una solicitud? ¿Qué ocurre cuando una modificación concurrente infringe una restricción de la base de datos?
Para cada simulación de una API externa, incluimos al menos un caso de error junto al caso correcto. La fixture de la API logística tiene una variante que devuelve 503 y confirma que el endpoint devuelve un error estructurado con un código de estado HTTP apropiado, en lugar de dejar que la excepción se propague como un 500. Las pruebas de restricciones de base de datos intentan realizar inserciones en conflicto y confirman que la API devuelve un 409 en lugar de un error de integridad sin gestionar.
Esta cobertura no surge por sí sola al escribir pruebas a posteriori. Exige enumerar deliberadamente los posibles fallos durante el diseño de las pruebas. Es un argumento honesto a favor de escribirlas junto con el código: resulta más fácil identificar los fallos cuando la implementación está reciente y los casos límite todavía están presentes en la memoria de trabajo.
El retorno de añadir pruebas tarde
Añadir pruebas con tres meses de retraso es mejor que no añadirlas nunca, por motivos concretos. Durante su elaboración, el PR detectó tres errores existentes: faltaba comprobar un valor nulo en un campo de dirección opcional, lo que causaba un 500 en pedidos sin segunda línea de dirección de facturación; había un error de una unidad en la paginación que solo aparecía con resultados vacíos; y un endpoint de creación de recursos devolvía un código HTTP incorrecto, 200 en lugar de 201. Estos errores llevaban meses en producción sin provocar quejas de usuarios, precisamente por eso habían pasado inadvertidos. Las pruebas los detectaron en una tarde.
El beneficio más duradero es la documentación del comportamiento. Un conjunto de pruebas que cubre límites de autenticación, interacciones con API externas y condiciones de error especifica lo que hace la API y sigue siendo preciso mientras las pruebas pasen. La prueba «el operador no puede acceder al endpoint de administración» no solo detecta regresiones: comunica la intención a la siguiente persona que lea el código. Para un servicio que mantendrá alguien distinto de su autor original, como acaba ocurriendo con todos los servicios, esa especificación vale mucho más que los errores que detecta al escribirla.
El argumento contra escribir pruebas tarde es que el costo se concentra al principio: un PR grande, una revisión intensiva y tiempo que podría dedicarse a funcionalidades. Es cierto. Pero la alternativa —una base de código que sigue sin pruebas porque nunca llega el momento adecuado para añadirlas— tiene un costo acumulativo que rara vez se mide, porque se distribuye entre todos los cambios posteriores. Añadir un conjunto de pruebas tarde resulta costoso una vez. No tenerlo resulta costoso continuamente.