Testing en FastAPI

Avanzado
FastAPI
FastAPI
Actualizado: 18/09/2025

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 - Autor del tutorial

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.