Módulo 7: Datos y aislamiento en integración
8. Mini-proyecto: aísla una suite de integración de Reservo
Descripción
Llegó el momento de juntar las siete lecciones en una sola entrega, sobre un caso completo y realista. Vas a recibir una suite de integración de Reservo que está rota por estado compartido —el pecado que este módulo entero combatió— y tu trabajo es convertirla en una suite aislada y repetible: verde en cualquier orden y cuantas veces la corras. No es un ejercicio de juguete: es exactamente el trabajo que harás cuando heredes una suite de integración flaky de un equipo, o cuando la tuya empiece a fallar de forma intermitente al crecer. El mini-proyecto tiene la forma de una investigación con arreglo: primero reproduces el problema y lo diagnosticas con la firma que ya conoces —cambia el veredicto según el orden—, luego eliges la técnica de aislamiento correcta y la justificas, y por último demuestras que la cura funcionó corriendo la suite en varios órdenes y varias veces. La entrega son las dos versiones —la contaminada en rojo, la aislada en verde— más la justificación de qué técnica elegiste y por qué.
Lo que hace valioso a este cierre es que la suite rota no falla de una forma cualquiera: falla de forma distinta según el orden, que es la manifestación más confusa del estado compartido y la que más horas hace perder cuando no la reconoces. Vas a ver, con salida real, cómo la misma suite da un fallo en el orden de definición y otro fallo diferente en el orden invertido —un test que pasaba ahora falla, y uno que fallaba ahora pasa—. Ese comportamiento, que parece embrujado, tiene una explicación de una línea una vez que lo diagnosticas, y una cura de una fixture. Al terminar, no solo tendrás la suite aislada: tendrás el método completo —reproducir, diagnosticar por la firma, elegir la técnica, verificar en varios órdenes— para arreglar cualquier suite de integración contaminada que te encuentres.
Conexión con el módulo: este mini-proyecto es la síntesis de todo. Usa el diagnóstico de la lección 2 (la firma dependiente del orden), la técnica de la lección 4 (la fixture con yield de base fresca), la decisión de recurso de la lección 5 (:memory:), el criterio de sembrado de la lección 6 (estado explícito) y la verificación de la lección 7 (varios órdenes, varias corridas). Y cierra la parte de integración de la guía dejándote una suite impecable —aislada y repetible— que el módulo 8, el capstone, podrá usar con confianza al unir contrato e integración en una entrega final. Aquí demuestras que sabes hacer que una suite de integración diga la verdad; allá lo combinarás con el contrato para cerrar la guía.
El punto de partida: una suite embrujada
Te entregan esta suite de integración de Reservo. Tres tests, cada uno correcto si lo lees solo, que comparten un repositorio real vivo para toda la suite.
# tests/test_suite_shared.py — ANTES: suite de integracion con un repo 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")
# El pecado: una BD real viva para toda la suite.
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
def test_exactly_one_confirmed_total():
rows = REPO.find_by_room("focus")
assert len(rows) == 1 # asume solo la suya
Los tres tests cargan sobre el mismo REPO de módulo, y como book hace commit, cada reserva que uno crea queda viva para los siguientes. Ninguno limpia. Corrámosla en el orden de definición.
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 3 items
tests/test_suite_shared.py::test_book_creates_one_focus_booking PASSED [ 33%]
tests/test_suite_shared.py::test_focus_is_empty_before_my_booking FAILED [ 66%]
tests/test_suite_shared.py::test_exactly_one_confirmed_total PASSED [100%]
Un fallo: el segundo test, que esperaba la base vacía y heredó la reserva del primero. Hasta aquí, nada nuevo respecto a la lección 1. Lo revelador viene ahora: corramos la misma suite en orden invertido.
python3 -m pytest tests/test_suite_shared.py::test_exactly_one_confirmed_total \
tests/test_suite_shared.py::test_focus_is_empty_before_my_booking \
tests/test_suite_shared.py::test_book_creates_one_focus_booking -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 3 items
tests/test_suite_shared.py::test_exactly_one_confirmed_total FAILED [ 33%]
tests/test_suite_shared.py::test_focus_is_empty_before_my_booking PASSED [ 66%]
tests/test_suite_shared.py::test_book_creates_one_focus_booking FAILED [100%]
Mira bien, porque esto es lo que vuelve loco a quien no lo reconoce. En el orden de definición fallaba uno (el segundo) y pasaban los otros dos. En el orden invertido fallan otros dos distintos (el primero y el tercero) y pasa el que antes fallaba. La misma suite, el mismo código, sin tocar una línea: tres veredictos distintos según el orden. Un test que pasaba ahora falla; uno que fallaba ahora pasa. Si vieras esto sin el diagnóstico de este módulo, sospecharías del código, del entorno, de pytest, de la posición de los planetas —y perderías horas—.
El diagnóstico: la firma no miente
Con lo que sabes, el diagnóstico es inmediato. El síntoma —el veredicto cambia según el orden— es la firma exacta del estado compartido sin aislar de la lección 2. No hay que leer la lógica de los tests buscando un bug; hay que reconocer la firma. La confirmación definitiva es correr cada test solo:
python3 -m pytest tests/test_suite_shared.py::test_exactly_one_confirmed_total -v
# -> PASSED
python3 -m pytest tests/test_suite_shared.py::test_focus_is_empty_before_my_booking -v
# -> PASSED
Cada test, aislado, pasa. Los tres son correctos por separado; solo se rompen cuando comparten el REPO y heredan las reservas de los demás. La causa está en una sola línea —REPO = SqliteBookingRepository(sqlite3.connect(":memory:")) a nivel de módulo, compartido por todos— y el commit de book que hace permanente lo que cada uno escribe. Ni un bug en las aserciones ni un problema de pytest: estado real compartido, exactamente lo que este módulo enseñó a cazar.
Con el problema entendido, la razón de cada veredicto se explica sola. En el orden de definición: test_book_creates_one_focus_booking corre primero sobre la base vacía, reserva una, ve 1, pasa —y deja una reserva—; test_focus_is_empty_before_my_booking corre segundo, espera 0, ve la 1 heredada, falla; test_exactly_one_confirmed_total corre tercero, espera 1... y como el segundo test falló en su primera línea sin llegar a reservar, sigue habiendo exactamente 1, así que pasa por casualidad. En el orden invertido, las reservas se acumulan distinto y los 1 esperados no cuadran, y por eso fallan otros dos. El detalle exacto de los conteos importa menos que la lección: cuando el estado se comparte, el resultado de cada test depende de lo que los anteriores dejaron, y eso cambia con el orden. La cura no es entender cada conteo; es cortar la herencia.
La cura: elige la técnica y justifícala
Tienes dos técnicas de aislamiento del módulo: el rollback de transacción (lección 3) y la fixture de base fresca por test (lección 4). ¿Cuál aplica aquí? La pregunta decisiva es la precondición del rollback: ¿el código bajo prueba hace commit? Esta suite integra a través de service().book(...), y book llama a repo.save, que hace commit. Por lo tanto, el rollback no serviría tal cual: el commit de save volvería permanentes las reservas antes de que cualquier rollback de teardown pudiera revertirlas —lo demostramos en la lección 3—. La técnica correcta es la fixture de base fresca por test: como cada test recibe su propia base :memory: que muere al terminar, el commit deja de importar —confirma sobre una base privada que nadie más verá—.
El recurso: :memory:, porque ninguno de los tres tests prueba persistencia en disco; todos verifican la costura servicio↔repositorio, que :memory: cubre igual y más rápido (lección 5). Aquí está la suite curada:
# tests/test_suite_isolated.py — DESPUES: 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:") # base fresca por test
yield SqliteBookingRepository(conn)
conn.close() # destruida en el teardown, pase lo que pase
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: base nueva
service(repo).book(FOCUS, ANA, datetime(2026, 3, 11, 9), datetime(2026, 3, 11, 12))
assert len(repo.find_by_room("focus")) == 1
def test_exactly_one_confirmed_total(repo):
service(repo).book(FOCUS, ANA, datetime(2026, 3, 12, 9), datetime(2026, 3, 12, 12))
assert len(repo.find_by_room("focus")) == 1 # su propia reserva, nada mas
El cambio es quirúrgico y cabe en tres líneas: se reemplaza el REPO global por una fixture repo que crea una base fresca por test y la cierra en el teardown, y cada test la pide por parámetro. Las aserciones no cambiaron ni una: siguen siendo las mismas afirmaciones razonables de antes. Lo único que cambió es que ahora cada una es verdad, porque cada test ve su propia base limpia. (Nota: test_exactly_one_confirmed_total ahora siembra su propia reserva antes de contar, para que su aserción == 1 afirme algo sobre un estado que él estableció, no sobre lo que heredaba.)
La verificación: verde en cualquier orden, cuantas veces sea
La cura no se declara: se demuestra, con la prueba de la lección 7. Orden de definición:
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 3 items
tests/test_suite_isolated.py::test_book_creates_one_focus_booking PASSED [ 33%]
tests/test_suite_isolated.py::test_focus_is_empty_before_my_booking PASSED [ 66%]
tests/test_suite_isolated.py::test_exactly_one_confirmed_total PASSED [100%]
============================== 3 passed in 0.01s ===============================
Orden invertido:
python3 -m pytest tests/test_suite_isolated.py::test_exactly_one_confirmed_total \
tests/test_suite_isolated.py::test_focus_is_empty_before_my_booking \
tests/test_suite_isolated.py::test_book_creates_one_focus_booking -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 3 items
tests/test_suite_isolated.py::test_exactly_one_confirmed_total PASSED [ 33%]
tests/test_suite_isolated.py::test_focus_is_empty_before_my_booking PASSED [ 66%]
tests/test_suite_isolated.py::test_book_creates_one_focus_booking PASSED [100%]
============================== 3 passed in 0.01s ===============================
Y dos veces seguidas, sin cambiar nada, para la repetibilidad: 3 passed la primera corrida, 3 passed la segunda, idénticas. Compara el antes y el después: la suite compartida daba tres veredictos distintos según el orden; la aislada da el mismo verde en todos los órdenes y todas las corridas. Eso es exactamente lo que perseguía el módulo —independencia y repetibilidad— logrado cambiando tres líneas: el REPO compartido por una fixture de base fresca. El embrujo se rompió al cortar la herencia de estado.
Tu entrega
Reúne lo que hiciste, que es el método completo para arreglar cualquier suite de integración contaminada:
- La suite contaminada en rojo, corrida en dos órdenes, mostrando que el veredicto cambia con el orden —la evidencia de que el problema es estado compartido, no un bug—.
- El diagnóstico: nombra la firma (veredicto dependiente del orden), confirma con la prueba de correr cada test solo (pasan aislados), y señala la causa exacta (el
REPOde módulo compartido más elcommitdesave). - La técnica elegida y su justificación: fixture de base fresca por test, porque el código integra a través de un servicio que hace
commit, lo que descarta el rollback; recurso:memory:, porque ningún test prueba persistencia en disco. - La suite aislada en verde, corrida en orden normal, en orden invertido y dos veces, mostrando el mismo verde siempre —la prueba de independencia y repetibilidad—.
Ese paquete no es solo "arreglé la suite": es la demostración de que dominas el método —reproducir, diagnosticar por la firma, elegir la técnica con criterio, verificar en varios órdenes—, que es lo que de verdad se lleva de este módulo.
Errores comunes
Arreglar los síntomas ajustando las aserciones. Qué pasa: para que la suite compartida pase, alguien cambia assert len == 0 por assert len >= 0 o ajusta los números esperados hasta que "pasa". Por qué pasa: es el arreglo más rápido a la vista. Cómo detectarlo: si tocaste las aserciones en vez del aislamiento, las escondiste; la suite "pasa" pero ya no verifica nada útil, y sigue dependiendo del orden por debajo. Cómo corregirlo: el problema no está en las aserciones —eran correctas—, está en el estado compartido. Arregla el aislamiento (la fixture) y deja las aserciones intactas.
Elegir el rollback sin verificar la precondición. Qué pasa: se aplica la fixture de rollback a esta suite y sigue fallando, porque book hace commit. Por qué pasa: el rollback es elegante y tienta usarlo primero. Cómo detectarlo: si aíslas con rollback y la contaminación persiste, revisa si el código bajo prueba confirma (aquí, vía save). Cómo corregirlo: cuando el código integra a través de un servicio que confirma, la técnica es la base fresca por test; el rollback queda para cuando tú controlas la transacción. Elegir la técnica es parte del trabajo, no un detalle.
Declarar victoria con una sola corrida en un solo orden. Qué pasa: la suite aislada pasa una vez en el orden de siempre y se da por terminada. Por qué pasa: un verde parece suficiente. Cómo detectarlo: no probaste que sea independiente hasta correrla en otro orden; no probaste que sea repetible hasta correrla dos veces. Cómo corregirlo: la verificación de la lección 7 —varios órdenes, varias corridas— es parte de la entrega, no un extra. Un verde en un solo orden no distingue una suite aislada de una que tuvo suerte.
Ejercicios
Ejercicio 1 — Justifica la técnica por escrito. Escribe el párrafo de justificación que acompañaría tu entrega: explica en tres o cuatro frases por qué elegiste la fixture de base fresca y no el rollback para esta suite, apoyándote en la precondición del rollback.
Ver solución
Una justificación posible:
"Elegí la fixture de base fresca por test, no el rollback, porque esta suite integra a través de service().book(...), y book llama internamente a repo.save, que hace commit. El rollback como técnica de aislamiento solo puede deshacer lo que no se ha confirmado; un commit dentro del test vuelve permanentes las reservas antes de que cualquier rollback de teardown pueda revertirlas, así que el rollback no aislaría esta suite —lo demostramos en la lección 3, donde un caso idéntico falló—. La fixture de base fresca, en cambio, le da a cada test su propia base :memory: que se destruye al terminar, de modo que el commit deja de importar: confirma sobre una base privada que muere con el test y no contamina a nadie. Usé :memory: porque ninguno de los tres tests prueba persistencia en disco —todos verifican la costura servicio↔repositorio—, así que gano velocidad sin perder realismo relevante."
Lo esencial de la justificación: nombrar la precondición del rollback (no aísla si el código confirma), constatar que book→save confirma, y concluir que por eso la base fresca es la técnica correcta. Elegir con criterio y poder explicarlo es lo que separa aplicar una receta de entender el problema.
Ejercicio 2 — Un test que sí necesita disco. Te piden agregar a la suite aislada un cuarto test que verifique que una reserva sobrevive a cerrar la conexión y reabrir la base. Explica por qué ese test no puede usar la fixture :memory: tal cual, y escribe la fixture que sí sirve, justificando el cambio de recurso.
Ver solución
Ese test no puede usar :memory: porque prueba precisamente lo que :memory: no tiene: persistencia más allá de la conexión. Una base :memory: vive atada a su conexión; al cerrarla, la base entera desaparece, así que "cerrar y reabrir" no reabre nada —la reserva se fue con la conexión—. Verificar que la reserva sobrevive a cerrar la conexión exige un archivo real en disco, donde la fila persiste aunque la conexión que la escribió se cierre.
La fixture con archivo temporal, usando tmp_path (lección 5):
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "reservo.db" # ruta unica por test, borrada por pytest
def test_booking_survives_reopen(db_path):
conn1 = sqlite3.connect(db_path)
service_with(SqliteBookingRepository(conn1)).book(FOCUS, ANA, START, END)
conn1.close() # se va la conexion que escribio
conn2 = sqlite3.connect(db_path) # nueva conexion al MISMO archivo
repo2 = SqliteBookingRepository(conn2)
assert len(repo2.find_by_room("focus")) == 1 # sobrevivio: estaba en disco
conn2.close()
El cambio de recurso está justificado por lo que el test prueba: la mayoría de los tests usan :memory: (rápido, suficiente), pero este verifica persistencia física, así que paga el disco con tmp_path —que además le da una ruta única por test y la limpia sola, manteniendo el aislamiento—. Es la regla de la lección 5 aplicada: el recurso lo decide qué frontera cruza el test, no un gusto uniforme.
Ejercicio 3 — Blinda la suite contra el orden a futuro. Tu suite aislada pasa en orden normal e invertido. Un compañero quiere garantizar que nunca vuelva a colarse una dependencia de orden, ni siquiera en tests que otros agreguen después. Propón una práctica de proceso (no un cambio en estos tres tests) que lo logre, y explica cómo detectaría una regresión.
Ver solución
La práctica es correr la suite con el orden barajado en cada ejecución de CI, con una herramienta como pytest-randomly, que reordena los tests aleatoriamente en cada corrida (y reporta la semilla usada, para reproducir un fallo). Con eso, cada vez que alguien agrega un test, la suite se corre en un orden distinto; si el test nuevo —o cualquier otro— depende de qué corrió antes, tarde o temprano el barajado lo pondrá en un orden que lo delate, y la corrida fallará.
Cómo detecta una regresión: en una suite bien aislada, barajar no cambia nada —verde en cualquier orden, corrida tras corrida—. El día que alguien introduce una dependencia de orden (un test que comparte estado, un REPO global que se coló de nuevo), el barajado producirá, en alguna corrida, un orden donde ese test falla, y CI se pondrá en rojo con la semilla que reproduce el problema. Así la dependencia de orden se caza la primera vez que aparece, no meses después cuando ya es un flaky consolidado que nadie sabe de dónde salió. Es la versión automática y permanente de la prueba manual —correr en orden invertido— que hiciste en este mini-proyecto: convertir "verde en cualquier orden" en una garantía que CI vigila por ti.
Resumen del mini-proyecto y del módulo
En este mini-proyecto recorriste el método completo para arreglar una suite de integración contaminada. Reprodujiste el problema y viste su cara más confusa —una suite que da tres veredictos distintos según el orden—; lo diagnosticaste por la firma (veredicto dependiente del orden) y lo confirmaste corriendo cada test solo; elegiste la técnica con criterio —la fixture de base fresca, porque el servicio hace commit y eso descarta el rollback— sobre el recurso correcto —:memory:, porque nada prueba disco—; y verificaste la cura corriendo la suite en orden normal, invertido y dos veces, viendo el mismo verde siempre. La entrega es ese arco entero: rojo dependiente del orden → diagnóstico → técnica justificada → verde en cualquier orden.
Y con esto cierra la parte de integración de la guía. Repasa lo que el módulo te dejó: entiendes por qué el estado real compartido contamina (el dict del fake muere con el test, la tabla de SQLite persiste) y reconoces su firma; tienes las dos técnicas de aislamiento —el rollback de transacción, para cuando controlas la transacción y recrear es caro, y la fixture de base fresca con yield, para cuando el código confirma o quieres máxima simplicidad—; sabes elegir el recurso entre :memory: y archivo temporal con números medidos; siembras datos mínimos y explícitos y conoces la frontera con el Builder; y juzgas una suite por dos criterios, independencia y repetibilidad, que verificas corriéndola en varios órdenes y varias veces. Tienes, en resumen, todo lo necesario para escribir pruebas de integración que dicen la verdad porque cada una parte de un estado conocido y no depende de nadie más.
Lo que sigue es el capstone. El módulo 8 une las dos disciplinas de la guía —contrato e integración— en una entrega final: un contrato consumer-driven para BookingRepository verificado contra el FakeBookingRepository y contra el SqliteBookingRepository real, más una prueba de integración de BookingService con el repositorio real, aislada y repetible como aprendiste aquí. Las suites de integración impecables que sabes construir ahora son la mitad de esa entrega; la otra mitad es el contrato de los módulos 3 y 4. El capstone las junta y cierra la guía.
Recursos
- Documentación de pytest — Fixtures con
yield(teardown recomendado) — la referencia de la técnica con la que curaste la suite: la fixture de base fresca por test que aísla incluso cuando el código bajo prueba hacecommit. sqlite3— Control de transacciones (documentación de Python) — la referencia de por qué elcommitdesavedescarta el rollback como técnica para esta suite, la clave de la elección justificada del mini-proyecto.pytest-randomly— el complemento que baraja el orden en cada corrida, la práctica de proceso del ejercicio 3 para blindar la suite contra dependencias de orden futuras.test-doubles-and-test-data-guide— la guía hermana con el patrón Builder para datos de prueba complejos, la frontera de esta guía; y con elFakeBookingRepositoryque el capstone del módulo 8 verificará contra el contrato junto al repositorio real.