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

3. El rollback de transacción como aislamiento

Descripción

Con el diagnóstico firme, llega la primera cura, y es de las más elegantes que existen en pruebas de integración: usar el rollback de una transacción para aislar. La idea es tan bonita como simple. Una base de datos te deja abrir una transacción, hacer todos los cambios que quieras dentro de ella, y luego decidir: o los confirmas con commit —y quedan permanentes— o los descartas con rollback —y desaparecen como si nunca hubieran ocurrido, dejando la base exactamente como estaba antes de abrir la transacción—. La técnica de aislamiento es aprovechar esa segunda opción: envuelves cada test en una transacción y, al terminar, haces rollback. Lo que el test escribió se esfuma, y el siguiente test encuentra la base tal como estaba, sin haberla recreado. No borras filas una por una, no destruyes ni reconstruyes la tabla, no vuelves a cargar el esquema: simplemente deshaces. Es rápido —revertir es barato— y es total —revierte todo lo que el test tocó, hayas o no previsto cada tabla—.

Pero esta técnica tiene una precondición dura que hay que entender antes de confiar en ella, o te va a morder. El rollback solo puede deshacer lo que todavía no se ha confirmado. Si el código bajo prueba hace commit a mitad de camino, ese commit vuelve permanentes sus cambios, y tu rollback posterior ya no tiene nada que deshacer sobre ellos —la base queda contaminada igual—. Y resulta que nuestro SqliteBookingRepository.save hace exactamente eso: termina con self._conn.commit(). Así que esta lección tiene dos caras honestas: la técnica del rollback funciona de maravilla cuando controlas la transacción y nadie confirma antes de tiempo, y no aísla nada cuando el código bajo prueba confirma por su cuenta. Vas a ver las dos con salida real, y vas a salir sabiendo cuándo el rollback es la herramienta correcta y cuándo hay que ir a la fixture de base nueva de la lección 4.

Conexión con el módulo: la lección 2 te dijo que la cura es darle a cada test un estado conocido; esta te da la primera forma de lograrlo —revertir lo que el test escribió— sin destruir ni recrear la base. Es la técnica que la guía de dobles te adelantó cuando, al hablar del commit de save, dejó anotado que el módulo 7 usaría "escribir sin commit y hacer rollback al final". Aquí cumplimos esa promesa y le ponemos la letra chica. La lección 4 te dará la otra forma —una base nueva por test— que no tiene la precondición del commit y por eso es el caballo de batalla cuando integras a través de un servicio que confirma. Entender las dos, y cuándo usar cada una, es lo que te deja aislar cualquier suite, no solo las fáciles.

Analogía: el borrador a lápiz

Piensa en cómo trabaja un contador que hace cuentas complicadas antes de pasarlas en limpio. Tiene una hoja de borrador donde escribe a lápiz: prueba una suma, tacha, prueba otra, anota totales tentativos. Mientras está a lápiz, nada es definitivo: si la cuenta no cierra, borra todo con la goma y la hoja vuelve a estar limpia, lista para el siguiente cálculo, sin necesidad de conseguir una hoja nueva. Solo cuando la cuenta está bien, la pasa a tinta al libro mayor, y ahí sí es permanente: la tinta no se borra.

El lápiz es la transacción; la goma es el rollback; la tinta es el commit. Aislar un test con rollback es hacerlo trabajar a lápiz: el test escribe lo que necesita, verifica, y al final pasas la goma —rollback— para que la hoja quede como estaba, sin recrearla. El siguiente test escribe a lápiz sobre la misma hoja recién borrada. Todo funciona de maravilla mientras nadie pase nada a tinta a mitad del cálculo. Y ahí está la trampa: si el código que estás probando, en medio de su trabajo, agarra la pluma y pasa un total al libro mayor —hace commit—, la goma ya no puede borrar esa línea. Por eso el rollback aísla perfectamente cuando el proceso entero es a lápiz, y falla cuando el proceso, por su cuenta, moja la pluma. Nuestro save moja la pluma en cada guardado; tenerlo presente es media lección.

El mecanismo: revertir deja la base limpia sin recrearla

Empecemos por el corazón de la técnica, aislado de todo lo demás, con SQL crudo. Queremos ver, con nuestros propios ojos, que después de un rollback la fila que escribimos desaparece pero la tabla sigue ahí: no recreamos nada, solo deshicimos.

# tests/test_rollback_mechanism.py — el mecanismo del rollback, con SQL crudo
import sqlite3
from reservo.sqlite_repo import SCHEMA

