IF0100 - Programación OO II

Unidad 3: Desarrollo Web con FastAPI

Clase 11: Testing de APIs

Martes, 21 de abril de 2026 Semana 12 - Martes (120 minutos) 60 min Teoría 60 min Práctica

E4: Lab Persistencia - Jueves 23/04/2026 (2 días)

Objetivos de Aprendizaje

  • Testear APIs FastAPI con TestClient
  • Escribir tests para rutas CRUD
  • Usar fixtures de pytest
  • Medir cobertura de código
  • Testear autenticación

TestClient (15 min)

FastAPI incluye TestClient para testear sin levantar el servidor.

Instalación

pip install httpx pytest

Estructura de Tests

# main.py (tu aplicación)
from fastapi import FastAPI

app = FastAPI()

@app.get("/")
def read_root():
    return {"message": "Hello World"}

@app.get("/items/{item_id}")
def read_item(item_id: int):
    return {"item_id": item_id}

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

client = TestClient(app)

def test_read_root():
    response = client.get("/")
    assert response.status_code == 200
    assert response.json() == {"message": "Hello World"}

def test_read_item():
    response = client.get("/items/42")
    assert response.status_code == 200
    assert response.json() == {"item_id": 42}

Ejecutar Tests

pytest test_main.py -v

# Salida:
# test_main.py::test_read_root PASSED
# test_main.py::test_read_item PASSED

Tests CRUD Completo (25 min)

# test_tareas.py
import pytest
from fastapi.testclient import TestClient
from main import app

client = TestClient(app)

class TestTareas:
    """Tests para la API de Tareas."""
    
    def test_crear_tarea(self):
        """POST /tareas - Crear nueva tarea."""
        response = client.post(
            "/tareas",
            json={"titulo": "Nueva tarea", "descripcion": "Descripción"}
        )
        assert response.status_code == 201
        data = response.json()
        assert data["titulo"] == "Nueva tarea"
        assert "id" in data
    
    def test_listar_tareas(self):
        """GET /tareas - Listar todas las tareas."""
        # Primero crear una tarea
        client.post("/tareas", json={"titulo": "Tarea 1"})
        
        response = client.get("/tareas")
        assert response.status_code == 200
        data = response.json()
        assert isinstance(data, list)
        assert len(data) >= 1
    
    def test_obtener_tarea(self):
        """GET /tareas/{id} - Obtener tarea específica."""
        # Crear tarea
        create_response = client.post("/tareas", json={"titulo": "Específica"})
        tarea_id = create_response.json()["id"]
        
        # Obtenerla
        response = client.get(f"/tareas/{tarea_id}")
        assert response.status_code == 200
        assert response.json()["titulo"] == "Específica"
    
    def test_obtener_tarea_no_existe(self):
        """GET /tareas/{id} - Tarea no encontrada."""
        response = client.get("/tareas/99999")
        assert response.status_code == 404
        assert response.json()["detail"] == "Tarea no encontrada"
    
    def test_actualizar_tarea(self):
        """PUT /tareas/{id} - Actualizar tarea."""
        # Crear
        create_response = client.post("/tareas", json={"titulo": "Original"})
        tarea_id = create_response.json()["id"]
        
        # Actualizar
        response = client.put(
            f"/tareas/{tarea_id}",
            json={"titulo": "Actualizada", "descripcion": "Nueva desc", "completada": True}
        )
        assert response.status_code == 200
        assert response.json()["titulo"] == "Actualizada"
    
    def test_eliminar_tarea(self):
        """DELETE /tareas/{id} - Eliminar tarea."""
        # Crear
        create_response = client.post("/tareas", json={"titulo": "A eliminar"})
        tarea_id = create_response.json()["id"]
        
        # Eliminar
        response = client.delete(f"/tareas/{tarea_id}")
        assert response.status_code == 200
        
        # Verificar que ya no existe
        get_response = client.get(f"/tareas/{tarea_id}")
        assert get_response.status_code == 404

Fixtures (15 min)

Las fixtures preparan datos para los tests.

import pytest
from fastapi.testclient import TestClient
from main import app

@pytest.fixture
def client():
    """Proporciona un TestClient."""
    return TestClient(app)

@pytest.fixture
def tarea_existente(client):
    """Crea una tarea y retorna su ID."""
    response = client.post("/tareas", json={"titulo": "Tarea fixture"})
    return response.json()

class TestConFixtures:
    def test_usar_tarea_existente(self, client, tarea_existente):
        """Usa la fixture de tarea."""
        tarea_id = tarea_existente["id"]
        
        response = client.get(f"/tareas/{tarea_id}")
        assert response.status_code == 200
        assert response.json()["titulo"] == "Tarea fixture"
    
    def test_eliminar_tarea_existente(self, client, tarea_existente):
        """Elimina la tarea creada por la fixture."""
        tarea_id = tarea_existente["id"]
        
        response = client.delete(f"/tareas/{tarea_id}")
        assert response.status_code == 200

Cobertura (10 min)

Instalar pytest-cov

pip install pytest-cov

Medir Cobertura

# Ver cobertura en terminal
pytest --cov=main --cov-report=term-missing

# Generar reporte HTML
pytest --cov=main --cov-report=html

# Abrir htmlcov/index.html en navegador
Meta: Para la Evaluación 3, se requiere cobertura mínima del 80%.

Ejercicio: Tests Completos (5 min)

Escribe tests para:

  1. Validación de datos (título vacío debe retornar 422)
  2. Autenticación (sin token debe retornar 401)
  3. Permisos (usuario normal no puede eliminar de otro)
def test_crear_tarea_sin_titulo(client):
    """Título vacío debe fallar."""
    response = client.post("/tareas", json={"titulo": ""})
    assert response.status_code == 422

def test_acceso_sin_token(client):
    """Sin token debe retornar 401."""
    response = client.get("/tareas/protegidas")
    assert response.status_code == 401

def test_eliminar_tarea_de_otro_usuario(client):
    """No se puede eliminar tarea de otro usuario."""
    # Crear tarea con usuario 1
    client.headers["X-Token"] = "token1"
    tarea = client.post("/tareas", json={"titulo": "Mia"}).json()
    
    # Intentar eliminar con usuario 2
    client.headers["X-Token"] = "token2"
    response = client.delete(f"/tareas/{tarea['id']}")
    assert response.status_code == 403

Resumen

  • TestClient: Simula peticiones HTTP
  • status_code: Verifica código de respuesta
  • .json(): Obtiene datos de respuesta
  • Fixtures: Preparan datos reutilizables
  • Cobertura: Mide % de código testeado
Evaluación 3: API funcional + tests con 80% cobertura.
← Anterior: Dependencias
Clase 14 de 25
Siguiente: Persistencia →