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

1. Presentación del módulo: el estado que no se va solo

Descripción

Llegaste al módulo que arregla el problema que los seis anteriores fueron dejando debajo de la alfombra. Aprendiste a probar contra lo real —el contrato que mantiene honesto al doble, la primera integración de BookingService con el SqliteBookingRepository, las fronteras de la base de datos, el archivo y el HTTP—, y en cada paso pasamos por alto algo que ahora hay que mirar de frente: el estado real persiste. Cuando un test unitario usa el FakeBookingRepository, arranca con un dict vacío recién creado, hace lo suyo, y al terminar tira la instancia a la basura; el siguiente test crea otro dict vacío y no se entera de nada de lo anterior. Cada test nace en un mundo limpio, gratis, sin que tengas que pensarlo. Con una base de datos de verdad, eso deja de ser gratis. Una reserva que un test escribe en la tabla bookings de SQLite no se evapora cuando el test termina: sigue en la tabla, esperando. Y el siguiente test, que consulta esa misma tabla, la encuentra ahí, sin haberla pedido, y toma decisiones sobre un estado que no creó.

Ese es el problema entero del módulo, y es más grave de lo que suena. Un test que hereda datos de otro no falla siempre: falla a veces, según qué corrió antes. Pasa cuando lo ejecutas solo, porque no hay nadie que lo contamine, y falla cuando lo ejecutas en la suite, después del test que le dejó una reserva. O peor: pasa hoy y falla mañana porque alguien agregó un test nuevo que corre antes. Es el flaky que el módulo de diagnóstico de fallos te enseñó a temer, y su causa número uno en integración tiene nombre y apellido: estado real compartido sin aislar. Este módulo te da las dos herramientas para cerrarlo de raíz —el rollback de transacción y la fixture de recurso real— y el criterio para que cada prueba de integración parta de un estado conocido y termine sin dejar rastro, en cualquier orden y cuantas veces la corras.

Conexión con el módulo: esta lección es el mapa del territorio. Aquí ves el problema con tus propios ojos —una suite de integración que cambia de color según el orden— y el destino —la misma suite, aislada, verde siempre—, para saber hacia dónde vamos antes de estudiar las técnicas. La lección 2 disecciona por qué el estado real contamina y el fake no; la 3 instala el rollback de transacción; la 4, la fixture con yield; la 5 elige entre :memory: y archivo con números medidos; la 6 siembra datos con criterio; la 7 nombra los dos principios —independiente y repetible— y los demuestra; y la 8 es el mini-proyecto que junta todo. La frontera dura: el patrón Builder para fabricar datos de prueba complejos es la guía de dobles (test-doubles-and-test-data-guide), no esta; aquí el foco es el aislamiento del recurso real, no la fábrica de datos. Y el capstone que une contrato e integración es el módulo 8.

Analogía: el laboratorio y la mesa de trabajo compartida

Piensa en dos formas de trabajar en un laboratorio de química. En la primera, cada estudiante recibe un juego de material desechable: un vaso de precipitados nuevo, una pipeta nueva, un mechero limpio. Hace su experimento, anota el resultado, y tira todo. El siguiente estudiante recibe otro juego nuevo. Nadie hereda residuos de nadie; si tu reacción salió azul, salió azul por lo que pusiste, no por una gota que quedó del experimento anterior. Ese es el test unitario con dobles: material fresco y desechable en cada corrida, sin memoria entre una y otra.

En la segunda forma, todos comparten una sola mesa de trabajo con un solo juego de material, y nadie lo limpia entre experimentos. El primer estudiante hace su reacción y deja el vaso con un residuo. El segundo llega, no lo nota, vierte sus reactivos encima, y obtiene un color que no corresponde ni a su experimento ni al anterior: es la mezcla de los dos. ¿De quién es la culpa del resultado raro? De nadie en particular; es del residuo compartido. Y lo peor: si cambias el orden en que los estudiantes pasan por la mesa, los resultados cambian, porque cada uno hereda lo que dejó el de antes. Esa mesa sucia y compartida es tu base de datos de integración sin aislar. Este módulo es aprender a limpiar la mesa entre experimentos —o a darle a cada uno su propia mesa— para que el resultado de cada test dependa solo de lo que ese test hizo, y de nada más.

