Módulo 3: Integration y E2E Tests

Database Testing con Fixtures

Database Testing con Fixtures

Descripción de la cápsula

Un integration test que usa la misma base de datos que otros tests es una bomba de tiempo. Test A crea datos, Test B depende de ellos, Test C los borra — y de repente Test B falla, no por un bug en tu código, sino por el orden de ejecución. Los tests compartiendo estado de base de datos son frágiles, dependientes del orden, y una pesadilla para debugear.

Esta cápsula te enseña a darle a cada test su propio estado limpio usando fixtures de pytest. Usarás una base de datos simple (dict-based para este módulo; SQLAlchemy/SQLite in-memory viene en Módulo 8) para enfocarte en los patrones de testing, no en la tecnología de base de datos. Aprenderás setup/teardown con yield, fixture scope, y cómo inyectar una test database en tu app FastAPI con dependency_overrides. Al final, tendrás tests de CRUD con aislamiento completo — cada test arranca desde cero.


El Problema: Tests que Comparten Base de Datos

Por qué fallan tests dependientes

Imagina tres tests que usan la misma base de datos:

Test A: Crea usuario con id=1
Test B: Asume que existe item con id=1 (creado por Test A) ← Falla si A corre después
Test C: Borra todos los items
Test B: Falla porque ya no existe id=1 ← Falla por culpa de Test C

Los síntomas:

  • ✅ Tests pasan cuando los corres uno por uno
  • ❌ Tests fallan cuando los corres todos juntos
  • ❌ El orden de ejecución importa (pytest no garantiza orden)
  • ❌ Un test "contamina" el estado para los demás

La solución: aislamiento por test

Cada test debe arrancar con un estado limpio y conocido. No debe depender de lo que hicieron tests anteriores. No debe afectar tests posteriores.

Test A: db vacía → crea usuario → verifica → [db se descarta]
Test B: db vacía → crea item → verifica → [db se descarta]
Test C: db vacía → borra item → verifica → [db se descarta]

Fixtures de pytest te permiten crear una "db fresh" para cada test. Este módulo usa una base de datos dict-based simple — el foco está en los patrones de testing, no en PostgreSQL o SQLAlchemy.


Bases de Datos In-Memory para Testing

Por qué no usar PostgreSQL en tests (por ahora)

En tests, quieres:

  • ⚡ Velocidad: tests deben correr en segundos
  • 🔒 Aislamiento: cada test con su propio estado
  • 📦 Simplicidad: sin instalar servidores ni configurar conexiones

PostgreSQL en producción es potente. Pero para integration tests tempranos, una base de datos in-memory es suficiente. En el Módulo 8 verás testing contra PostgreSQL real cuando la aplicación sea más compleja. Aquí usamos una base de datos dict-based que simula operaciones CRUD — el patrón es el mismo.

Base de datos dict-based para este módulo

# database.py — base de datos simple para entender fixtures
# (SQLAlchemy/SQLite in-memory viene en Módulo 8)

def create_db():
    """Crea una base de datos vacía (dict)."""
    return {"items": [], "next_id": 1}


def insert_item(db: dict, name: str, price: float) -> dict:
    """Inserta item y retorna el creado."""
    item = {"id": db["next_id"], "name": name, "price": price}
    db["items"].append(item)
    db["next_id"] += 1
    return item.copy()


def get_item(db: dict, item_id: int) -> dict | None:
    """Obtiene item por id."""
    for item in db["items"]:
        if item["id"] == item_id:
            return item.copy()
    return None


def update_item(db: dict, item_id: int, name: str | None = None, price: float | None = None) -> dict | None:
    """Actualiza item. Retorna el actualizado o None si no existe."""
    for item in db["items"]:
        if item["id"] == item_id:
            if name is not None:
                item["name"] = name
            if price is not None:
                item["price"] = price
            return item.copy()
    return None


def delete_item(db: dict, item_id: int) -> bool:
    """Elimina item. Retorna True si existía."""
    for i, item in enumerate(db["items"]):
        if item["id"] == item_id:
            db["items"].pop(i)
            return True
    return False


