TestClient y fixtures
El testing es una parte fundamental del desarrollo de APIs robustas. FastAPI proporciona herramientas integradas que simplifican significativamente la creación y ejecución de pruebas para nuestros endpoints. El componente central de esta funcionalidad es TestClient, que nos permite simular peticiones HTTP sin necesidad de ejecutar un servidor real.
Configuración básica de TestClient
TestClient se basa en la biblioteca Starlette y utiliza requests
internamente, lo que significa que podemos usar la misma sintaxis familiar para realizar peticiones HTTP en nuestros tests. La configuración inicial es sorprendentemente sencilla:
from fastapi.testclient import TestClient
from main import app # Nuestra aplicación FastAPI
client = TestClient(app)
def test_read_root():
response = client.get("/")
assert response.status_code == 200
assert response.json() == {"message": "Hello World"}
El TestClient actúa como un cliente HTTP simulado que puede realizar peticiones GET, POST, PUT, DELETE y cualquier otro método HTTP contra nuestra aplicación. Una ventaja clave es que no necesitamos iniciar el servidor - todas las peticiones se procesan internamente.
Estructura de proyectos para testing
Para organizar nuestros tests de manera eficiente, es recomendable crear una estructura de directorios clara:
proyecto/
├── app/
│ ├── main.py
│ ├── models.py
│ └── routers/
├── tests/
│ ├── __init__.py
│ ├── conftest.py
│ └── test_endpoints.py
└── requirements.txt
El archivo conftest.py
es especial en pytest - contiene las configuraciones y fixtures que se compartirán entre todos nuestros tests. Aquí definiremos nuestro TestClient principal:
# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
"""Fixture que proporciona un cliente de testing."""
return TestClient(app)
Fixtures en pytest para FastAPI
Las fixtures son funciones que se ejecutan antes de nuestros tests y proporcionan datos o recursos necesarios. En el contexto de FastAPI, las fixtures nos permiten reutilizar configuraciones y preparar el estado inicial para nuestras pruebas.
Fixture básica para el cliente:
@pytest.fixture
def client():
with TestClient(app) as c:
yield c
Fixture para datos de prueba:
@pytest.fixture
def sample_user_data():
"""Datos de usuario para testing."""
return {
"name": "Juan Pérez",
"email": "juan@example.com",
"age": 25
}
def test_create_user(client, sample_user_data):
response = client.post("/users", json=sample_user_data)
assert response.status_code == 201
assert response.json()["name"] == "Juan Pérez"
Testing de endpoints con diferentes métodos HTTP
El TestClient soporta todos los métodos HTTP que usamos en nuestras APIs. Cada método tiene su sintaxis específica para enviar datos:
Testing de endpoints GET:
def test_get_users(client):
response = client.get("/users")
assert response.status_code == 200
assert isinstance(response.json(), list)
def test_get_user_by_id(client):
response = client.get("/users/1")
assert response.status_code == 200
assert "id" in response.json()
Testing de endpoints POST con validación:
def test_create_user_success(client):
user_data = {
"name": "Ana García",
"email": "ana@example.com",
"age": 30
}
response = client.post("/users", json=user_data)
assert response.status_code == 201
created_user = response.json()
assert created_user["name"] == "Ana García"
assert "id" in created_user
def test_create_user_validation_error(client):
invalid_data = {
"name": "", # Nombre vacío
"email": "invalid-email" # Email inválido
}
response = client.post("/users", json=invalid_data)
assert response.status_code == 422 # Unprocessable Entity
Fixtures avanzadas para configuración
Podemos crear fixtures más sofisticadas que configuren el estado de la aplicación antes de cada test:
@pytest.fixture
def app_with_data():
"""Fixture que configura la aplicación con datos iniciales."""
# Aquí podríamos cargar datos de prueba, configurar mocks, etc.
return app
@pytest.fixture
def authenticated_client(client):
"""Fixture que proporciona un cliente autenticado."""
# Simular autenticación (esto se verá más en detalle en mocking)
auth_response = client.post("/auth/login", json={
"username": "testuser",
"password": "testpass"
})
token = auth_response.json()["token"]
# Configurar headers de autenticación
client.headers = {"Authorization": f"Bearer {token}"}
return client
Testing de headers y response models
El TestClient nos permite verificar no solo el contenido de las respuestas, sino también headers, códigos de estado y la estructura completa de la respuesta:
def test_response_headers(client):
response = client.get("/users/1")
assert response.status_code == 200
assert response.headers["content-type"] == "application/json"
def test_response_structure(client):
response = client.get("/users/1")
user_data = response.json()
# Verificar que tiene todos los campos esperados
required_fields = ["id", "name", "email", "created_at"]
for field in required_fields:
assert field in user_data
# Verificar tipos de datos
assert isinstance(user_data["id"], int)
assert isinstance(user_data["name"], str)
Configuración de fixtures con scope
Las fixtures pueden tener diferentes ámbitos (scopes) que determinan cuándo se ejecutan y cuándo se limpian:
@pytest.fixture(scope="session")
def app_config():
"""Configuración que se ejecuta una vez por sesión de testing."""
return {
"testing": True,
"database_url": "sqlite:///test.db"
}
@pytest.fixture(scope="function")
def clean_client():
"""Cliente que se reinicia en cada test."""
return TestClient(app)
@pytest.fixture(scope="module")
def shared_client():
"""Cliente compartido por todos los tests del módulo."""
with TestClient(app) as c:
yield c
Parametrización de tests con fixtures
Pytest permite parametrizar tests para ejecutar el mismo test con diferentes datos de entrada:
@pytest.fixture
def user_test_cases():
return [
{"name": "Juan", "email": "juan@test.com", "age": 25},
{"name": "María", "email": "maria@test.com", "age": 30},
{"name": "Carlos", "email": "carlos@test.com", "age": 35}
]
@pytest.mark.parametrize("user_data", user_test_cases(), indirect=True)
def test_create_multiple_users(client, user_data):
response = client.post("/users", json=user_data)
assert response.status_code == 201
assert response.json()["name"] == user_data["name"]
Con estas herramientas básicas de TestClient y fixtures, tenemos una base sólida para escribir tests efectivos para nuestras APIs de FastAPI. La clave está en organizar bien nuestras fixtures, reutilizar configuraciones comunes y estructurar los tests de manera que sean fáciles de mantener y entender.
Mocking y database testing
Cuando desarrollamos APIs que interactúan con bases de datos, necesitamos estrategias específicas para aislar nuestros tests de dependencias externas. El mocking y las bases de datos de prueba son técnicas fundamentales que nos permiten crear tests rápidos, confiables y reproducibles.
Introducción al mocking en FastAPI
El mocking consiste en reemplazar componentes reales de nuestra aplicación con versiones simuladas durante las pruebas. Esto es especialmente útil cuando queremos probar la lógica de nuestros endpoints sin depender de bases de datos reales, APIs externas o servicios complejos.
En Python utilizamos principalmente la biblioteca unittest.mock que viene integrada, junto con pytest-mock para una integración más fluida con pytest:
from unittest.mock import Mock, patch
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.services.user_service import UserService
client = TestClient(app)
def test_get_user_with_mock():
# Crear un mock del servicio
mock_user_data = {
"id": 1,
"name": "Usuario Mock",
"email": "mock@example.com"
}
with patch('app.services.user_service.UserService.get_user') as mock_get_user:
mock_get_user.return_value = mock_user_data
response = client.get("/users/1")
assert response.status_code == 200
assert response.json() == mock_user_data
mock_get_user.assert_called_once_with(1)
Mocking de dependencias con pytest
Las dependencias de FastAPI pueden ser fácilmente mockeadas utilizando el sistema de override que proporciona el framework. Esto es especialmente útil para servicios y conexiones a bases de datos:
from app.dependencies import get_database, get_user_service
def test_endpoint_with_mocked_dependencies():
# Mock del servicio de base de datos
mock_db = Mock()
# Mock del servicio de usuario
mock_user_service = Mock()
mock_user_service.create_user.return_value = {
"id": 1,
"name": "Nuevo Usuario",
"email": "nuevo@example.com"
}
# Override de las dependencias
app.dependency_overrides[get_database] = lambda: mock_db
app.dependency_overrides[get_user_service] = lambda: mock_user_service
try:
response = client.post("/users", json={
"name": "Nuevo Usuario",
"email": "nuevo@example.com"
})
assert response.status_code == 201
mock_user_service.create_user.assert_called_once()
finally:
# Limpiar overrides después del test
app.dependency_overrides.clear()
Fixtures para mocking automatizado
Podemos crear fixtures que automáticamente configuren y limpien nuestros mocks, haciendo los tests más limpios y reutilizables:
@pytest.fixture
def mock_user_service():
"""Fixture que proporciona un mock del UserService."""
with patch('app.services.user_service.UserService') as mock:
# Configurar comportamientos por defecto
mock.return_value.get_user.return_value = {
"id": 1,
"name": "Usuario Test",
"email": "test@example.com"
}
mock.return_value.create_user.return_value = {
"id": 2,
"name": "Usuario Creado",
"email": "creado@example.com"
}
yield mock.return_value
@pytest.fixture
def client_with_mocks(mock_user_service):
"""Cliente con servicios mockeados."""
app.dependency_overrides[get_user_service] = lambda: mock_user_service
with TestClient(app) as client:
yield client
app.dependency_overrides.clear()
Base de datos en memoria para testing
Para tests que necesitan interactuar con una base de datos real pero sin persistencia, utilizamos bases de datos en memoria. SQLite en memoria es una opción excelente por su velocidad y simplicidad:
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base, get_db
# Engine de testing en memoria
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
test_engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False}
)
TestingSessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=test_engine
)
@pytest.fixture
def test_db():
"""Fixture que proporciona una base de datos limpia para cada test."""
# Crear todas las tablas
Base.metadata.create_all(bind=test_engine)
# Crear sesión
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
# Limpiar todas las tablas después del test
Base.metadata.drop_all(bind=test_engine)
Cliente con base de datos de testing
Combinamos la base de datos de testing con nuestro TestClient para crear un entorno de pruebas completo:
@pytest.fixture
def client_with_test_db(test_db):
"""Cliente que usa la base de datos de testing."""
def override_get_db():
try:
yield test_db
finally:
pass # La sesión ya se maneja en test_db fixture
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as client:
yield client
app.dependency_overrides.clear()
def test_create_user_with_database(client_with_test_db):
"""Test que usa una base de datos real en memoria."""
user_data = {
"name": "Usuario DB",
"email": "userdb@example.com",
"age": 28
}
response = client_with_test_db.post("/users", json=user_data)
assert response.status_code == 201
# Verificar que el usuario fue creado
created_user = response.json()
assert created_user["name"] == "Usuario DB"
assert "id" in created_user
Testing de transacciones y rollback
Para tests que requieren transacciones más complejas, podemos configurar rollback automático después de cada test:
@pytest.fixture
def db_session():
"""Sesión de base de datos con rollback automático."""
connection = test_engine.connect()
transaction = connection.begin()
session = TestingSessionLocal(bind=connection)
try:
yield session
finally:
session.close()
transaction.rollback()
connection.close()
def test_user_creation_rollback(db_session):
"""Test que se revierte automáticamente."""
from app.models import User
# Crear usuario
new_user = User(
name="Usuario Temporal",
email="temporal@example.com",
age=25
)
db_session.add(new_user)
db_session.commit()
# Verificar que existe
user = db_session.query(User).filter(User.email == "temporal@example.com").first()
assert user is not None
assert user.name == "Usuario Temporal"
# Al finalizar el test, la transacción se revierte automáticamente
Mocking de servicios externos
Cuando nuestras APIs interactúan con servicios externos (APIs de terceros, servicios de email, etc.), necesitamos mockearlos para evitar dependencias externas en nuestros tests:
import httpx
from unittest.mock import patch, AsyncMock
@pytest.fixture
def mock_external_api():
"""Mock para API externa."""
with patch('httpx.AsyncClient.post') as mock_post:
mock_post.return_value = httpx.Response(
status_code=200,
json={"status": "success", "message": "Email sent"}
)
yield mock_post
def test_send_notification(client, mock_external_api):
"""Test de endpoint que envía notificaciones externas."""
response = client.post("/users/1/notify", json={
"message": "Bienvenido a la plataforma"
})
assert response.status_code == 200
assert response.json()["notification_sent"] is True
# Verificar que se llamó al servicio externo
mock_external_api.assert_called_once()
Estrategias de testing híbridas
En aplicaciones complejas, podemos combinar mocking y bases de datos reales según las necesidades específicas de cada test:
@pytest.fixture
def hybrid_test_environment(test_db, mock_user_service):
"""Entorno híbrido: BD real para datos, mocks para servicios."""
def override_get_db():
yield test_db
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_user_service] = lambda: mock_user_service
with TestClient(app) as client:
yield client, test_db, mock_user_service
app.dependency_overrides.clear()
def test_complex_workflow(hybrid_test_environment):
"""Test que combina datos reales con servicios mockeados."""
client, db, mock_service = hybrid_test_environment
# Configurar mock para servicio externo
mock_service.validate_user_data.return_value = True
# Crear usuario usando BD real
response = client.post("/users", json={
"name": "Usuario Complejo",
"email": "complejo@example.com"
})
assert response.status_code == 201
# Verificar que el mock fue llamado
mock_service.validate_user_data.assert_called_once()
# Verificar datos en BD real
from app.models import User
user = db.query(User).filter(User.email == "complejo@example.com").first()
assert user is not None
Configuración de tests parametrizados para diferentes escenarios
Podemos utilizar parametrización para probar diferentes escenarios de base de datos y mocking:
@pytest.mark.parametrize("use_mock,expected_calls", [
(True, 1), # Con mock, debe llamarse una vez
(False, 0) # Sin mock, no debe llamarse
])
def test_conditional_mocking(client, use_mock, expected_calls):
"""Test parametrizado que alterna entre mock y implementación real."""
if use_mock:
with patch('app.services.external_service.send_email') as mock_email:
mock_email.return_value = {"sent": True}
response = client.post("/users/1/welcome-email")
assert response.status_code == 200
assert mock_email.call_count == expected_calls
else:
# Test sin mock - requeriría configuración real del servicio
response = client.post("/users/1/welcome-email")
assert response.status_code in [200, 503] # 503 si servicio no disponible
Estas técnicas de mocking y database testing nos proporcionan un control completo sobre el entorno de pruebas, permitiéndonos crear tests rápidos, confiables y que cubran todos los aspectos de nuestras APIs de FastAPI.
Fuentes y referencias
Documentación oficial y recursos externos para profundizar en FastAPI
Documentación oficial de FastAPI
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, FastAPI es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de FastAPI
Explora más contenido relacionado con FastAPI y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
- Comprender el uso de TestClient para simular peticiones HTTP en tests de FastAPI.
- Aprender a organizar y utilizar fixtures en pytest para configurar entornos de prueba reutilizables.
- Implementar mocking para aislar dependencias externas y servicios durante las pruebas.
- Configurar bases de datos en memoria para pruebas con persistencia temporal y rollback automático.
- Combinar técnicas de mocking y testing con bases de datos reales para escenarios complejos.