Testing con pytest y TestClient

Intermedio
FastAPI
FastAPI
Actualizado: 18/04/2026

Diagrama: Fastapi testing pytest

¿Por qué hacer testing en una API?

El testing automatizado garantiza que tu API funciona correctamente y sigue funcionando cuando introduces cambios. Cada test actúa como una red de seguridad que detecta problemas antes de llegar a producción.

En FastAPI, el testing es especialmente sencillo gracias a la integración con pytest (el framework de testing estándar en Python) y TestClient, un cliente HTTP que permite realizar peticiones a tu aplicación sin necesidad de ejecutar un servidor real.

Instalación de dependencias de testing

pip install pytest pytest-cov httpx
  • pytest: el framework de testing
  • pytest-cov: para medir la cobertura de código
  • httpx: cliente HTTP asíncrono (requerido por TestClient)

Estructura de un proyecto con tests

La organización recomendada separa los tests del código de producción:

mi_proyecto/
├── app/
│   ├── __init__.py
│   ├── main.py
│   └── routers/
│       └── productos.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py        ← Fixtures compartidas
│   ├── test_main.py
│   └── test_productos.py
└── requirements.txt

La aplicación de ejemplo

Para este tutorial, trabajamos con una API de gestión de tareas:

# app/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

# Base de datos en memoria para los tests
tareas_db: dict[int, dict] = {}
contador_id = 0

class TareaCrear(BaseModel):
    titulo: str
    descripcion: Optional[str] = None
    completada: bool = False

class TareaRespuesta(BaseModel):
    id: int
    titulo: str
    descripcion: Optional[str] = None
    completada: bool

@app.get("/")
def raiz():
    return {"mensaje": "API de Tareas"}

@app.get("/tareas", response_model=list[TareaRespuesta])
def listar_tareas():
    return list(tareas_db.values())

@app.post("/tareas", response_model=TareaRespuesta, status_code=201)
def crear_tarea(tarea: TareaCrear):
    global contador_id
    contador_id += 1
    nueva = {"id": contador_id, **tarea.model_dump()}
    tareas_db[contador_id] = nueva
    return nueva

@app.get("/tareas/{tarea_id}", response_model=TareaRespuesta)
def obtener_tarea(tarea_id: int):
    if tarea_id not in tareas_db:
        raise HTTPException(status_code=404, detail="Tarea no encontrada")
    return tareas_db[tarea_id]

@app.put("/tareas/{tarea_id}", response_model=TareaRespuesta)
def actualizar_tarea(tarea_id: int, tarea: TareaCrear):
    if tarea_id not in tareas_db:
        raise HTTPException(status_code=404, detail="Tarea no encontrada")
    tareas_db[tarea_id] = {"id": tarea_id, **tarea.model_dump()}
    return tareas_db[tarea_id]

@app.delete("/tareas/{tarea_id}", status_code=204)
def eliminar_tarea(tarea_id: int):
    if tarea_id not in tareas_db:
        raise HTTPException(status_code=404, detail="Tarea no encontrada")
    del tareas_db[tarea_id]

Primer test: configuración básica

El TestClient se inicializa con la instancia de la aplicación FastAPI:

# tests/test_main.py
from fastapi.testclient import TestClient
from app.main import app

client = TestClient(app)

def test_raiz():
    respuesta = client.get("/")
    assert respuesta.status_code == 200
    assert respuesta.json() == {"mensaje": "API de Tareas"}

Para ejecutar:

pytest tests/test_main.py -v

El patrón Arrange-Act-Assert

Los buenos tests siguen el patrón AAA (Arrange-Act-Assert):

def test_crear_tarea():
    # Arrange: preparar los datos de entrada
    datos_tarea = {
        "titulo": "Estudiar FastAPI",
        "descripcion": "Completar el módulo de testing",
        "completada": False
    }

    # Act: realizar la acción que se quiere testear
    respuesta = client.post("/tareas", json=datos_tarea)

    # Assert: verificar el resultado esperado
    assert respuesta.status_code == 201
    datos_respuesta = respuesta.json()
    assert datos_respuesta["titulo"] == "Estudiar FastAPI"
    assert datos_respuesta["descripcion"] == "Completar el módulo de testing"
    assert datos_respuesta["completada"] is False
    assert "id" in datos_respuesta

