Módulo 8: Proyecto: contrato + integración de Reservo

6. Aislar la integración con fixture y rollback

Descripción

Tu integración pasa en verde, pero tiene una grieta que todavía no atendiste: usa un recurso real, y los recursos reales persisten. Un fake en memoria muere con la instancia; una base de datos de SQLite en un archivo sigue ahí cuando el test termina, con las reservas que escribió. Si dos tests comparten esa base, el primero deja su estado y el segundo lo hereda —empieza sucio, y falla por algo que no es su culpa—. Esa es la fragilidad del estado real compartido, y es el precio de tocar lo real. En esta lección la ves ocurrir con salida roja, y luego la curas con las dos técnicas del módulo 7: una fixture que crea y destruye el recurso, y una transacción que se revierte al final de cada test.

El aislamiento no es un adorno del entregable 3: es lo que lo hace confiable. Un test de integración que solo pasa "cuando corre solo" o "cuando corre primero" no es un test, es una trampa —da verde por suerte y rojo por contaminación—. Los tres pilares de un test que sirve son que sea independiente (no depende de otros ni del orden), repetible (da el mismo resultado cada vez) y determinista (no falla por el ambiente). El estado real compartido rompe los tres. Aislar es restaurarlos. Vas a ver la enfermedad —un test que contamina al siguiente— y las dos curas corriendo en verde, y a entender cuándo conviene cada una.

Conexión con el módulo: esta lección endurece el entregable 3 que construiste en la lección 5. El flujo bookgetcancel no cambia; lo que cambia es cómo se le entrega el recurso: ya no una conexión suelta, sino una que una fixture crea limpia y destruye al terminar, o una transacción que se revierte para que nada sobreviva entre tests. Es la última pieza técnica del capstone antes de la caza del breaking change (lección 7) y la entrega formal (lección 8). Con el aislamiento en su sitio, tu integración deja de ser un flujo que pasó una vez y se vuelve una prueba que pasa siempre, sola o acompañada, en cualquier orden.

Analogía: la mesa de laboratorio entre experimentos

En un laboratorio de química, cada experimento empieza con la mesa limpia: material esterilizado, tubos vacíos, la balanza en cero. Nadie hace un experimento sobre los residuos del anterior —una gota olvidada de un reactivo contaminaría el resultado, y no sabrías si tu medición es real o es basura del experimento pasado—. Hay dos maneras de garantizar la mesa limpia. La primera: para cada experimento, sacas material nuevo del armario y, al terminar, lo tiras o lo esterilizas —material fresco cada vez—. La segunda, más rápida cuando el material es caro: trabajas sobre una bandeja encima de la mesa, y al terminar vuelcas la bandeja entera a la basura, dejando la mesa como estaba —todo lo que hiciste se deshace de un golpe—.

Las dos maneras son las dos curas de esta lección. La primera —material fresco cada vez— es la fixture que crea y destruye: cada test recibe una base de datos nueva, vacía, y al terminar se destruye. La segunda —la bandeja que se vuelca— es el rollback de la transacción: cada test trabaja dentro de una transacción abierta, y al terminar se hace rollback, deshaciendo todo lo que escribió sin tocar el estado de partida. En ambos casos el próximo test encuentra la mesa limpia: la balanza en cero, la base sin reservas. El estado real compartido es hacer química sobre los residuos del experimento anterior; el aislamiento es la disciplina de empezar siempre limpio.

La enfermedad: un test que contamina al siguiente

Veamos primero la fragilidad, para entender qué curamos. Imagina dos tests de integración que, por comodidad, comparten una sola conexión a un archivo de base de datos —con commit, como el save real—. Cada uno reserva la misma sala en el mismo horario, creyendo que empieza limpio:

# tests/test_shared_state_is_fragile.py — SIN aislamiento (a proposito)
import sqlite3
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(id="focus", name="Focus", capacity=4, hourly_cents=2500)
ANA = Member(id="m-ana", name="Ana", tier="pro")
START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12)
CLOCK = datetime(2026, 3, 1, 9)

# Una sola conexion a un archivo, COMPARTIDA y con commit: nada se limpia entre tests.
_conn = sqlite3.connect("leaky.db")
_repo = SqliteBookingRepository(_conn)


def _service():
    return BookingService(Calendar(), FixedClock(CLOCK),
                          StubPaymentGateway(ok=True), SpyEmailSender(), _repo)