def test_rollback_discards_writes_without_recreating_the_db():
    conn = sqlite3.connect(":memory:")
    conn.execute(SCHEMA)
    conn.commit()                          # el schema es permanente

    # Escribimos una fila DENTRO de la transaccion, sin commit.
    conn.execute(
        "INSERT INTO bookings VALUES "
        "('bk-1','focus','m-ana','2026-03-10T09:00:00','2026-03-10T12:00:00','confirmed',6000)")
    before = conn.execute("SELECT COUNT(*) FROM bookings").fetchone()[0]

    conn.rollback()                        # revertimos la transaccion
    after = conn.execute("SELECT COUNT(*) FROM bookings").fetchone()[0]

    # La tabla sigue existiendo (no la recreamos), pero la fila se fue.
    table_exists = conn.execute(
        "SELECT name FROM sqlite_master WHERE type='table' AND name='bookings'"
    ).fetchone() is not None

    print(f"\nfilas antes={before}  filas despues={after}  tabla_existe={table_exists}")
    assert before == 1
    assert after == 0
    assert table_exists is True

Fíjate en el detalle que hace que esto funcione: creamos el esquema y le hacemos commit antes de la parte que nos importa, para que la tabla sea permanente. Luego insertamos la fila sin commit: esa fila vive solo dentro de la transacción abierta. Contamos —hay una—, hacemos rollback, y contamos de nuevo. La fila desapareció, pero la tabla sigue existiendo, porque su creación se confirmó antes y el rollback no la toca. Corramos con -s para ver el print.

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

python3 -m pytest tests/test_rollback_mechanism.py -v -s
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item

tests/test_rollback_mechanism.py::test_rollback_discards_writes_without_recreating_the_db
filas antes=1  filas despues=0  tabla_existe=True
PASSED

============================== 1 passed in 0.01s ===============================

Ahí está la técnica en una línea de salida: filas antes=1 filas despues=0 tabla_existe=True. Escribimos una fila, la revertimos, y la base volvió a cero filas sin que recreáramos la tablatabla_existe=True lo confirma—. Eso es lo que hace especial al rollback frente a otras formas de limpiar: no reconstruye la base desde el esquema (lento), no borra fila por fila (frágil si olvidas una tabla); simplemente descarta todo lo no confirmado de un golpe. Por eso, cuando aplica, es la técnica de aislamiento más rápida y más completa que hay.

La fixture que revierte en el teardown

Ahora convirtamos el mecanismo en aislamiento real de una suite. La idea: una base de datos que vive para todo el módulo —el esquema se crea una sola vez—, y una fixture por test que, al terminar, hace rollback para descartar lo que ese test escribió. Como el esquema se confirmó una vez y las escrituras de cada test no se confirman, cada test parte de una tabla vacía sobre la misma base, sin recrearla nunca.

# tests/test_rollback_isolation.py — aislar con rollback sobre una BD compartida
import sqlite3
import pytest
from reservo.sqlite_repo import SCHEMA

@pytest.fixture(scope="module")
def db():
    # UNA BD para todo el modulo; el schema se crea una vez y es permanente.
    conn = sqlite3.connect(":memory:")
    conn.execute(SCHEMA)
    conn.commit()
    yield conn
    conn.close()

@pytest.fixture
def conn(db):
    # Cada test corre sobre la MISMA conexion, dentro de una transaccion...
    yield db
    db.rollback()   # ...que se revierte al final: la BD queda limpia sin recrearla

def seed_booking(conn, bid, room_id="focus"):
    # Inserta SIN commit: la fila vive solo en la transaccion del test.
    conn.execute(
        "INSERT INTO bookings VALUES (?,?,?,?,?,?,?)",
        (bid, room_id, "m-ana", "2026-03-10T09:00:00",
         "2026-03-10T12:00:00", "confirmed", 6000))

def count(conn, room_id="focus"):
    return conn.execute(
        "SELECT COUNT(*) FROM bookings WHERE room_id = ?", (room_id,)).fetchone()[0]

def test_a_seeds_two(conn):
    seed_booking(conn, "bk-1")
    seed_booking(conn, "bk-2")
    assert count(conn) == 2

def test_b_starts_clean(conn):
    assert count(conn) == 0        # el rollback del test A limpio la tabla
    seed_booking(conn, "bk-3")
    assert count(conn) == 1

def test_c_also_starts_clean(conn):
    assert count(conn) == 0        # y tambien despues del test B
    seed_booking(conn, "bk-4")
    assert count(conn) == 1