El problema, con las piezas de siempre

Recordemos las dos caras del repositorio, porque la diferencia entre ellas es el problema. El fake, que ya conoces, guarda las reservas en un dict que vive dentro de la instancia:

# reservo/doubles.py — el fake nace vacio en cada test
class FakeBookingRepository:
    def __init__(self):
        self._store = {}                  # dict nuevo cada vez que se instancia

    def save(self, booking):
        self._store[booking.id] = booking

    def find_by_room(self, room_id):
        return [b for b in self._store.values() if b.room_id == room_id]

Cuando un test hace FakeBookingRepository(), obtiene un dict vacío. Cuando el test termina y la variable sale de alcance, Python recolecta la instancia y el dict con ella. El siguiente test hace otro FakeBookingRepository() y arranca de cero. El aislamiento es automático porque el estado vive en un objeto de Python que muere con el test.

El repositorio real no funciona así. Su estado no vive en un objeto de Python que Python recolecta: vive en una tabla de SQLite, en una conexión que —si la compartes entre tests— sigue abierta y sigue teniendo todas las filas que cualquier test le escribió.

# reservo/sqlite_repo.py — el estado vive en la tabla, no muere con el test
class SqliteBookingRepository:
    def __init__(self, connection):
        self._conn = connection
        self._conn.execute(SCHEMA)         # CREATE TABLE IF NOT EXISTS bookings ...

    def save(self, booking):
        self._conn.execute("INSERT INTO bookings ... ON CONFLICT(id) DO UPDATE ...", (...))
        self._conn.commit()                # la fila queda PERMANENTE

    def find_by_room(self, room_id):
        rows = self._conn.execute(
            "SELECT ... FROM bookings WHERE room_id = ?", (room_id,)).fetchall()
        return [Booking(*r) for r in rows]

Fíjate en save: termina con commit(), que hace la fila permanente. Si dos tests comparten la misma conexión, la reserva que el primero guarda está en la tabla cuando el segundo consulta. El estado no se fue solo, porque no hay ninguna instancia de Python cuya muerte lo borre: está en la base de datos, que es justo lo que quisiéramos en producción y lo que nos arruina la vida en los tests si no lo aislamos.

Ejemplo trabajado: la brecha, de un vistazo

Veamos el problema y su solución antes de estudiar ninguna técnica, para tener el destino claro. Primero, el pecado: una suite de integración donde tres tests comparten un solo repositorio real, vivo para toda la suite. Cada test asume, razonablemente, que la sala Focus arranca sin reservas para él. Pero comparten la tabla, así que se pisan.

# tests/test_suite_shared.py — el pecado: un repositorio real COMPARTIDO
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("focus", "Focus", 4, 2500)
ANA = Member("m-ana", "Ana", "pro")

# Una BD real viva para TODA la suite. Nadie la limpia entre tests.
REPO = SqliteBookingRepository(sqlite3.connect(":memory:"))

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

def test_book_creates_one_focus_booking():
    service().book(FOCUS, ANA, datetime(2026, 3, 10, 9), datetime(2026, 3, 10, 12))
    assert len(REPO.find_by_room("focus")) == 1

def test_focus_is_empty_before_my_booking():
    assert len(REPO.find_by_room("focus")) == 0     # asume la BD limpia
    service().book(FOCUS, ANA, datetime(2026, 3, 11, 9), datetime(2026, 3, 11, 12))
    assert len(REPO.find_by_room("focus")) == 1

Cada test, leído solo, es correcto. El primero reserva una vez y espera ver una reserva; el segundo espera que Focus arranque vacío para él y luego reserva. El problema no está en ningún test: está en que comparten REPO, y el commit de book deja la reserva del primero viva cuando el segundo consulta.

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

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

tests/test_suite_shared.py::test_book_creates_one_focus_booking PASSED    [ 50%]
tests/test_suite_shared.py::test_focus_is_empty_before_my_booking FAILED  [100%]

=================================== FAILURES ===================================
_______________________ test_focus_is_empty_before_my_booking _______________________

    def test_focus_is_empty_before_my_booking():
>       assert len(REPO.find_by_room("focus")) == 0     # asume la BD limpia
E       AssertionError: assert 1 == 0
E        +  where 1 = len([Booking(id='bk-m-ana-...', room_id='focus', ...)])

