Módulo 7: Datos y aislamiento en integración

4. Fixtures de BD temporal con yield

Descripción

La lección anterior te dejó con un problema abierto: el rollback aísla de maravilla, pero solo si nadie confirma dentro del test, y nuestro save confirma. Esta lección resuelve ese caso con la técnica que vas a usar más que ninguna otra en integración: una fixture con yield que le da a cada test su propia base de datos temporal —fresca, vacía, recién nacida— y la destruye cuando el test termina. La idea es distinta a la del rollback y por eso no comparte su debilidad. El rollback dice: "una sola base, revierte lo que cada test escribió". La fixture de base nueva dice: "una base distinta por test, y tírala entera al final". Si cada test tiene su propia base, el commit de save deja de ser un problema: confirma cuanto quiera, porque esa base es privada de ese test y muere con él —no hay un vecino que herede nada, porque no comparten base—. Es la diferencia entre limpiar la mesa entre experimentos y darle a cada experimento su propia mesa; la segunda es más simple de garantizar, porque no depende de que la limpieza corra bien.

El corazón de la técnica es una palabra de Python que pytest carga de significado: yield. Una fixture normal devuelve un valor con return; una fixture con yield hace algo más rico: ejecuta código de preparación (crear la base, el esquema), entrega el recurso con yield, deja que el test corra, y cuando el test termina, ejecuta el código de limpieza que está después del yield (cerrar la conexión, destruir la base). El yield es la frontera exacta entre el "antes" y el "después" de cada test, y —esto es lo decisivo— pytest garantiza que el "después" corre pase lo que pase, incluso si el test falla en medio. Por eso la limpieza de una fixture con yield no tiene el agujero del DELETE al final o del rollback en el cuerpo del test: no es una línea que el test pueda saltarse al reventar, es una responsabilidad que pytest cumple siempre. Vas a escribir esa fixture, verla aislar una suite en cualquier orden con salida real, y entender el papel del scope en cuánto vive el recurso.

Conexión con el módulo: la lección 3 te dio el rollback y su límite; esta te da la técnica sin ese límite, la que resuelve el caso del servicio que confirma. Juntas son las dos herramientas de aislamiento del módulo: rollback cuando controlas la transacción y recrear es caro; base nueva por test cuando el código confirma o cuando quieres la máxima simplicidad. La lección 5 tomará esta misma fixture y decidirá el recurso que entrega —:memory: o un archivo temporal—, con números medidos. La lección 6 le agregará el sembrado de datos conocidos. Y la 7 la usará para demostrar los principios de independencia y repetibilidad. O sea: la fixture con yield que escribes aquí es el esqueleto sobre el que se monta el resto del módulo. Vale la pena entenderla a fondo.

Analogía: el quirófano que se prepara y se limpia solo

Piensa en cómo se organiza un quirófano entre una cirugía y la siguiente. Antes de cada operación, un equipo prepara la sala: instrumental esterilizado, sábanas limpias, todo en su sitio. Durante la cirugía, el equipo médico usa la sala sin preocuparse por lo anterior, porque llegó impecable. Y después —salga la cirugía como salga, sin complicaciones o con ellas— otro equipo limpia y esteriliza todo, dejando la sala lista para la siguiente. Lo crucial: la limpieza ocurre siempre, no solo cuando la cirugía sale bien. Si algo se complicó, la sala se limpia con más razón, no menos. Nadie diría "como la operación se complicó, dejemos la sala sucia para la próxima"; sería absurdo y peligroso.

Una fixture con yield es ese protocolo de quirófano. El código antes del yield es la preparación: crea la base, monta el esquema. El yield es el momento en que la sala se entrega al cirujano: el test corre y usa el recurso. El código después del yield es la limpieza: cierra la conexión, destruye la base. Y como en el quirófano, esa limpieza corre pase lo que pase con el test —pasó, falló, reventó con una excepción—, porque pytest la trata como una responsabilidad del protocolo, no como un paso opcional que dependa de cómo terminó la operación. Esa garantía es justamente lo que hacía falta: un aislamiento que no se salte cuando el test falla, que es cuando más lo necesitas.

La fixture: crear una base fresca y destruirla

Escribamos la fixture y una suite que la use, integrando a través del servicio que confirma —el caso que el rollback no pudo aislar—.

# tests/test_fresh_db_fixture.py — una fixture que crea una BD real y la destruye por test
import sqlite3
import pytest
from datetime import datetime
from reservo.calendar import Calendar
from reservo.doubles import FixedClock, SpyEmailSender, StubPaymentGateway
from reservo.models import Member, Room
from reservo.services import BookingService
from reservo.sqlite_repo import SqliteBookingRepository

FOCUS = Room("focus", "Focus", 4, 2500); ANA = Member("m-ana", "Ana", "pro")