Hay dos fixtures, y la distinción entre ellas es la clave. La fixture db, de scope module, crea la conexión y el esquema una vez para todo el archivo: es cara, no la queremos repetir por test. La fixture conn, de scope por defecto (función), corre una vez por test: entrega la misma conexión de db y, en su teardown —después del yield—, hace db.rollback(). Así, el test A siembra dos reservas dentro de su transacción, verifica que hay dos, y al terminar el rollback las descarta; el test B abre su propia transacción sobre una tabla que —gracias a ese rollback— está vacía otra vez. Nadie recrea la base; solo se revierte entre tests.

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

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

tests/test_rollback_isolation.py::test_a_seeds_two PASSED                  [ 33%]
tests/test_rollback_isolation.py::test_b_starts_clean PASSED              [ 66%]
tests/test_rollback_isolation.py::test_c_also_starts_clean PASSED         [100%]

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

Los tres verdes, y fíjate en lo que afirman los tests B y C: count == 0 al empezar. Sobre una base compartida por todo el módulo, cada uno arranca con la tabla vacía porque el rollback del anterior borró sus filas. Y la prueba de que esto es aislamiento de verdad y no una casualidad del orden: corrámoslo al revés.

python3 -m pytest tests/test_rollback_isolation.py::test_c_also_starts_clean \
                  tests/test_rollback_isolation.py::test_b_starts_clean \
                  tests/test_rollback_isolation.py::test_a_seeds_two -v
collected 3 items

tests/test_rollback_isolation.py::test_c_also_starts_clean PASSED         [ 33%]
tests/test_rollback_isolation.py::test_b_starts_clean PASSED              [ 66%]
tests/test_rollback_isolation.py::test_a_seeds_two PASSED                 [100%]

Verde en orden invertido también. Cada test parte de una tabla limpia sin importar quién corrió antes, porque el rollback de cada uno deja la base como estaba. Eso es exactamente lo que la lección 2 pedía —cada test con un estado conocido— logrado sin recrear la base ni una sola vez.

La precondición dura: el commit del código bajo prueba

Aquí viene la letra chica, y es honesta e importante. Todo lo anterior funcionó porque nosotros controlamos la transacción: sembramos con un INSERT crudo sin commit, y el único que confirma o revierte es nuestro teardown. Pero, ¿qué pasa si en vez de sembrar con SQL crudo integramos a través del servicio real, cuyo save hace commit? Probémoslo, porque el resultado es la lección más valiosa de todas.

# tests/test_rollback_caveat.py — el rollback NO aisla si el codigo bajo prueba hace commit
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, SCHEMA

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

@pytest.fixture(scope="module")
def db():
    conn = sqlite3.connect(":memory:"); conn.execute(SCHEMA); conn.commit()
    yield conn; conn.close()

@pytest.fixture
def conn(db):
    yield db
    db.rollback()   # intentamos revertir... pero save() ya hizo commit

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

def count(conn):
    return conn.execute("SELECT COUNT(*) FROM bookings").fetchone()[0]

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

def test_b_expects_clean_but_inherits(conn):
    assert count(conn) == 0   # el rollback deberia haber limpiado... pero save hizo commit

Es la misma fixture de rollback de antes. La única diferencia es que el test A ya no siembra con SQL crudo: reserva a través del servicio, y book llama a repo.save, que hace commit. El test B espera que el rollback del A haya limpiado la tabla.

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

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

tests/test_rollback_caveat.py::test_a_books_through_the_service PASSED     [ 50%]
tests/test_rollback_caveat.py::test_b_expects_clean_but_inherits FAILED   [100%]

=================================== FAILURES ===================================
_______________________ test_b_expects_clean_but_inherits _______________________

    def test_b_expects_clean_but_inherits(conn):
>       assert count(conn) == 0   # el rollback deberia haber limpiado... pero save hizo commit
E       assert 1 == 0

Falla. El test B esperaba una tabla vacía y encontró la reserva del test A, a pesar del rollback. ¿Por qué? Porque el book del test A, por dentro, llamó a save, que hizo commit, y ese commit volvió permanente la reserva antes de que nuestro teardown pudiera revertir nada. Cuando el rollback corre, la transacción que contenía esa fila ya fue confirmada; no queda nada por deshacer. El lápiz se pasó a tinta a mitad del cálculo, y la goma no borra la tinta.

Esta es la precondición del rollback, demostrada: solo aísla si nadie confirma dentro del test. Sirve de maravilla cuando controlas la transacción —tests que siembran con SQL crudo, o un repositorio diseñado para no confirmar y dejar el commit/rollback al que lo llama—. No sirve, tal cual, cuando integras a través de un servicio que confirma en cada escritura, como el nuestro. Y ahí está la razón de existir de la lección 4: cuando el código bajo prueba confirma, la salida no es pelear con las transacciones, sino darle a cada test una base de datos nueva, de modo que el commit no tenga a quién contaminar porque esa base muere con el test.