def list_items(db: dict) -> list[dict]:
    """Lista todos los items."""
    return [item.copy() for item in db["items"]]

Esta "base de datos" es solo un diccionario. No necesitas instalar nada. El foco está en cómo testear operaciones con fixtures — el patrón se transfiere a SQLAlchemy o cualquier ORM.

Comparación rápida: dict vs SQLite in-memory vs PostgreSQL

EnfoqueVelocidadAislamientoUso típico
Dict-basedMuy rápidaTotal (copia por test)Este módulo, aprendizaje
SQLite in-memoryRápidaTotal (DB nueva por test)Módulo 8, apps con ORM
PostgreSQL testMás lentaRequiere reset/transacciónE2E contra stack real

Para aprender patrones, dict-based es ideal: cero configuración, máximo foco en cómo estructurar fixtures y tests.


Fixtures de Base de Datos con pytest

Fixture básica: db vacía por test

# tests/test_database.py
import pytest
from database import create_db, insert_item, get_item, list_items


@pytest.fixture
def db():
    """Base de datos fresca para cada test."""
    database = create_db()
    return database


def test_insert_and_get(db):
    # ARRANGE: db vacía (viene de fixture)
    # ACT
    inserted = insert_item(db, "Widget", 19.99)
    # ASSERT
    assert inserted["id"] == 1
    assert inserted["name"] == "Widget"
    assert inserted["price"] == 19.99

    retrieved = get_item(db, 1)
    assert retrieved is not None
    assert retrieved["name"] == "Widget"


def test_list_empty_db(db):
    assert list_items(db) == []

Cada test que usa db recibe una nueva instancia. test_insert_and_get y test_list_empty_db no comparten estado.

Fixture compuesta: db pre-poblada

@pytest.fixture
def db():
    """Base de datos fresca."""
    return create_db()


@pytest.fixture
def db_with_items(db):
    """Base de datos con items de ejemplo."""
    db["items"] = [
        {"id": 1, "name": "Item A", "price": 10.0},
        {"id": 2, "name": "Item B", "price": 20.0},
    ]
    db["next_id"] = 3
    return db


def test_get_existing_item(db_with_items):
    item = get_item(db_with_items, 1)
    assert item is not None
    assert item["name"] == "Item A"


def test_update_item(db_with_items):
    from database import update_item
    updated = update_item(db_with_items, 1, name="Item A Updated")
    assert updated["name"] == "Item A Updated"
    assert get_item(db_with_items, 1)["name"] == "Item A Updated"

db_with_items depende de db — pytest resuelve el orden automáticamente. Cada test que pide db_with_items obtiene una db con los mismos datos iniciales, pero cada test tiene su copia.


Setup y Teardown con yield

Fixtures que necesitan limpieza

A veces el setup crea recursos que deben liberarse: conexiones, archivos, procesos. La forma estándar es usar yield:

@pytest.fixture
def db():
    """Base de datos con teardown explícito."""
    database = create_db()
    yield database
    # Teardown: se ejecuta después del test
    database.clear()
    database["items"] = []
    database["next_id"] = 1


def test_something(db):
    insert_item(db, "X", 1.0)
    assert len(list_items(db)) == 1
# Aquí pytest ejecuta el teardown (después de yield)

En nuestra db dict-based no hay recursos externos que liberar, pero el patrón es el mismo que usarás con SQLAlchemy: yield session → session.close() en teardown.

autouse: fixture que corre sola

Si quieres que una fixture se ejecute en todos los tests del módulo, sin pasarla como parámetro:

@pytest.fixture(autouse=True)
def reset_global_state():
    """Limpia estado global antes de cada test."""
    # Setup
    original = get_global_config()
    yield
    # Teardown
    set_global_config(original)

Úsalo con moderación. Para bases de datos, normalmente prefieres pasar db explícitamente — es más claro qué tests usan la base de datos.

Scope: function vs module vs session

Por defecto, una fixture tiene scope function — se crea una vez por test.

@pytest.fixture(scope="function")  # default
def db():
    return create_db()


@pytest.fixture(scope="module")
def shared_db():
    """Una sola db para todo el módulo — ¡cuidado con contaminación!"""
    return create_db()