El segundo test falla en su primera línea: esperaba 0 reservas en Focus y encontró 1 —la que el primer test dejó—. No falló por un bug en su lógica; falló porque heredó una reserva que no creó. Y la prueba de que la culpa es del estado compartido y no del test: si corres ese mismo test solo, pasa.

python3 -m pytest tests/test_suite_shared.py::test_focus_is_empty_before_my_booking -v
tests/test_suite_shared.py::test_focus_is_empty_before_my_booking PASSED  [100%]

El mismo test, dos veredictos. Verde solo, rojo en la suite. Eso es un flaky de manual, y su causa es el estado real que no se aisló.

Ahora el destino. La misma suite, con un cambio: en vez de un REPO compartido, una fixture que le da a cada test una base de datos nueva y la destruye al terminar. Nada más cambia en las aserciones.

# tests/test_suite_isolated.py — la misma suite, AISLADA con una fixture
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:")     # BD nueva y vacia por test
    yield SqliteBookingRepository(conn)
    conn.close()                           # destruida al terminar el test

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

def test_book_creates_one_focus_booking(repo):
    service(repo).book(FOCUS, ANA, datetime(2026, 3, 10, 9), datetime(2026, 3, 10, 12))
    assert len(repo.find_by_room("focus")) == 1

def test_focus_is_empty_before_my_booking(repo):
    assert len(repo.find_by_room("focus")) == 0     # ahora ES verdad
    service(repo).book(FOCUS, ANA, datetime(2026, 3, 11, 9), datetime(2026, 3, 11, 12))
    assert len(repo.find_by_room("focus")) == 1

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

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

tests/test_suite_isolated.py::test_book_creates_one_focus_booking PASSED  [ 50%]
tests/test_suite_isolated.py::test_focus_is_empty_before_my_booking PASSED [100%]

============================== 2 passed in 0.01s ===============================

Verde, las dos. La única diferencia con la suite rota es que cada test recibe su propia base de datos, fresca, por medio de la fixture repo, y esa base muere cuando el test termina. La aserción find_by_room("focus") == 0 ahora es verdad porque la tabla que ve ese test de verdad está vacía: no es la tabla del test anterior, es una nueva. Ese es el módulo entero, resumido en dos suites: pasar de una mesa de trabajo compartida y sucia a una mesa limpia por experimento. Todo lo que sigue es entender por qué el estado real contamina, cuáles son las técnicas para aislarlo, y cómo elegir entre ellas.

El mapa del módulo: las ocho lecciones

Vale la pena ver el recorrido, porque cada lección instala una pieza del aislamiento.

LecciónTemaLa idea en una frase
1El estado que no se va solo (esta)El fake nace vacío en cada test; la base de datos real persiste y contamina
2Por qué el estado real contaminaEl dict del fake muere con el test; la tabla de SQLite sobrevive, y el resultado depende del orden
3El rollback de transacciónCada test corre en una transacción que se revierte al final; la BD queda limpia sin recrearla
4Fixtures de BD temporal con yieldUna fixture crea una base fresca por test y la destruye en el teardown
5:memory: contra archivo temporalMemoria rápida y aislada por conexión, o archivo real y persistente pero decenas de veces más lento
6Sembrar datos de integraciónUn estado inicial conocido, mínimo y dicho en voz alta, antes de cada test
7Independientes y repetiblesCada test parte de un estado conocido y da el mismo resultado en cualquier orden
8Mini-proyectoToma una suite contaminada y hazla aislada y repetible en cualquier orden

Si entiendes por qué el estado real contamina y dominas las dos técnicas de aislamiento —rollback y fixture—, sabes elegir el recurso y sembrar lo justo, tienes resuelto el problema que más flaky genera en integración. Las lecciones 3 a 6 son las herramientas; la 7 el principio que las justifica; la 8, la práctica que las junta.

Lo que este módulo NO toca (la frontera)

Conviene marcar los límites, porque hay temas vecinos que parecen de aquí.