@pytest.fixture
def repo():
    conn = sqlite3.connect(":memory:")   # (1) SETUP: BD nueva y vacia
    yield SqliteBookingRepository(conn)  # (2) entrega el repo al test
    conn.close()                         # (3) TEARDOWN: se destruye la BD entera

def make_service(repo):
    return BookingService(Calendar(), FixedClock(datetime(2026, 3, 1, 9)),
                          StubPaymentGateway(True), SpyEmailSender(), repo)

def count(repo):
    return len(repo.find_by_room("focus"))

def test_a_books_and_sees_one(repo):
    make_service(repo).book(FOCUS, ANA,
                            datetime(2026, 3, 10, 9), datetime(2026, 3, 10, 12))
    assert count(repo) == 1

def test_b_starts_empty(repo):
    assert count(repo) == 0            # BD nueva: nada del test A
    make_service(repo).book(FOCUS, ANA,
                            datetime(2026, 3, 11, 9), datetime(2026, 3, 11, 12))
    assert count(repo) == 1

def test_c_also_starts_empty(repo):
    assert count(repo) == 0            # otra BD nueva: nada de A ni de B

Lee la fixture repo con los tres marcadores. En (1), el setup: abrimos una conexión a una base :memory: recién creada —vacía, sin filas—. En (2), el yield: construimos el repositorio sobre esa conexión y se lo entregamos al test; el test corre aquí, con su base privada. En (3), el teardown: cuando el test termina, cerramos la conexión, y como la base es :memory:, cerrarla la destruye por completo —tabla, filas, todo—. Cada test que pide repo en sus parámetros dispara este ciclo entero: base nueva antes, base destruida después. Fíjate en lo que ya no importa: que book haga commit. Confirma sobre una base que es solo suya y que va a morir; su commit no alcanza a nadie.

Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):

python3 -m pytest tests/test_fresh_db_fixture.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 3 items

tests/test_fresh_db_fixture.py::test_a_books_and_sees_one PASSED           [ 33%]
tests/test_fresh_db_fixture.py::test_b_starts_empty PASSED                 [ 66%]
tests/test_fresh_db_fixture.py::test_c_also_starts_empty PASSED            [100%]

============================== 3 passed in 0.01s ===============================

Los tres verdes, integrando a través del servicio que confirma —lo que el rollback de la lección 3 no logró—. Los tests B y C afirman count == 0 al empezar y es verdad, porque cada uno recibe una base recién nacida donde nadie escribió. Y la prueba de aislamiento real, correrlo al revés:

python3 -m pytest tests/test_fresh_db_fixture.py::test_c_also_starts_empty \
                  tests/test_fresh_db_fixture.py::test_b_starts_empty \
                  tests/test_fresh_db_fixture.py::test_a_books_and_sees_one -v
collected 3 items

tests/test_fresh_db_fixture.py::test_c_also_starts_empty PASSED           [ 33%]
tests/test_fresh_db_fixture.py::test_b_starts_empty PASSED                [ 66%]
tests/test_fresh_db_fixture.py::test_a_books_and_sees_one PASSED          [100%]

Verde en orden invertido. El orden no importa porque no hay nada compartido que herenciar: cada test vive y muere en su propia base. Esta es la técnica más simple y más robusta de aislamiento en integración, y la que vas a usar por defecto.

El yield, en detalle: por qué la limpieza corre siempre

Detengámonos en la mecánica del yield, porque es la que da la garantía. Una fixture con yield es, por dentro, un generador que pytest maneja en tres tiempos:

  1. pytest entra a la fixture y ejecuta hasta el yield. Todo lo anterior al yield es el setup.
  2. pytest toma el valor que produce el yield y se lo inyecta al test como argumento. El test corre.
  3. cuando el test termina —haya pasado, fallado, o lanzado una excepción—, pytest vuelve a la fixture y ejecuta lo que está después del yield. Ese es el teardown.

El paso 3 es la clave. pytest ejecuta el teardown en un contexto que garantiza que corra aunque el test falle: internamente, es como si el yield estuviera dentro de un try/finally, con la limpieza en el finally. Por eso conn.close() corre siempre. Compáralo con las dos formas rotas que ya descartamos:

  • El DELETE como última línea del cuerpo del test: si el test falla en una aserción anterior, esa línea no se ejecuta. El teardown de la fixture sí.
  • El rollback como última línea del cuerpo del test: mismo problema. En una fixture con yield, va después del yield y corre pase lo que pase.

Verlo con un experimento pequeño lo hace tangible. Si agregas un print antes y después del yield, y un test que falla a propósito, verás que el print de después del yield aparece igual —la limpieza corrió aunque el test reventara—. Esa es la propiedad que hace de la fixture con yield el lugar correcto para el aislamiento: la limpieza no es un favor que el test hace si llega vivo al final, es una obligación que pytest cumple por ti.