def test_first_books_focus():
    booking = _service().book(FOCUS, ANA, START, END)   # reserva Focus 9-12
    assert booking.status == "confirmed"


def test_second_books_the_same_slot():
    # Este test cree empezar limpio, pero el anterior dejo su reserva en el archivo.
    booking = _service().book(FOCUS, ANA, START, END)   # el mismo horario: choca
    assert booking.status == "confirmed"

El segundo test hace exactamente lo mismo que el primero, y debería pasar igual. Pero no comparte solo el código: comparte la base de datos. El primer test reservó Focus de 9 a 12 y lo dejó ahí, con commit. Cuando el segundo intenta reservar el mismo horario, book consulta la disponibilidad, encuentra la reserva del primer test todavía en la tabla, y Calendar la rechaza. Corramos:

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

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

tests/test_shared_state_is_fragile.py::test_first_books_focus PASSED [ 50%]
tests/test_shared_state_is_fragile.py::test_second_books_the_same_slot FAILED [100%]

=================================== FAILURES ===================================
_______________________ test_second_books_the_same_slot ________________________

    def test_second_books_the_same_slot():
        # Este test cree empezar limpio, pero el anterior dejo su reserva en el archivo.
>       booking = _service().book(FOCUS, ANA, START, END)   # el mismo horario: choca

reservo/services.py:18: in book
    raise RoomUnavailable(room.id)
E   reservo.calendar.RoomUnavailable: focus
=========================== short test summary info ============================
FAILED tests/test_shared_state_is_fragile.py::test_second_books_the_same_slot
========================= 1 failed, 1 passed in 0.04s ==========================

Ahí está la contaminación, sin adornos. El primer test pasó; el segundo falló con RoomUnavailable: focus, no porque su código esté mal —es idéntico al del primero, que pasó— sino porque heredó la reserva que el primero dejó en el archivo. El segundo test no es independiente: su resultado depende de que el primero corriera antes. Y es peor de lo que parece: si invirtieras el orden, fallaría el otro; si corrieras el segundo solo, pasaría. Un test que pasa o falla según qué corrió antes es inútil como red de seguridad —no puedes creerle ni al verde ni al rojo—. Esto es exactamente lo que el aislamiento cura.

Cura A: la fixture que crea y destruye

La primera cura es material fresco cada vez: una fixture que entrega, por test, una base de datos nueva y vacía, y la destruye al terminar. Es la que ya usaste en la lección 5, ahora vista como técnica de aislamiento. Con :memory:, cada conexión es una base independiente que vive solo mientras la conexión vive:

# tests/test_isolation.py — Cura A: base efimera fresca por test
@pytest.fixture
def fresh_repo():
    conn = sqlite3.connect(":memory:")          # base nueva y vacia
    yield SqliteBookingRepository(conn)
    conn.close()                                # se destruye al terminar


def test_isolated_a_sees_one_booking(fresh_repo):
    _book_once(fresh_repo)
    assert len(fresh_repo.find_by_room("focus")) == 1


def test_isolated_b_sees_one_booking(fresh_repo):
    _book_once(fresh_repo)                       # reserva el MISMO horario que A
    assert len(fresh_repo.find_by_room("focus")) == 1

Los dos tests reservan la misma sala y horario, como los del ejemplo enfermo. Pero ahora cada uno recibe su propia base :memory:, así que ninguno ve la reserva del otro: cada uno encuentra exactamente una reserva, la suya. La clave es el scope por defecto de la fixture —function—: pytest la ejecuta de nuevo para cada test, así que conn es una conexión nueva a una base nueva cada vez. El yield entrega el repositorio al test; lo que va después del yield (conn.close()) es el desmontaje, que corre al terminar. Material fresco, tirado al acabar.

Cura B: la transacción que se revierte

La segunda cura es la bandeja que se vuelca: una sola base de datos —quizás un archivo caro de crear— compartida entre tests, pero cada test trabaja dentro de una transacción que se revierte al final, deshaciendo todo lo que escribió. Para esto, el save no puede hacer commit —un commit haría la escritura permanente y la sacaría del alcance del rollback—. Se usa una variante del repositorio que deja el control de la transacción al test:

# reservo/sqlite_repo_tx.py — variante que NO hace commit (deja el control al test)
class UncommittedSqliteBookingRepository(SqliteBookingRepository):
    def save(self, booking):
        self._conn.execute("INSERT INTO bookings (...) VALUES (...) "
                           "ON CONFLICT(id) DO UPDATE SET ...", (...))
        # sin commit: la escritura vive en la transaccion abierta de la conexion

Y la fixture envuelve cada test en un punto de retorno (SAVEPOINT) y hace rollback a él al terminar. La conexión al archivo se crea una sola vez (scope module), porque abrirla es lo caro; lo que se repite por test es el savepoint y su rollback:

# tests/test_isolation.py — Cura B: rollback por test sobre una conexion compartida
@pytest.fixture(scope="module")
def shared_conn(tmp_path_factory):
    path = tmp_path_factory.mktemp("db") / "reservo.db"
    conn = sqlite3.connect(path)
    SqliteBookingRepository(conn)               # crea el esquema una sola vez
    conn.commit()
    yield conn
    conn.close()


@pytest.fixture
def rolled_back_repo(shared_conn):
    shared_conn.execute("SAVEPOINT test")       # marca el punto de partida
    yield UncommittedSqliteBookingRepository(shared_conn)
    shared_conn.execute("ROLLBACK TO test")     # deshace todo lo del test
    shared_conn.execute("RELEASE test")


def test_rollback_a_starts_empty(rolled_back_repo):
    assert rolled_back_repo.find_by_room("focus") == []    # nada del test anterior
    _book_uncommitted(rolled_back_repo)
    assert len(rolled_back_repo.find_by_room("focus")) == 1


def test_rollback_b_starts_empty(rolled_back_repo):
    assert rolled_back_repo.find_by_room("focus") == []    # el rollback de A limpio
    _book_uncommitted(rolled_back_repo)
    assert len(rolled_back_repo.find_by_room("focus")) == 1

Fíjate en la primera aserción de cada test: find_by_room("focus") == []. Comprueba que el test empieza vacío —que el rollback del test anterior de verdad limpió la bandeja—. El test A reserva Focus, verifica que hay una, y al terminar la fixture hace ROLLBACK TO test, borrando esa reserva. El test B, sobre la misma conexión al mismo archivo, empieza y encuentra la tabla vacía otra vez, porque el rollback de A deshizo lo suyo. Comparten el recurso caro (la conexión al archivo) pero no el estado (cada uno se limpia con su rollback).

Ejemplo trabajado: las dos curas, en verde

Corramos las cuatro pruebas —las dos de la fixture fresca y las dos del rollback— juntas:

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

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

tests/test_isolation.py::test_isolated_a_sees_one_booking PASSED [ 25%]
tests/test_isolation.py::test_isolated_b_sees_one_booking PASSED [ 50%]
tests/test_isolation.py::test_rollback_a_starts_empty PASSED [ 75%]
tests/test_isolation.py::test_rollback_b_starts_empty PASSED [100%]

============================== 4 passed in 0.02s ==============================

Cuatro verdes. Los mismos dos tests que, compartiendo estado, se contaminaban y daban 1 failed, ahora —aislados— pasan los dos, en cualquier orden y corran solos o juntos. La cura A (fixture fresca) y la cura B (rollback) logran lo mismo por caminos distintos: que cada test empiece con la mesa limpia. Las aserciones == [] del inicio de los tests de rollback son la prueba explícita de que el aislamiento funciona: cada uno empieza vacío porque el anterior deshizo lo suyo. Con esto, tu entregable 3 no solo pasa: pasa siempre.

Cuándo cada cura

Las dos aíslan; se eligen por el costo del recurso. La regla práctica:

  • Fixture fresca por test (cura A) cuando crear el recurso es barato. Con SQLite :memory:, abrir una conexión y crear el esquema es casi gratis, así que una base nueva por test es lo ideal: máxima independencia, cero riesgo de fuga entre tests, y el código es el más simple —connect, yield, close—. Para la mayoría de las integraciones de Reservo, esta es la elección por defecto.
  • Rollback sobre conexión compartida (cura B) cuando crear el recurso es caro. Si en vez de :memory: tuvieras una base de datos real cuya creación tarda (migraciones, esquema grande, datos semilla), abrirla una vez por test sería lento. Ahí conviene crearla una vez (scope module o session) y aislar cada test con una transacción que se revierte —pagas el costo de crear una sola vez y el rollback es rapidísimo—.