El patrón Builder para fabricar datos es la guía de dobles. Aquí vas a sembrar datos de prueba, pero de forma mínima y explícita: unas pocas reservas conocidas escritas a mano. Cuando los datos de prueba se vuelven complejos —muchos campos, muchas variantes, fábricas con valores por defecto y sobrescrituras—, el patrón que lo resuelve con elegancia es el Builder, y ese es el tema del módulo 7 de test-doubles-and-test-data-guide, no de este. En esta guía, los datos son el medio; el fin es el aislamiento del recurso real. Cuando el sembrado empiece a pedir una fábrica de verdad, te enlazamos allá.

El capstone es el módulo 8. Este módulo cierra la técnica de integración —datos y aislamiento— pero no junta todavía las dos disciplinas de la guía. El módulo 8 es el proyecto integrador: un contrato consumer-driven verificado contra el fake y el real, más una prueba de integración de BookingService con el repositorio real, entregados juntos. Aquí dejamos las pruebas de integración impecables —aisladas, repetibles— para que el capstone las use con confianza.

El framework web no es de esta guía. Reservo se integra en proceso: BookingService más un SqliteBookingRepository real. Aislar los datos de una app web completa —con su base de datos, sus migraciones, sus fixtures de framework— es testing-backend-applications-guide. Los principios de aislamiento que aprendes aquí se trasladan, pero las herramientas específicas de un framework las verás allá.

Errores comunes

Creer que "usé SQLite en memoria" ya aísla. Qué pasa: alguien usa sqlite3.connect(":memory:") y concluye que, por ser en memoria, cada test arranca limpio. Por qué pasa: "en memoria" suena a efímero. Cómo detectarlo: una base :memory: vive mientras la conexión viva; si compartes la conexión entre tests —como el REPO del ejemplo—, la base es la misma para todos y el estado se hereda igual que en disco. Cómo corregirlo: lo que aísla no es :memory: contra archivo, sino una base nueva por test (una conexión nueva por test, o un rollback entre tests). El recurso lo eliges por velocidad y por si quieres tocar disco; el aislamiento es una decisión aparte, la de las lecciones 3 y 4.

Confundir un bug con un problema de aislamiento (y al revés). Qué pasa: un test de integración falla en la suite, y alguien empieza a depurar la lógica del test, que está perfecta. Por qué pasa: el fallo aparece dentro del test, así que parece suyo. Cómo detectarlo: corre el test solo. Si pasa solo y falla en la suite, no es un bug del test: es contaminación de estado. Si falla en las dos, es un bug de verdad. Cómo corregirlo: esa prueba —solo contra suite— es el primer diagnóstico de todo flaky de integración; la aprendiste en el módulo de diagnóstico de fallos y es el reflejo que este módulo te pide instalar.

Limpiar "a mano" con DELETE al final de cada test y confiar en que basta. Qué pasa: alguien pone conn.execute("DELETE FROM bookings") al final de cada test para limpiar. Por qué pasa: es intuitivo y a veces funciona. Cómo detectarlo: si un test falla a mitad de camino, la línea de DELETE del final no se ejecuta, y el siguiente test hereda la basura igual —el teardown manual no corre si el test revienta antes—. Cómo corregirlo: el aislamiento tiene que correr pase lo que pase, y para eso están las fixtures con yield (lección 4) y el rollback en el teardown (lección 3), que pytest ejecuta aunque el test falle. La limpieza no puede depender de que el test llegue vivo al final.

Ejercicios

Ejercicio 1 — ¿Por qué el fake nunca sufre esto? Explica, en tus palabras, por qué una suite de tests unitarios que usa FakeBookingRepository() nunca tiene el problema de contaminación que acabamos de ver, aunque tenga cientos de tests que guardan reservas. Sé preciso sobre dónde vive el estado en cada caso.

Ver solución

Porque el estado del fake vive en un dict que es un atributo de la instancia del repositorio, y cada test crea su propia instancia con FakeBookingRepository(). Cuando el test termina, la variable que apuntaba a esa instancia sale de alcance, Python recolecta el objeto, y el dict —con todas las reservas que el test guardó— se va con él. El siguiente test crea otra instancia, con otro dict vacío, que no comparte nada con la anterior. El aislamiento es automático porque el ciclo de vida del estado está atado al ciclo de vida de un objeto de Python, y ese objeto muere con el test.