@pytest.fixture(scope="session")
def app_config():
    """Config que no cambia durante toda la sesión de tests."""
    return {"debug": True}

Para bases de datos de test, function es lo correcto: aislamiento total.

Dónde colocar las fixtures: conftest.py

Cuando varias carpetas de tests usan las mismas fixtures, colócalas en conftest.py:

tests/
├── conftest.py          ← fixtures compartidas (db, db_with_items)
├── test_database.py     ← tests que usan las fixtures
└── integration/
    └── test_api.py      ← también usa db, db_with_items
# tests/conftest.py
import pytest
from database import create_db


@pytest.fixture
def db():
    return create_db()


@pytest.fixture
def db_with_items(db):
    db["items"] = [
        {"id": 1, "name": "Item A", "price": 10.0},
        {"id": 2, "name": "Item B", "price": 20.0},
    ]
    db["next_id"] = 3
    return db

Cualquier test en tests/ o tests/integration/ puede usar db y db_with_items sin importarlas: pytest las descubre automáticamente.


Testing CRUD con Fixtures

Create: insertar y verificar

import pytest
from database import create_db, insert_item, get_item, list_items


@pytest.fixture
def db():
    return create_db()


def test_create_item_stores_correctly(db):
    item = insert_item(db, "Laptop", 999.99)
    assert item["id"] == 1
    assert item["name"] == "Laptop"
    assert item["price"] == 999.99


def test_create_item_increments_id(db):
    insert_item(db, "A", 1.0)
    item2 = insert_item(db, "B", 2.0)
    assert item2["id"] == 2

Read: verificar datos pre-seeded

@pytest.fixture
def db_with_items(db):
    db["items"] = [
        {"id": 1, "name": "Item A", "price": 10.0},
        {"id": 2, "name": "Item B", "price": 20.0},
    ]
    db["next_id"] = 3
    return db


def test_read_existing_item(db_with_items):
    item = get_item(db_with_items, 1)
    assert item["name"] == "Item A"
    assert item["price"] == 10.0


def test_read_nonexistent_returns_none(db_with_items):
    assert get_item(db_with_items, 99) is None


def test_list_returns_all(db_with_items):
    items = list_items(db_with_items)
    assert len(items) == 2
    assert items[0]["name"] == "Item A"

Update: modificar y verificar

from database import update_item


def test_update_item_name(db_with_items):
    updated = update_item(db_with_items, 1, name="Item A Updated")
    assert updated["name"] == "Item A Updated"
    assert get_item(db_with_items, 1)["name"] == "Item A Updated"


def test_update_item_price(db_with_items):
    updated = update_item(db_with_items, 2, price=25.0)
    assert updated["price"] == 25.0


def test_update_nonexistent_returns_none(db_with_items):
    assert update_item(db_with_items, 99, name="X") is None

Delete: eliminar y verificar

from database import delete_item


def test_delete_removes_item(db_with_items):
    result = delete_item(db_with_items, 1)
    assert result is True
    assert get_item(db_with_items, 1) is None
    assert len(list_items(db_with_items)) == 1


def test_delete_nonexistent_returns_false(db_with_items):
    assert delete_item(db_with_items, 99) is False

Override de Dependencias en FastAPI

El problema: la app usa get_db()

En una app FastAPI típica, los endpoints dependen de get_db() para obtener la conexión a la base de datos. En tests, quieres inyectar tu test database en vez de la real.

# main.py
from fastapi import FastAPI, Depends

app = FastAPI()


def get_db():
    """En producción, retorna conexión real."""
    db = create_connection_to_postgres()
    try:
        yield db
    finally:
        db.close()


@app.get("/items/{item_id}")
def read_item(item_id: int, db=Depends(get_db)):
    item = get_item(db, item_id)
    if item is None:
        raise HTTPException(404)
    return item

La solución: dependency_overrides

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


def get_test_db():
    """Retorna la test database en vez de la real."""
    return test_database


# Antes de los tests, override la dependencia
app.dependency_overrides[get_db] = get_test_db