El scope: cuánto vive el recurso

Toda fixture tiene un scope, que decide cada cuánto se ejecuta su ciclo de setup/teardown. Por defecto es function: la fixture corre una vez por test, que es lo que quieres para el aislamiento —base nueva por test—. Pero hay otros, y elegir mal el scope rompe el aislamiento o desperdicia tiempo.

  • scope="function" (por defecto): setup y teardown por cada test. Es el que aísla: cada test recibe su propia base. Úsalo para el recurso que debe estar limpio en cada test.
  • scope="module": setup y teardown una vez por archivo de tests. Todos los tests del módulo comparten el recurso. Útil para lo caro y de solo lectura (un esquema que no cambia, datos de referencia inmutables), pero peligroso para lo que se escribe: si compartes una base con escritura entre todos los tests del módulo, vuelves al problema de la lección 2.
  • scope="session": una vez para toda la corrida de pytest. Aún más amplio; mismo cuidado.

La regla práctica: pon en scope amplio (módulo/sesión) lo caro y estable que no se modifica, y en scope función lo que cada test necesita fresco. En la lección 3 usaste las dos juntas: db de módulo para el esquema (caro, estable), conn de función para el rollback (aislamiento por test). En esta lección, como cada test crea su propia base :memory: desde cero, una sola fixture de scope función basta —el "caro" (crear una tabla vacía) es aquí tan barato que no vale la pena separarlo—. Con un esquema grande, volverías al esquema de dos fixtures. El scope no es un detalle: es la palanca con la que balanceas aislamiento contra velocidad.

Un aviso concreto sobre el scope amplio y la escritura: si por velocidad pones la base en una fixture de scope módulo y tus tests escriben en ella, has recreado el REPO compartido de la lección 2 con otro disfraz, y la contaminación vuelve. El scope función existe justamente para que el aislamiento sea el comportamiento por defecto; ampliarlo es una optimización que solo es segura para recursos que no se modifican.

Errores comunes

Usar return en vez de yield y perder el teardown. Qué pasa: la fixture hace return SqliteBookingRepository(conn) en vez de yield, y la conexión nunca se cierra. Por qué pasa: return es lo natural para "dar un valor". Cómo detectarlo: con return no hay lugar para el código de limpieza; las conexiones se acumulan, y con bases en archivo, los archivos quedan sin borrar. Cómo corregirlo: usa yield para el recurso y pon la limpieza después. Si de verdad no hay nada que limpiar, return está bien; pero un recurso real —una conexión, un archivo— casi siempre necesita teardown, y eso pide yield.

Ampliar el scope a módulo "para que sea más rápido" y reintroducir la contaminación. Qué pasa: la fixture pasa a scope="module" para no recrear la base por test, y de golpe los tests se contaminan entre sí. Por qué pasa: el scope amplio ahorra tiempo, y es tentador. Cómo detectarlo: si al ampliar el scope aparecen fallos dependientes del orden, es que compartiste un recurso con escritura. Cómo corregirlo: solo amplía el scope de recursos que no se escriben durante los tests (esquema, datos de referencia). Lo que cada test modifica va en scope función.

Poner la limpieza antes del yield por error. Qué pasa: alguien, confundido, cierra la conexión o borra la base antes del yield, y el test recibe un recurso ya destruido. Por qué pasa: se mezcla el orden mental de setup y teardown. Cómo detectarlo: el test falla con un error de "conexión cerrada" o "no such table" apenas toca el recurso. Cómo corregirlo: recuerda la regla del quirófano —preparar antes del yield, limpiar después—; todo lo que el test necesita listo va arriba, todo lo que hay que deshacer va abajo.

Ejercicios

Ejercicio 1 — Traza el orden de ejecución. Para la suite test_fresh_db_fixture.py con sus tres tests, escribe la secuencia exacta de eventos que pytest ejecuta, marcando cada setup (antes del yield) y cada teardown (después del yield) de la fixture repo. Supón el orden de definición.

Ver solución