El repositorio real rompe justo esa cadena: su estado no vive en un objeto de Python que muere con el test, sino en una tabla de SQLite dentro de una conexión. Si compartes la conexión entre tests, la tabla —y sus filas— sobrevive a cada test, porque nada la borra: el commit de save la hizo permanente. Por eso el aislamiento, que en el fake era gratis, en la integración hay que provocarlo a propósito. Ese "a propósito" es el módulo entero.

Ejercicio 2 — Predice el efecto del orden. En la suite compartida del ejemplo, invierte el orden de los dos tests: pon test_focus_is_empty_before_my_booking primero y test_book_creates_one_focus_booking segundo. Sin correr nada, predice cuál pasa y cuál falla, y por qué.

Ver solución

Con el orden invertido, test_focus_is_empty_before_my_booking corre primero, cuando la tabla de verdad está vacía (nadie escribió aún). Su primera línea, assert len(REPO.find_by_room("focus")) == 0, ahora es verdad, así que pasa: reserva una y termina, dejando una reserva de Focus en la tabla compartida.

Luego corre test_book_creates_one_focus_booking, que reserva otra vez y espera len(...) == 1. Pero la tabla ya tenía la reserva que el primer test dejó, así que ahora hay dos, y assert len(...) == 1 falla con assert 2 == 1.

O sea: invirtiendo el orden, el test que fallaba pasa y el que pasaba falla. El mismo conjunto de tests, el mismo código, resultados opuestos según el orden. Esa dependencia del orden es la firma inconfundible del estado compartido sin aislar, y es exactamente lo que la lección 7 elimina: una suite bien aislada da el mismo veredicto en cualquier orden.

Ejercicio 3 — El DELETE que no salvó. Un compañero, para arreglar la suite compartida, agrega al final de cada test la línea REPO._conn.execute("DELETE FROM bookings"); REPO._conn.commit(). La suite pasa. Explica en qué situación concreta esta solución dejaría de funcionar y por qué las fixtures con yield no tienen ese problema.

Ver solución

La solución del DELETE al final funciona mientras cada test llegue vivo hasta esa última línea. El problema aparece cuando un test falla antes de llegar al DELETE: una aserción intermedia revienta, la ejecución del test se corta ahí, y la línea de limpieza del final nunca corre. La tabla queda con las filas de ese test, y el siguiente las hereda —justo el problema que se quería evitar—. Así, un solo test que falla a la mitad reintroduce la contaminación para todos los que siguen, y encima de forma intermitente y confusa.

Las fixtures con yield no tienen ese problema porque pytest ejecuta el teardown —lo que va después del yieldaunque el test falle. La limpieza no es una línea más del cuerpo del test que puede saltarse; es una responsabilidad de la fixture que pytest garantiza pase lo que pase. Lo verás en detalle en la lección 4; la idea clave es que el aislamiento no puede depender de que el test termine bien, porque los tests, a veces, terminan mal —y es justo cuando fallan que más necesitas que la limpieza corra igual—.

Resumen y siguiente paso

En esta lección viste el problema que da nombre al módulo: el estado real persiste entre tests y los contamina, algo que el test unitario con dobles nunca sufre porque el dict del fake muere con el test, mientras que la tabla de SQLite sobrevive. Con la mesa de trabajo compartida entendiste que el resultado de un test contaminado no depende de su lógica sino de qué corrió antes, y lo confirmaste con salida real: una suite compartida donde un test pasa solo y falla en la suite, y la misma suite aislada con una fixture, verde. Tienes el mapa de las ocho lecciones y la frontera con el patrón Builder (guía de dobles) y el capstone (módulo 8).

Antes de avanzar deberías poder: explicar dónde vive el estado en el fake y en el repositorio real, y por qué eso hace que uno se aísle solo y el otro no; reconocer el síntoma del flaky de integración —pasa solo, falla en la suite, cambia con el orden—; y descartar el DELETE al final como aislamiento confiable.

Lo que sigue es entender el mecanismo a fondo. En la lección 2 vamos a diseccionar por qué el estado real compartido contamina —el ciclo de vida del dict contra el de la tabla—, a ver la contaminación en cámara lenta con salida real, y a conectarla explícitamente con el diagnóstico de fallos: por qué este es el flaky más común de la integración y cómo su firma —dependiente del orden— lo delata. Entender la causa con precisión es lo que te deja elegir la técnica de aislamiento correcta en las lecciones que vienen.

Recursos