Tests para cada método HTTP

Test de GET - listar recursos

def test_listar_tareas_vacia():
    """Verifica que la lista de tareas devuelve una lista vacía inicialmente."""
    respuesta = client.get("/tareas")
    assert respuesta.status_code == 200
    assert isinstance(respuesta.json(), list)

def test_listar_tareas_con_datos():
    """Verifica que las tareas creadas aparecen en el listado."""
    # Crear una tarea primero
    client.post("/tareas", json={"titulo": "Tarea de prueba"})

    # Listar y verificar
    respuesta = client.get("/tareas")
    assert respuesta.status_code == 200
    tareas = respuesta.json()
    assert len(tareas) >= 1
    titulos = [t["titulo"] for t in tareas]
    assert "Tarea de prueba" in titulos

Test de GET - obtener recurso específico

def test_obtener_tarea_existente():
    """Verifica que se puede obtener una tarea por su ID."""
    # Crear la tarea
    crear = client.post("/tareas", json={"titulo": "Mi tarea"})
    tarea_id = crear.json()["id"]

    # Obtener por ID
    respuesta = client.get(f"/tareas/{tarea_id}")
    assert respuesta.status_code == 200
    assert respuesta.json()["id"] == tarea_id
    assert respuesta.json()["titulo"] == "Mi tarea"

def test_obtener_tarea_no_existente():
    """Verifica que devuelve 404 cuando la tarea no existe."""
    respuesta = client.get("/tareas/99999")
    assert respuesta.status_code == 404
    assert "no encontrada" in respuesta.json()["detail"].lower()

Test de PUT - actualización completa

def test_actualizar_tarea():
    """Verifica que una tarea se actualiza correctamente."""
    # Crear la tarea original
    crear = client.post("/tareas", json={"titulo": "Tarea original"})
    tarea_id = crear.json()["id"]

    # Actualizar
    datos_actualizados = {
        "titulo": "Tarea actualizada",
        "descripcion": "Con nueva descripción",
        "completada": True
    }
    respuesta = client.put(f"/tareas/{tarea_id}", json=datos_actualizados)

    assert respuesta.status_code == 200
    datos = respuesta.json()
    assert datos["titulo"] == "Tarea actualizada"
    assert datos["completada"] is True

def test_actualizar_tarea_no_existente():
    """Verifica que actualizar una tarea inexistente devuelve 404."""
    respuesta = client.put(
        "/tareas/99999",
        json={"titulo": "No importa", "completada": False}
    )
    assert respuesta.status_code == 404

Test de DELETE

def test_eliminar_tarea():
    """Verifica que una tarea se elimina correctamente."""
    # Crear la tarea
    crear = client.post("/tareas", json={"titulo": "A eliminar"})
    tarea_id = crear.json()["id"]

    # Eliminar
    respuesta = client.delete(f"/tareas/{tarea_id}")
    assert respuesta.status_code == 204

    # Verificar que ya no existe
    obtener = client.get(f"/tareas/{tarea_id}")
    assert obtener.status_code == 404

def test_eliminar_tarea_no_existente():
    """Verifica que eliminar una tarea inexistente devuelve 404."""
    respuesta = client.delete("/tareas/99999")
    assert respuesta.status_code == 404

Tests de validación

FastAPI válida automáticamente los datos con Pydantic. Es importante testear también los casos de error de validación:

def test_crear_tarea_sin_titulo():
    """Verifica que se rechaza una tarea sin título (campo requerido)."""
    respuesta = client.post("/tareas", json={"descripcion": "Sin título"})
    assert respuesta.status_code == 422  # Unprocessable Entity