client = TestClient(app)


def test_read_item(test_database):
    # test_database es una fixture que crea la db
    insert_item(test_database, "Widget", 19.99)

    response = client.get("/items/1")
    assert response.status_code == 200
    assert response.json()["name"] == "Widget"

El truco: get_test_db debe retornar la misma instancia que la fixture test_database. Puedes hacerlo con una variable de módulo o con una closure:

# tests/conftest.py
import pytest
from database import create_db

_test_db = None


@pytest.fixture
def test_database():
    global _test_db
    _test_db = create_db()
    yield _test_db
    _test_db = None


def get_test_db():
    return _test_db

Para evitar globales, una forma más limpia es usar app.dependency_overrides dentro del test con una fixture:

@pytest.fixture
def client(db):
    def override_get_db():
        yield db

    from main import app, get_db
    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()

Así cada test recibe un client que usa su propia db fixture.

Cuándo no usar dependency_overrides

No uses overrides para tests que no requieren base de datos. Si un endpoint no usa get_db, no necesitas inyectar nada. Tampoco conviene overridear demasiadas dependencias en un solo test: si necesitas mockear 5 dependencias, quizá el test está verificando demasiado. Para tests de integración con db, overridear solo get_db suele ser suficiente.


Claude Code para Generar Tests de Base de Datos

Prompt para tests CRUD con fixtures

Genera tests para las operaciones CRUD de esta API, usando fixtures para una test database.

Requisitos:
1. Fixture `db`: base de datos vacía para cada test
2. Fixture `db_with_items`: base de datos con 2 items de ejemplo (id 1 y 2)
3. Tests para: create (insert y verificar), read (get por id, list), update, delete
4. Cada test debe ser independiente — no depender del orden de ejecución
5. Patrón arrange-act-assert
6. Usar la base de datos dict-based (database.py), no SQLAlchemy

Código de database.py: [pegar el contenido]

Ejemplo de respuesta esperada

Claude Code puede generar algo como:

import pytest
from database import create_db, insert_item, get_item, update_item, delete_item, list_items


@pytest.fixture
def db():
    return create_db()


@pytest.fixture
def db_with_items(db):
    db["items"] = [
        {"id": 1, "name": "Item A", "price": 10.0},
        {"id": 2, "name": "Item B", "price": 20.0},
    ]
    db["next_id"] = 3
    return db


class TestCreate:
    def test_insert_returns_item_with_id(self, db):
        item = insert_item(db, "Test", 5.0)
        assert item["id"] == 1
        assert item["name"] == "Test"

    def test_insert_increments_id(self, db):
        insert_item(db, "A", 1.0)
        item2 = insert_item(db, "B", 2.0)
        assert item2["id"] == 2


class TestRead:
    def test_get_existing_item(self, db_with_items):
        item = get_item(db_with_items, 1)
        assert item["name"] == "Item A"

    def test_get_nonexistent_returns_none(self, db_with_items):
        assert get_item(db_with_items, 99) is None

Ajusta el prompt si necesitas más edge cases o fixtures adicionales.

Iterar con Claude Code cuando los tests fallan

Si Claude Code genera tests que fallan por datos incorrectos en las fixtures:

  1. Identifica el fallo: ¿El test espera datos que no existen en db_with_items? ¿Los IDs no coinciden?
  2. Dale contexto: "La fixture db_with_items tiene items con id 1 y 2. El test test_get_item espera un item con name 'X' pero la fixture tiene 'Item A'. Corrige el test o la fixture."
  3. Valida el flujo: Después de la corrección, ejecuta pytest tests/ y verifica que todos pasen.

El ciclo test → fallo → feedback a Claude → corrección es central. No asumas que la primera generación es perfecta.


Ejercicios

Ejercicio 1: Fixture básica (Fácil)

Crea una fixture db que retorne una base de datos vacía y escribe un test que inserte un item y verifique que list_items lo retorna.

Ver solución
import pytest
from database import create_db, insert_item, list_items


@pytest.fixture
def db():
    return create_db()