Cuándo el rollback es la herramienta correcta

Recojamos el criterio, porque es lo que te llevas. El rollback como aislamiento brilla cuando se cumplen dos condiciones. Primera: tú controlas la transacción —el test o la fixture deciden cuándo se confirma o revierte, y el código bajo prueba no confirma por su cuenta—. Segunda: recrear la base es caro —un esquema grande, muchas tablas, datos de referencia que cargar—, y quieres pagar ese costo una sola vez (en la fixture de scope módulo o sesión) y revertir barato entre tests. En ese escenario, el rollback es imbatible: pagas el CREATE una vez y cada test cuesta un rollback, que es casi gratis.

El rollback no es la herramienta cuando el código bajo prueba confirma dentro del test —como nuestro servicio— y no quieres o no puedes cambiarlo para que difiera el commit. En ese caso, la base nueva por test de la lección 4 es más simple y más robusta: no depende de que nadie confirme, porque cada test tiene su propia base que se destruye entera al terminar. Muchos equipos, de hecho, usan las dos según el caso: rollback para tests que operan sobre el repositorio o el SQL directamente controlando la transacción, y base nueva para tests que atraviesan servicios que confirman. Saber cuál aplica en cada caso —y por qué— es lo que esta lección te deja.

Errores comunes

Esperar que el rollback deshaga un commit ajeno. Qué pasa: se envuelve el test en una transacción y se hace rollback al final, pero el código bajo prueba hizo commit adentro, y la base queda contaminada. Por qué pasa: uno piensa en "la transacción del test" como si englobara todo, pero un commit intermedio cierra esa transacción y abre otra. Cómo detectarlo: si aíslas con rollback y aun así ves herencia de estado, busca un commit dentro del código que pruebas (en Reservo, el de save). Cómo corregirlo: o difiere ese commit (un repositorio que no confirma y deja el control al llamador), o usa la base nueva por test de la lección 4.

Recrear el esquema en cada test "para asegurar". Qué pasa: alguien pone el CREATE TABLE y la carga de datos de referencia en la fixture de función, corriendo por cada test. Por qué pasa: parece lo más seguro. Cómo detectarlo: si tu suite de integración es lenta y el perfil muestra tiempo en crear tablas y cargar semillas repetidas, estás pagando el costo caro N veces. Cómo corregirlo: pon lo caro y estable (esquema, datos de referencia inmutables) en una fixture de scope módulo o sesión, confírmalo una vez, y usa el rollback por test para lo que cambia. Pagas el CREATE una vez y revierte barato.

Hacer rollback en el cuerpo del test en vez de en el teardown. Qué pasa: se pone conn.rollback() como última línea del test. Por qué pasa: es lo más directo. Cómo detectarlo: si el test falla antes de esa línea, el rollback no corre y el siguiente test hereda la basura —el mismo problema que el DELETE al final de la lección 1—. Cómo corregirlo: pon el rollback después del yield en una fixture, donde pytest lo ejecuta pase lo que pase, incluso si el test falla. El aislamiento vive en el teardown de la fixture, no en el cuerpo del test.

Ejercicios

Ejercicio 1 — Predice con y sin commit. Tienes la fixture conn que hace rollback en el teardown. Un test hace, en este orden: conn.execute("INSERT ...") (sin commit), luego assert count(conn) == 1. El siguiente test hace assert count(conn) == 0. Predice el resultado. Ahora cambia el primer test para que, después del INSERT, haga conn.commit(). Predice de nuevo.

Ver solución

Sin commit: el primer test inserta dentro de su transacción y ve count == 1 (pasa). En su teardown, la fixture hace rollback, que descarta esa inserción no confirmada. El segundo test ve count == 0 (pasa). Los dos verdes: es el caso de la fixture de rollback funcionando, porque nadie confirmó.

Con commit: el primer test inserta y confirma; la fila queda permanente. Ve count == 1 (pasa). En el teardown, la fixture hace rollback, pero la fila ya está confirmada, así que no hay nada que revertir sobre ella: sigue en la tabla. El segundo test ve count == 1, no 0, y assert count(conn) == 0 falla. Es exactamente el caso del caveat: un commit dentro del test derrota al rollback del teardown.

La regla que estás fijando: el rollback deshace solo lo no confirmado. La presencia de un solo commit dentro del alcance del test rompe el aislamiento por rollback. Por eso hay que saber si el código bajo prueba confirma —y el nuestro, vía save, lo hace—.