Para este capstone, con SQLite, la cura A es la recomendada por su simplicidad. La cura B está en tu caja de herramientas para el día en que el recurso real sea caro —una base de datos de verdad en testing-backend-applications-guide, por ejemplo—. Saber que existen las dos, y por qué elegirías cada una, es parte del método.

Errores comunes

Usar un archivo con nombre fijo y no limpiarlo. Qué pasa: alguien crea la base en test.db con nombre fijo y no la borra; el siguiente test —o la siguiente corrida— la encuentra con datos viejos y falla misteriosamente. Por qué pasa: el estado real persiste, y un nombre fijo lo hace reaparecer. Cómo detectarlo: si un test pasa la primera vez y falla la segunda, o depende del orden, probablemente comparte un archivo que no se limpia. Cómo corregirlo: usa :memory: (que desaparece con la conexión) o tmp_path/tmp_path_factory de pytest (un archivo único por test que pytest destruye). Nunca un nombre fijo sin limpieza. El ejemplo enfermo de esta lección usó leaky.db a propósito para exhibir justo esto.

Hacer commit cuando quieres aislar con rollback. Qué pasa: se usa la estrategia de rollback pero el save del repositorio hace commit, así que la escritura se vuelve permanente y el rollback no la deshace. Por qué pasa: el repositorio de producción hace commit, y se reusa tal cual. Cómo detectarlo: si tu fixture hace rollback pero los tests igual se contaminan, alguien commiteó en medio. Cómo corregirlo: para aislar con rollback, el código bajo prueba no debe commitear dentro del test —usa una variante sin commit (como UncommittedSqliteBookingRepository) y deja el control de la transacción a la fixture—. Un commit cierra la transacción y saca lo escrito del alcance del rollback: son incompatibles.

Compartir el recurso y el estado por usar mal el scope. Qué pasa: alguien pone la fixture del repositorio en scope module o session para "que sea más rápida", pero sin rollback, así que todos los tests comparten la misma base con estado acumulado. Por qué pasa: se confunde "compartir el recurso caro" con "compartir el estado". Cómo detectarlo: si subiste el scope y los tests empezaron a contaminarse, compartiste de más. Cómo corregirlo: puedes compartir el recurso (la conexión, scope module) sin compartir el estado, si aíslas cada test con rollback (cura B). Compartir el recurso es una optimización legítima; compartir el estado es la enfermedad. El rollback es lo que te deja tener lo uno sin lo otro.

Ejercicios

Ejercicio 1 — Diagnostica la contaminación. El test test_second_books_the_same_slot falló con RoomUnavailable: focus, pero su código es idéntico al de test_first_books_focus, que pasó. Explica por qué falló, qué pilar de un buen test se rompió, y qué pasaría si corrieras test_second_books_the_same_slot solo (sin el primero).

Ver solución

Por qué falló: los dos tests comparten la misma conexión a leaky.db, y el save real hace commit, así que la reserva del primer test quedó permanente en el archivo. Cuando el segundo test intenta reservar el mismo horario, book consulta la disponibilidad con find_by_room, encuentra la reserva del primer test todavía en la tabla, y Calendar.is_available devuelve False, así que book lanza RoomUnavailable. El segundo test no falló por su código —falló por el estado que heredó del primero—.

Qué pilar se rompió: la independencia. Un buen test no debe depender de otros ni del orden de ejecución; este depende de que el primero corriera antes y dejara su reserva. (De paso también se rompe la repetibilidad: la segunda corrida podría comportarse distinto a la primera según lo que haya en el archivo.)

Si corrieras el segundo solo: pasaría. Sin el primero, el archivo (recién creado o vacío) no tiene la reserva de Focus, así que book encuentra el horario libre y confirma. Ese es el síntoma clásico de la contaminación: el test pasa aislado y falla acompañado, o al revés. Un test cuyo resultado cambia según qué corrió antes no sirve como red de seguridad —por eso hay que aislarlo—.

Ejercicio 2 — Elige la cura. Para cada situación di si conviene la cura A (fixture fresca por test) o la cura B (rollback sobre conexión compartida), y por qué: (a) los tests de integración de Reservo contra SQLite :memory:; (b) una suite contra una base de datos real cuyo esquema tarda 3 segundos en crearse con sus migraciones; (c) un solo test de integración que corre aislado, sin vecinos.