def test_insert_then_list(db):
    insert_item(db, "Test Item", 42.0)
    items = list_items(db)
    assert len(items) == 1
    assert items[0]["name"] == "Test Item"
    assert items[0]["price"] == 42.0

Ejercicio 2: Fixture compuesta (Fácil)

Crea db_with_items que pre-poble la db con 3 items. Escribe un test que verifique que get_item(db_with_items, 2) retorna el segundo item.

Ver solución
@pytest.fixture
def db():
    return create_db()


@pytest.fixture
def db_with_items(db):
    db["items"] = [
        {"id": 1, "name": "Item 1", "price": 1.0},
        {"id": 2, "name": "Item 2", "price": 2.0},
        {"id": 3, "name": "Item 3", "price": 3.0},
    ]
    db["next_id"] = 4
    return db


def test_get_second_item(db_with_items):
    item = get_item(db_with_items, 2)
    assert item is not None
    assert item["name"] == "Item 2"
    assert item["price"] == 2.0

Ejercicio 3: Fixture con yield (Medio)

Modifica la fixture db para usar yield y en el teardown "limpiar" la db (resetear items y next_id). Verifica que un segundo test que usa db arranca con db vacía aunque el test anterior haya insertado datos.

Ver solución
@pytest.fixture
def db():
    database = create_db()
    yield database
    # Teardown
    database["items"] = []
    database["next_id"] = 1


def test_first_test_inserts(db):
    insert_item(db, "A", 1.0)
    assert len(list_items(db)) == 1


def test_second_test_has_empty_db(db):
    # Cada test recibe una nueva instancia de db, así que esto está vacío
    assert list_items(db) == []

Nota: En realidad cada test recibe una nueva instancia de db (scope function). El teardown con yield es útil cuando la fixture crea recursos externos (conexiones, archivos). La idea del ejercicio es practicar la sintaxis.

Ejercicio 4: Tests CRUD completos (Medio)

Escribe 4 tests: uno para cada operación CRUD (create, read, update, delete), usando las fixtures db y db_with_items según convenga.

Ver solución
def test_create_inserts_and_returns_item(db):
    item = insert_item(db, "New Item", 99.99)
    assert item["id"] == 1
    assert get_item(db, 1)["name"] == "New Item"


def test_read_returns_existing_item(db_with_items):
    item = get_item(db_with_items, 1)
    assert item["name"] == "Item A"
    assert item["price"] == 10.0


def test_update_modifies_item(db_with_items):
    updated = update_item(db_with_items, 1, name="Updated A")
    assert updated["name"] == "Updated A"
    assert get_item(db_with_items, 1)["name"] == "Updated A"


def test_delete_removes_item(db_with_items):
    result = delete_item(db_with_items, 1)
    assert result is True
    assert get_item(db_with_items, 1) is None
    assert len(list_items(db_with_items)) == 1

Ejercicio 5: Dependency override con FastAPI (Medio-Alto)

Si tienes una app FastAPI con un endpoint GET /items/{id} que usa Depends(get_db), escribe un test que use dependency_overrides para inyectar una test database. El test debe insertar un item, llamar al endpoint, y verificar la respuesta.

Ver solución
# Suponiendo main.py con:
# app = FastAPI()
# @app.get("/items/{item_id}")
# def read_item(item_id: int, db=Depends(get_db)):
#     ...

from fastapi.testclient import TestClient
from main import app, get_db


@pytest.fixture
def client(db):
    def override_get_db():
        yield db

    app.dependency_overrides[get_db] = override_get_db
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()


def test_get_item_via_api(client, db):
    from database import insert_item
    insert_item(db, "API Item", 33.0)

    response = client.get("/items/1")
    assert response.status_code == 200
    assert response.json()["name"] == "API Item"

Ejercicio 6: Prompt para Claude Code (Fácil)

Escribe un prompt que le pidas a Claude Code para generar tests de integración que usen fixtures de base de datos. Incluye al menos 3 requisitos específicos.

Ver solución
Genera integration tests para la API de items en main.py.