La secuencia, con la fixture de scope función corriendo una vez por test:

  1. setup de repo (conexión :memory: nueva #1) → test_a_books_and_sees_one corre → teardown de repo (conn.close(), base #1 destruida).
  2. setup de repo (conexión :memory: nueva #2) → test_b_starts_empty corre → teardown de repo (base #2 destruida).
  3. setup de repo (conexión :memory: nueva #3) → test_c_also_starts_empty corre → teardown de repo (base #3 destruida).

Tres ciclos completos de setup/teardown, uno por test, cada uno con su propia base. Lo esencial: entre el teardown de un test y el setup del siguiente no hay ningún estado que sobreviva —la base #1 ya no existe cuando nace la #2—. Por eso test_b y test_c ven count == 0: su base es nueva, no la del anterior. Y por eso el orden no importa: reordenar los tests solo reordena ciclos idénticos e independientes.

Ejercicio 2 — El teardown que corre aunque el test falle. Escribe (mentalmente o en tu editor) una fixture repo que imprima "SETUP" antes del yield y "TEARDOWN" después, y un test que use repo y falle a propósito con assert False. Predice qué se imprime al correr con -s, y explica qué garantía de pytest lo hace posible.

Ver solución

Se imprime SETUP y luego TEARDOWN, aunque el test falle. La salida sería, en esencia: la fixture entra e imprime SETUP, el test corre y revienta en assert False, pytest marca el test como fallado, y de todas formas vuelve a la fixture y ejecuta lo de después del yield, imprimiendo TEARDOWN. El test aparece como FAILED, pero el TEARDOWN está en la salida.

La garantía que lo hace posible: pytest ejecuta el código posterior al yield de una fixture en un contexto equivalente a un finally, así que corre pase lo que pase con el test —pasado, fallado o con excepción—. Esa es exactamente la propiedad que hace de la fixture con yield el lugar correcto para la limpieza de aislamiento: si el aislamiento viviera en el cuerpo del test, un assert False a mitad de camino lo saltaría y el siguiente test heredaría basura. En la fixture, no. Por eso el aislamiento va en el teardown de la fixture, nunca en el cuerpo del test.

Ejercicio 3 — Diagnostica un scope mal elegido. Un compañero cambió la fixture repo a scope="module" para acelerar la suite, y ahora test_b_starts_empty y test_c_also_starts_empty fallan, los dos con assert 1 == 0, pero solo cuando corren después de test_a. Explica qué pasó y por qué ambos ven exactamente 1.

Ver solución

Al poner scope="module", la fixture repo corre su setup una sola vez para todo el archivo: se crea una base :memory: que todos los tests comparten, y su teardown (cerrar la conexión) ocurre recién al final del módulo. Con eso, la base ya no es fresca por test: es la misma para los tres, y lo que un test escribe queda para los siguientes —exactamente el REPO compartido de la lección 2, ahora escondido en un scope mal elegido—.

Por qué ambos ven 1: test_a reserva una vez y deja 1 reserva en la base compartida (y pasa, porque afirma count == 1). Luego test_b empieza con assert count(repo) == 0, ve la reserva de A, y falla ahí mismo con 1 == 0 —falla en su primera línea, así que nunca llega a la línea donde reservaría—. Como test_b no alcanzó a escribir nada, la base sigue con 1 sola reserva. Entonces test_c también empieza con assert count(repo) == 0, ve esa misma reserva de A, y falla igual con 1 == 0. El conteo se queda en 1 para los dos porque el único que alcanzó a escribir fue test_a.

La moraleja no es memorizar los números, sino ver el mecanismo: con base compartida, lo que cada test ve depende de lo que los anteriores alcanzaron a escribir antes de terminar o fallar, y eso vuelve el resultado frágil y dependiente del orden. La solución es volver a scope="function": base nueva por test, conteos siempre desde cero, orden irrelevante.

Resumen y siguiente paso

En esta lección escribiste la técnica de aislamiento que más vas a usar: una fixture con yield que crea una base de datos temporal fresca por test y la destruye en el teardown. Viste que aísla incluso cuando integras a través del servicio que confirma —el caso que el rollback no pudo—, porque cada test tiene su propia base y el commit no alcanza a ningún vecino. Entendiste el yield como la frontera entre setup (antes) y teardown (después), y la garantía que lo hace confiable: pytest ejecuta la limpieza pase lo que pase, incluso si el test falla. Y aprendiste el papel del scope: función para lo que cada test necesita fresco, módulo o sesión solo para lo caro y estable que no se escribe.

Antes de avanzar deberías poder: escribir una fixture con yield que abra un recurso, lo entregue y lo cierre; explicar por qué el teardown corre aunque el test falle; y elegir el scope correcto según si el recurso se escribe o no.

Lo que sigue es una decisión que esta fixture deja abierta: qué recurso entregar. Usamos :memory:, pero la fixture podría dar igual una base en un archivo temporal. En la lección 5 vas a comparar :memory: contra un archivo temporal —con la fixture tmp_path de pytest— y a decidir con números medidos: :memory: es rápido y aislado por conexión pero no toca disco; el archivo prueba la persistencia real pero es decenas de veces más lento. Vas a ver esos números en tu propia máquina y a saber cuándo pagar cada costo. Es la misma fixture con yield, con un recurso distinto adentro.

Recursos