Ver solución
  • (a) Cura A (fixture fresca). Con :memory:, crear una base nueva por test es casi gratis, así que la máxima independencia (una base limpia por test) no cuesta nada. Es la elección por defecto de Reservo: el código más simple, cero riesgo de fuga, y rapidísimo.
  • (b) Cura B (rollback). Si crear el esquema tarda 3 segundos, hacerlo por test volvería la suite lenta (3 s × N tests). Conviene crear la base una sola vez (scope module/session) y aislar cada test con una transacción que se revierte —pagas los 3 segundos una vez, y el rollback por test es instantáneo—. Compartes el recurso caro sin compartir el estado.
  • (c) Cualquiera (o ninguna elaborada). Un test que corre solo, sin vecinos con quienes contaminarse, tiene menos presión de aislamiento —no hay "test anterior" que le deje basura—. Aun así, la cura A (fixture fresca) es buena práctica barata: garantiza que empiece limpio incluso si mañana le agregas un vecino. La regla: aísla por defecto; el costo de la cura A es tan bajo que no hay razón para no aplicarla.

El criterio en una frase: la cura A cuando el recurso es barato de crear (casi siempre, con :memory:); la cura B cuando es caro y quieres crearlo una vez y reusarlo aislando con rollback.

Ejercicio 3 — Por qué el == [] al inicio. Los tests de rollback empiezan con assert rolled_back_repo.find_by_room("focus") == []. Explica qué verifica esa aserción, por qué es importante tenerla, y qué bug del aislamiento cazaría si estuviera mal el rollback.

Ver solución

Qué verifica: que el test empieza con la base vacía de reservas de Focus —es decir, que el aislamiento funcionó y el test anterior no dejó residuos—. Es una comprobación explícita de la precondición "empiezo limpio".

Por qué es importante: sin ella, un test podría pasar por accidente aunque el aislamiento estuviera roto. Imagina que el rollback fallara y el test B empezara con la reserva de A todavía ahí: si B solo verificara "hay al menos una reserva de Focus", pasaría con la basura de A, y nunca sabrías que el aislamiento no funciona. La aserción == [] al inicio convierte el aislamiento en algo verificado, no supuesto: si la bandeja no se volcó, esta línea lo delata.

Qué bug cazaría: si el ROLLBACK TO test de la fixture estuviera mal —por ejemplo, si el save hiciera commit y la escritura sobreviviera al rollback, o si el savepoint estuviera mal nombrado—, el test B empezaría con la reserva de A todavía en la tabla, find_by_room("focus") devolvería una lista de longitud 1 en vez de [], y la aserción == [] fallaría al inicio de B. Ese rojo diría, con precisión, "el aislamiento entre A y B no funciona": exactamente el bug que quieres que salte temprano y no que se esconda detrás de un verde afortunado.

Resumen y siguiente paso

En esta lección endureciste el entregable 3 con el aislamiento del módulo 7. Viste primero la enfermedad —dos tests que comparten una base con commit y se contaminan: el segundo falla con RoomUnavailable por heredar la reserva del primero, rompiendo la independencia—. Luego aplicaste las dos curas y las viste en verde: la fixture fresca (cura A), que da una base :memory: nueva por test y la destruye al terminar —material fresco cada vez—, y el rollback (cura B), que comparte una conexión a un archivo caro pero envuelve cada test en un SAVEPOINT que se revierte —la bandeja que se vuelca—. Con la mesa de laboratorio entre experimentos fijaste la imagen: empezar siempre limpio. Y aprendiste cuándo cada una: la A cuando el recurso es barato (casi siempre, con :memory:), la B cuando es caro y quieres crearlo una vez y aislar con rollback.

Antes de avanzar deberías poder: reconocer la contaminación por estado real compartido y nombrar el pilar que rompe; escribir una fixture que crea y destruye el recurso, y una que aísla con SAVEPOINT/rollback; y elegir entre las dos según el costo del recurso.

Tienes los tres entregables completos y robustos: el contrato desde ambos lados, y la integración de punta a punta aislada. Falta demostrar por qué valieron la pena. En la lección 7 disparas la maquinaria: alguien cambia el SqliteBookingRepository.get para que devuelva None, y ves las dos caras del mismo bug —el crimen (un AttributeError lejano en producción, sin contrato) y el arresto (el rojo quirúrgico del contrato, antes del deploy)—. Es el pago de oro de todo el proceso que armaste.

Recursos