Requisitos:
1. Usa una fixture `db` que cree una base de datos vacía por test
2. Usa una fixture `db_with_items` con 2 items pre-cargados
3. Usa dependency_overrides para inyectar la test db en la app
4. Tests: GET /items (lista), GET /items/1 (por id), POST /items (crear), PUT /items/1 (actualizar), DELETE /items/1 (eliminar)
5. Cada test debe ser independiente
6. Patrón arrange-act-assert

Troubleshooting

Problema 1: Tests fallan cuando se corren juntos pero pasan individualmente

Causa: Los tests comparten estado (misma db, variable global, etc.).

Solución: Asegúrate de que cada test reciba su propia instancia de db vía fixture con scope function. No reutilices una db como variable de módulo entre tests.

Problema 2: Fixture db_with_items no tiene los datos esperados

Causa: La fixture db que usa db_with_items puede estar devolviendo una referencia compartida que otro test modificó, o el orden de dependencias está mal.

Solución: db_with_items(db) recibe db de la fixture db. Cada llamada a db crea una nueva instancia. Verifica que db retorna create_db() nuevo cada vez. Si usas scope="module", cambia a scope="function".

Problema 3: dependency_overrides no parece aplicarse

Causa: El override se aplica después de que el cliente ya se creó, o se limpia antes de que termine el test.

Solución: Aplica el override antes de crear el TestClient. Usa una fixture que haga app.dependency_overrides[get_db] = override y luego yield TestClient(app), y en teardown app.dependency_overrides.clear().

Problema 4: get_test_db() retorna None

Causa: La función get_test_db se llama antes de que la fixture haya inicializado la variable, o la variable está en un scope incorrecto.

Solución: Evita globales. Usa una fixture que reciba db y defina override_get_db que retorne esa db, y pasa esa función a dependency_overrides dentro del mismo contexto del test.

Problema 5: Teardown con yield no se ejecuta si el test falla

Causa: En teoría pytest ejecuta el teardown después de yield incluso si hay excepciones. Si no ocurre, puede ser una versión antigua de pytest o un error en la fixture.

Solución: Actualiza pytest. Si usas yield, el bloque después del yield es el teardown y pytest lo ejecuta. Para recursos críticos, considera @pytest.fixture con bloque try/finally si sospechas problemas.


Conexión con Proyecto

En el proyecto de test pyramid de este módulo:

  • Necesitas fixtures de base de datos para los integration tests
  • Los tests de endpoints (GET, POST, PUT, DELETE) deben usar una test database aislada
  • Cada test debe arrancar con estado conocido: db vacía o db pre-poblada según el caso
  • dependency_overrides te permite conectar la app FastAPI con la test db sin tocar el código de producción

Las fixtures que definas aquí son el patrón exacto que escalarás en el Módulo 8 con SQLAlchemy y PostgreSQL. El concepto es el mismo: setup limpio por test, teardown opcional, cero dependencias entre tests.


Resumen

  • ✅ Los tests que comparten base de datos son frágiles y dependientes del orden de ejecución
  • ✅ Cada test debe tener su propio estado limpio — usa fixtures para crear una db por test
  • ✅ Para este módulo usamos una db dict-based; el patrón se transfiere a SQLAlchemy
  • ✅ db vacía y db_with_items pre-poblada son fixtures típicas para CRUD
  • ✅ yield en fixtures permite teardown; scope function da aislamiento total
  • ✅ app.dependency_overrides[get_db] inyecta la test database en FastAPI
  • ✅ Claude Code puede generar tests CRUD con fixtures si le das el contexto de database.py y los requisitos

Próxima cápsula: E2E testing — flujos completos de usuario vía API (crear → leer → actualizar → eliminar).


Recursos Adicionales

  1. pytest Fixtures - Documentación oficial de fixtures
  2. pytest Fixture Scope - Function, module, session
  3. FastAPI Testing: Override dependencies - Cómo overridear dependencias en tests
  4. Test Database Patterns (Martin Fowler) - Patrones de test databases en la pyramid
  5. SQLite In-Memory Databases - Para cuando migres a SQLAlchemy en Módulo 8
  6. pytest Yield Fixtures - Teardown con yield

Módulo 3, Cápsula 04 — Testing with Claude Code Guide