Ejercicio 2 — Por qué dos fixtures y no una. En la suite de rollback, el esquema se crea en la fixture db (scope módulo) y el rollback está en la fixture conn (scope función). Explica qué se rompería si pusieras todo en una sola fixture de scope función que crea la conexión, el esquema, hace yield y luego rollback. ¿Aislaría igual? ¿Qué costo cambiaría?

Ver solución

Aislaría igual —de hecho, aislaría incluso mejor, porque cada test tendría su propia conexión y su propia base—, pero el rollback sería innecesario: si cada test crea su propia conexión :memory: y su propio esquema, la base entera muere cuando la conexión se cierra al final del test, sin que haga falta revertir nada. Eso es, precisamente, la fixture de base nueva por test de la lección 4, no la técnica de rollback.

Lo que cambia es el costo: crear la conexión y correr el CREATE TABLE (más cualquier carga de datos de referencia) en cada test significa pagar ese costo N veces. La gracia de separar en dos fixtures —db de scope módulo para lo caro y estable, conn de función para el rollback barato— es pagar el CREATE una sola vez y que el aislamiento por test cueste solo un rollback. Con un esquema trivial como el de Reservo, la diferencia es despreciable; con un esquema grande y datos de referencia pesados, es la diferencia entre una suite rápida y una lenta. La técnica de rollback existe justamente para ese caso: cuando recrear es caro y quieres hacerlo una vez.

Ejercicio 3 — Rescata el aislamiento por rollback del caso del servicio. El caveat mostró que integrar a través de book (que hace commit) rompe el rollback. Sin cambiar a la base-nueva-por-test de la lección 4, propón un cambio en el diseño del repositorio que haría que el rollback volviera a aislar, y explica qué costo o riesgo tiene ese cambio.

Ver solución

El cambio es sacar el commit de save y dejar el control de la transacción a quien llama al repositorio. Es decir, save solo hace el INSERT/UPDATE y no confirma; el que decide commit o rollback es el código de nivel superior (en producción, un "unidad de trabajo" o el manejador de la petición; en los tests, la fixture). Con save sin commit, la reserva que crea book viviría dentro de la transacción del test, y el rollback del teardown la descartaría: el aislamiento por rollback volvería a funcionar aunque integres a través del servicio.

El costo y el riesgo: mover el control de transacción hacia afuera es una decisión de diseño real y con consecuencias. En producción, alguien tiene que acordarse de confirmar, y si se olvida, las reservas no persisten —un bug serio—. También cambia el contrato del repositorio: ya no es "guardar y queda permanente" sino "guardar dentro de una transacción que otro confirma". Es un patrón legítimo y común (el "Unit of Work"), pero es una decisión de arquitectura, no un truco de test. Por eso muchos equipos, en vez de reescribir el repositorio para poder aislar con rollback, prefieren la base nueva por test de la lección 4: aísla sin pedirle nada al diseño del código de producción. Las dos son válidas; la elección depende de si el control de transacción externo te sirve también en producción o solo lo harías por los tests.

Resumen y siguiente paso

En esta lección instalaste la primera técnica de aislamiento: el rollback de transacción. Viste el mecanismo con SQL crudo —una fila escrita y revertida, la base a cero sin recrear la tabla (filas antes=1 filas despues=0 tabla_existe=True)— y lo convertiste en aislamiento de una suite con dos fixtures: una de scope módulo que crea el esquema una vez, y una por test que hace rollback en el teardown, dejando cada test con la tabla limpia en cualquier orden. Y aprendiste la precondición dura, demostrada con un fallo real: el rollback solo deshace lo no confirmado, así que un commit dentro del test —como el de save— lo derrota. Con eso sabes cuándo el rollback es la herramienta correcta (tú controlas la transacción, recrear es caro) y cuándo no (el código bajo prueba confirma).

Antes de avanzar deberías poder: explicar por qué el rollback deja la base limpia sin recrearla; escribir la pareja de fixtures módulo/función que aísla con rollback; y predecir por qué un commit intermedio rompe el aislamiento.

Lo que sigue es la otra técnica, la que no tiene la precondición del commit y por eso resuelve el caso que el rollback no pudo. En la lección 4 vas a escribir una fixture con yield que crea una base de datos temporal fresca por test y la destruye en el teardown. Como cada test tiene su propia base, el commit de save ya no contamina a nadie: no hay un vecino que herede, porque la base muere con el test. Vas a ver el yield como la frontera exacta entre setup y teardown, y a entender por qué esta es la herramienta que más vas a usar cuando integres a través de servicios que confirman.

Recursos