def test_crear_tarea_datos_invalidos():
    """Verifica el rechazo de datos con tipos incorrectos."""
    respuesta = client.post("/tareas", json={
        "titulo": "Tarea",
        "completada": "no-es-booleano"  # Debe ser bool
    })
    # Pydantic intentará convertir "no-es-booleano" a bool
    # Si no puede, devuelve 422
    assert respuesta.status_code in [201, 422]

def test_ruta_con_id_invalido():
    """Verifica que un ID no numérico devuelve 422."""
    respuesta = client.get("/tareas/no-es-un-numero")
    assert respuesta.status_code == 422

Fixtures de pytest con conftest.py

Las fixtures de pytest permiten compartir configuración entre múltiples tests. En el conftest.py puedes definir el cliente de prueba para reutilizarlo:

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from app.main import app, tareas_db, contador_id

@pytest.fixture
def client():
    """Fixture que proporciona un TestClient con la BD limpia."""
    # Limpiar la BD antes de cada test
    tareas_db.clear()

    with TestClient(app) as test_client:
        yield test_client

    # Limpiar después del test también
    tareas_db.clear()

@pytest.fixture
def tarea_creada(client):
    """Fixture que proporciona un cliente con una tarea ya creada."""
    respuesta = client.post("/tareas", json={
        "titulo": "Tarea de fixture",
        "descripcion": "Creada por fixture"
    })
    return respuesta.json()

Usando las fixtures:

# tests/test_con_fixtures.py
def test_listar_con_tarea(client, tarea_creada):
    """Usa las fixtures para tener datos listos."""
    respuesta = client.get("/tareas")
    assert respuesta.status_code == 200
    tareas = respuesta.json()
    assert len(tareas) == 1
    assert tareas[0]["titulo"] == "Tarea de fixture"

def test_eliminar_con_fixture(client, tarea_creada):
    """Elimina la tarea creada por la fixture."""
    tarea_id = tarea_creada["id"]
    respuesta = client.delete(f"/tareas/{tarea_id}")
    assert respuesta.status_code == 204

Ejecutar los tests

# Ejecutar todos los tests
pytest

# Con salida detallada
pytest -v

# Solo un archivo
pytest tests/test_main.py -v

# Solo tests que contengan "crear" en el nombre
pytest -k "crear" -v

# Con informe de cobertura
pytest --cov=app tests/

# Informe de cobertura en HTML
pytest --cov=app --cov-report=html tests/

El informe de cobertura HTML se genera en la carpeta htmlcov/ y muestra exactamente qué líneas de código están cubiertas por los tests.

Verificar respuestas HTTP de forma completa

Un test completo verifica no solo el status code, sino también el cuerpo, las cabeceras y la estructura de la respuesta:

def test_crear_tarea_completo(client):
    """Test completo que verifica todos los aspectos de la respuesta."""
    datos = {
        "titulo": "Test completo",
        "descripcion": "Verificando todos los campos"
    }

    respuesta = client.post("/tareas", json=datos)

    # Status code
    assert respuesta.status_code == 201

    # Content-Type
    assert "application/json" in respuesta.headers["content-type"]

    # Cuerpo de la respuesta
    cuerpo = respuesta.json()
    assert isinstance(cuerpo["id"], int)
    assert cuerpo["titulo"] == "Test completo"
    assert cuerpo["descripcion"] == "Verificando todos los campos"
    assert cuerpo["completada"] is False

    # Verificar que el recurso es accesible después de la creación
    obtener = client.get(f"/tareas/{cuerpo['id']}")
    assert obtener.status_code == 200
    assert obtener.json()["titulo"] == "Test completo"

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

Configurar el entorno de testing para FastAPI con pytest y TestClient. Escribir tests para endpoints GET, POST, PUT y DELETE verificando status codes y respuestas. Probar el manejo de errores y validaciones de Pydantic en los endpoints. Organizar los tests en ficheros y usar fixtures de pytest para reutilizar configuración. Ejecutar la suite de tests y generar informes de cobertura con pytest-cov.