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

2. Por qué el estado real compartido contamina

Descripción

La lección anterior te mostró el problema; esta te lo explica hasta el hueso, porque entender la causa con precisión es lo que te deja elegir bien la cura. Vas a ver, en cámara lenta, por qué una base de datos real compartida entre tests los contamina, y por qué el mismo patrón con un fake nunca lo hace. La respuesta no es "SQLite es raro" ni "los fakes son mágicos": es una diferencia concreta y física en dónde vive el estado y cuándo muere. En el fake, el estado es un dict que es atributo de una instancia de Python; nace cuando instancias el repositorio y muere cuando el recolector de basura se lleva la instancia, al terminar el test. En el repositorio real, el estado son filas en una tabla de SQLite dentro de una conexión; nace cuando haces commit y no muere hasta que borras las filas o destruyes la base —desde luego, no muere solo porque un test termine—. Esa asimetría, y solo esa, es la raíz de todo flaky de integración por estado.

Y vas a ponerle nombre a la firma del problema, porque reconocerla en el momento te ahorra horas de depurar el lugar equivocado. Un test contaminado por estado compartido tiene una firma inconfundible: pasa cuando lo corres solo y falla cuando lo corres en la suite, y su veredicto cambia según el orden. Esa firma es exactamente la que el módulo de diagnóstico de fallos te enseñó a leer: no es un bug en la lógica del test —esa está bien—, es una dependencia oculta de lo que otro test dejó. Cuando la veas, no depures el test que falla; sospecha del estado. Esta lección te instala ese reflejo con salida real, para que la próxima vez que una suite de integración se ponga intermitente, sepas exactamente qué mirar.

Conexión con el módulo: la lección 1 te dio el mapa y el destino; esta clava el diagnóstico —por qué contamina y cómo se reconoce— antes de que las lecciones 3 y 4 te den las curas. Es deliberado: una cura que no entiendes se aplica mal. Aquí vas a entender que el problema no es el recurso real en sí —lo real es bueno, es lo que da valor a la integración— sino el recurso real compartido sin aislar; que la solución no es "no uses la base de datos" sino "dale a cada test un estado conocido"; y que la firma dependiente del orden es el hilo que conecta este módulo con el de diagnóstico de fallos. Con esa claridad, el rollback de la lección 3 y la fixture de la 4 dejan de ser recetas y pasan a ser dos formas de lograr lo mismo: cortar la herencia de estado entre tests.

Analogía: la pizarra que nadie borra

Imagina un salón de clases con una sola pizarra, usada por profesores que se turnan a lo largo del día, y una regla no escrita: cada quien borra lo suyo al terminar. Mientras todos cumplen, funciona: cada clase empieza con la pizarra limpia y lo que aparece en ella es de esa clase. Pero un día un profesor se va con prisa y deja sus ecuaciones escritas. El siguiente entra, empieza a explicar geografía, y sus alumnos ven, mezcladas con el mapa, unas ecuaciones que no vienen a cuento. Si un alumno pregunta "¿de dónde salió esto?", el profesor de geografía no sabe: él no lo escribió. El estado —lo que hay en la pizarra— vino de otra clase, y contamina la actual sin que nadie de la actual lo haya puesto.

Nota dos cosas de esta analogía, porque son el corazón de la lección. Primero: el problema no es la pizarra —una pizarra es útil, permanente, y por eso sirve—; el problema es compartirla sin borrarla entre usos. Segundo: el desastre depende del orden. Si el profesor de geografía hubiera entrado antes que el de matemáticas, no habría visto ecuaciones. Su clase salió mal no por lo que él hizo, sino por quién pasó antes por la pizarra. Una hoja de papel nueva para cada clase —el equivalente al dict del fake— nunca tendría este problema, porque cada clase empieza en una hoja en blanco que se tira al final. La base de datos de integración es la pizarra compartida: potente, permanente, y peligrosa si no la borras —o no le das a cada clase su propia hoja— entre un uso y el siguiente.

El ciclo de vida del estado, lado a lado

Pongamos las dos implementaciones una junto a la otra y sigamos, paso a paso, qué le pasa al estado en cada una a lo largo de dos tests.

Con el fake, el estado es un atributo de instancia:

class FakeBookingRepository:
    def __init__(self):
        self._store = {}                  # (1) nace aqui, vacio
    def save(self, booking):
        self._store[booking.id] = booking # (2) crece aqui
    def find_by_room(self, room_id):
        return [b for b in self._store.values() if b.room_id == room_id]

Sigue el rastro cuando dos tests hacen, cada uno, repo = FakeBookingRepository():

  1. Test A llama FakeBookingRepository(). Se crea una instancia, con un _store vacío. Test A guarda una reserva: _store tiene una entrada. Test A termina; la variable repo sale de alcance.
  2. Python recolecta la instancia de A —nadie la referencia—, y con ella se va el _store y su única entrada. El estado murió con el test.
  3. Test B llama FakeBookingRepository(). Se crea una instancia nueva, con un _store nuevo y vacío. Test B no ve nada de A, porque el _store de A ya no existe.

El aislamiento es un efecto secundario gratis de que el estado viva en un objeto de Python cuyo ciclo de vida coincide con el del test. Ahora el repositorio real:

class SqliteBookingRepository:
    def __init__(self, connection):
        self._conn = connection           # (1) la conexion viene de AFUERA
        self._conn.execute(SCHEMA)
    def save(self, booking):
        self._conn.execute("INSERT ... ON CONFLICT ...", (...))
        self._conn.commit()               # (2) la fila queda PERMANENTE en la tabla

La diferencia decisiva está en la línea (1): la conexión viene de afuera, se la pasas al construir el repositorio. El estado no vive en el repositorio; vive en la base de datos al otro lado de esa conexión. Sigue el rastro con dos tests que comparten la misma conexión conn:

  1. Test A usa un repositorio sobre conn. Guarda una reserva; el commit la escribe en la tabla bookings, permanente. Test A termina; su repositorio sale de alcance y Python lo recolecta.
  2. Pero la conexión conn no se recolecta —vive afuera, referenciada por el módulo— y la tabla bookings sigue teniendo la fila de A. El estado sobrevivió al test.
  3. Test B usa otro repositorio, pero sobre la misma conn. Su __init__ hace CREATE TABLE IF NOT EXISTS —que no hace nada, la tabla ya existe— y consulta: ve la reserva de A. Heredó el estado.

La asimetría es total. En el fake, matar el test mata el estado. En el real, matar el test no toca la base de datos; el estado vive mientras la conexión viva, y si compartes la conexión, el estado se comparte. No hay nada místico: es dónde vive el estado y cuándo se libera.

Ejemplo trabajado: la contaminación en cámara lenta

Montemos el caso más pequeño que muestra la contaminación con nitidez y lo diagnostiquemos como lo haría el módulo de diagnóstico de fallos. Dos tests comparten un repositorio real. El primero guarda una reserva de Focus; el segundo asume que Focus arranca sin reservas para él.

# tests/test_shared_repo.py — dos tests, una BD real compartida
import sqlite3
from datetime import datetime
from reservo.models import Booking
from reservo.sqlite_repo import SqliteBookingRepository

# Una sola conexion y un solo repo para TODO el modulo. Estado real vivo.
_conn = sqlite3.connect(":memory:")
REPO = SqliteBookingRepository(_conn)

def make_booking(bid, room_id="focus"):
    return Booking(id=bid, room_id=room_id, member_id="m-ana",
                   start=datetime(2026, 3, 10, 9), end=datetime(2026, 3, 10, 12),
                   status="confirmed", price_cents=6000)

def test_focus_has_one_after_i_save():
    REPO.save(make_booking("bk-1"))
    assert len(REPO.find_by_room("focus")) == 1   # yo guarde una

def test_focus_starts_empty_for_me():
    # Asumo que la sala Focus arranca sin reservas para mi test.
    assert len(REPO.find_by_room("focus")) == 0   # <- hereda bk-1 del test anterior

Corramos la suite completa y leamos el fallo con lupa.

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

python3 -m pytest tests/test_shared_repo.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_repo.py::test_focus_has_one_after_i_save PASSED          [ 50%]
tests/test_shared_repo.py::test_focus_starts_empty_for_me FAILED           [100%]

=================================== FAILURES ===================================
________________________ test_focus_starts_empty_for_me ________________________

    def test_focus_starts_empty_for_me():
        # Asumo que la sala Focus arranca sin reservas para mi test.
>       assert len(REPO.find_by_room("focus")) == 0   # <- hereda bk-1 del test anterior
E       AssertionError: assert 1 == 0
E        +  where 1 = len([Booking(id='bk-1', room_id='focus', member_id='m-ana', start='2026-03-10T09:00:00', end='2026-03-10T12:00:00', status='confirmed', price_cents=6000)])
E        +    where [Booking(id='bk-1', ...)] = find_by_room('focus')

Lee el fallo como un detective, no como quien va a arreglar el test. La aserción que revienta es 1 == 0, y pytest te muestra qué es ese 1: una lista con una reserva de id='bk-1'. Pero mira el cuerpo de test_focus_starts_empty_for_me: nunca guarda una reserva bk-1. La bk-1 la guardó test_focus_has_one_after_i_save, el test de arriba. El fallo aparece en el segundo test, pero el dato que lo causa lo puso el primero. Ese es el sello de la contaminación: el objeto que rompe tu test lleva las huellas de otro.

Ahora la prueba diagnóstica decisiva, la misma que aprendiste en el módulo de diagnóstico de fallos: corre el test que falla, solo.

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

tests/test_shared_repo.py::test_focus_starts_empty_for_me PASSED           [100%]

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

Verde. El mismo test, sin cambiar una línea, pasa cuando corre solo y falla cuando corre después de su vecino. Esa diferencia —solo pasa, en suite falla— es el veredicto: el problema no está en test_focus_starts_empty_for_me, cuya lógica es intachable, sino en el estado que hereda de quien corrió antes. Depurar el segundo test sería perseguir un fantasma; la falla está en que la base de datos es compartida y nadie la limpió entre uno y otro.

La firma: por qué esto es un flaky de manual

Vale la pena nombrar con precisión por qué este es el flaky más común de la integración, porque la firma es lo que te permite reconocerlo rápido.

Un test es flaky cuando su resultado no depende solo de su código, sino de algo externo que varía. La contaminación por estado compartido encaja perfecto: el resultado del segundo test depende de si el primero corrió antes, y eso varía con el orden de ejecución, con qué tests seleccionas, con si alguien agregó un test nuevo que ahora corre primero. Tres síntomas, todos con la misma raíz:

  • Pasa solo, falla en la suite. Corrido en aislamiento no hay quien lo contamine; en la suite, sí. Es la prueba diagnóstica que acabamos de hacer.
  • Cambia con el orden. Como viste en el ejercicio de la lección 1, invertir el orden de dos tests invierte cuál falla. Un resultado que depende del orden es la definición operativa de "no aislado".
  • Se rompe al crecer la suite. El test pasó durante meses. Alguien agrega un test nuevo, que corre antes y deja una reserva, y de golpe tu test —sin que lo tocaras— empieza a fallar. La causa está a metros de distancia, en código que no escribiste.

Cuando veas cualquiera de estos tres en una suite de integración, tu primera hipótesis debe ser estado compartido sin aislar, no un bug en el test que falla. Y tu primer comando, correr ese test solo. Si pasa solo, dejaste de buscar en el lugar equivocado y ganaste las horas que se van depurando lógica que está bien. Esta es la bisagra con el módulo de diagnóstico de fallos: aquellos síntomas que allá aprendiste a leer, aquí tienen, en integración, una causa dominante con nombre propio.

Por qué la cura no es "deja de usar lo real"

Un reflejo tentador, al ver todo esto, es concluir que la integración es un problema y que mejor volver a los fakes. Es el diagnóstico equivocado. El valor de la integración —lo que viste en los módulos 5 y 6— es precisamente que prueba contra lo real, con sus reglas de serialización, tipos y persistencia; renunciar a eso es renunciar a cazar la clase de bugs que ningún fake atrapa. El problema no es lo real; es lo real compartido sin aislar.

La cura, entonces, no es quitar la base de datos, sino darle a cada test un estado conocido: o bien una base nueva por test (la fixture de la lección 4), o bien revertir lo que el test escribió antes de pasar al siguiente (el rollback de la lección 3). Las dos logran lo mismo —cortar la herencia— por caminos distintos, y elegir entre ellas es el tema de las lecciones que siguen. Lo que esta lección te deja firme es el diagnóstico: sabes qué contamina, dónde vive el estado que lo causa, y cómo reconocer la firma. Con eso, las curas dejan de ser recetas de memoria y pasan a ser decisiones con fundamento.

Errores comunes

Depurar el test que falla en vez del que contamina. Qué pasa: el fallo sale en el test B, así que se depura B, cuya lógica está perfecta, durante una hora. Por qué pasa: el error se reporta donde revienta la aserción, no donde se creó el estado culpable. Cómo detectarlo: corre B solo; si pasa, B no tiene la culpa. Mira qué objeto rompe la aserción y de qué test salió (en el ejemplo, la bk-1 que B nunca guardó). Cómo corregirlo: persigue al test que deja el estado, no al que lo encuentra; y mejor aún, aísla, para que ninguno deje nada.

Confundir "en memoria" con "efímero por test". Qué pasa: se usa :memory: y se asume que cada test arranca limpio por ser memoria. Por qué pasa: "memoria" suena a que se borra. Cómo detectarlo: una base :memory: vive lo que vive su conexión; si la conexión es de módulo (como el _conn del ejemplo), la base es una sola para toda la suite y contamina igual que un archivo. Cómo corregirlo: el aislamiento no lo da el tipo de recurso sino el ciclo de vida —una conexión nueva por test, o un rollback entre tests—.

Creer que si pasa en tu máquina, está aislado. Qué pasa: la suite pasa localmente y se asume que está bien. Por qué pasa: localmente los tests suelen correr en el mismo orden (el de definición), y ese orden puede ocultar la contaminación. Cómo detectarlo: corre la suite en otro orden —invierte dos tests, o selecciona un subconjunto— y observa si cambia el veredicto. Cómo corregirlo: la lección 7 hace esto sistemático; por ahora, no confíes en que "pasa" equivale a "aislado" hasta que pase en más de un orden.

Ejercicios

Ejercicio 1 — Lee el fallo como detective. Te muestran este fragmento de un fallo de pytest en una suite de integración:

E       AssertionError: assert 3 == 1
E        +  where 3 = len([Booking(id='bk-seed-1', ...), Booking(id='bk-seed-2', ...), Booking(id='bk-mine', ...)])

El test que falla solo guarda una reserva, bk-mine. Sin más contexto, ¿qué está pasando y cuál es tu primer comando de diagnóstico?

Ver solución

Está pasando contaminación por estado compartido. El test que falla guarda una sola reserva (bk-mine) y espera ver 1, pero find devuelve 3: la suya más bk-seed-1 y bk-seed-2, dos reservas con nombres (bk-seed-...) que sugieren que otro test —o una fixture de sembrado mal aislada— las dejó. El objeto que rompe la aserción lleva huellas ajenas: dos ids que este test nunca creó.

El primer comando de diagnóstico es correr ese test solo: pytest ruta::test_que_falla -v. Si pasa solo, queda confirmado que la causa no es su lógica sino el estado heredado de sus vecinos, y hay que buscar quién deja bk-seed-1 y bk-seed-2 sin limpiarlos —o, mejor, aislar la suite para que nadie herede nada—. Si fallara también solo, entonces sí sería un bug propio del test, y el diagnóstico cambiaría por completo. Esa bifurcación —solo pasa contra solo falla— es el primer nudo que hay que desatar en todo fallo de integración.

Ejercicio 2 — El estado que vive afuera. Explica por qué, en el repositorio real, es la línea self._conn = connection (recibir la conexión de afuera) —y no el commit en sí— la que hace posible la contaminación entre tests. ¿Qué cambiaría si el repositorio creara su propia conexión en __init__?

Ver solución

La contaminación entre tests requiere que el estado sobreviva al test, y eso pasa porque la conexión —donde vive la base de datos— es un objeto externo que el repositorio recibe y no controla. Como la conexión vive afuera (referenciada por el módulo, por una variable global, por una fixture de scope amplio), no se recolecta cuando el repositorio del test muere; sobrevive, con su tabla y sus filas, y el siguiente repositorio construido sobre esa misma conexión las ve. El commit hace las filas permanentes dentro de la base, pero es la conexión compartida la que hace que esa base persista entre tests.

Si el repositorio creara su propia conexión en __init__ —por ejemplo, sqlite3.connect(":memory:") adentro—, cada repositorio tendría su propia base :memory:, atada a su propia conexión, que moriría cuando el repositorio se recolectara. En ese caso, dos tests que crean cada uno su repositorio no compartirían nada, y el aislamiento sería automático, igual que con el fake. Justamente por eso la fixture de la lección 4 le da a cada test una conexión nueva: mueve el ciclo de vida de la base al ciclo de vida del test. La contaminación no es culpa de SQLite; es culpa de compartir la conexión.

Ejercicio 3 — Diseña una prueba de que hay contaminación. Un compañero jura que su suite de integración de 40 tests está bien aislada. Sin leer los 40 tests, propón dos experimentos rápidos que, si la suite estuviera contaminada, lo revelarían, y explica qué esperarías ver en cada caso.

Ver solución

Dos experimentos baratos y contundentes, ambos basados en la firma del problema:

  1. Corre la suite en orden invertido. Si está bien aislada, los 40 tests pasan igual que en orden normal. Si está contaminada, algún test que dependía de que otro corriera antes (o de que no corriera antes) cambiará de veredicto: uno que pasaba fallará, o uno que fallaba pasará. Un solo cambio de resultado al invertir el orden prueba que hay dependencia de estado. (Herramientas como pytest-randomly automatizan esto barajando el orden; a mano, basta con listar algunos tests en orden distinto en la línea de comandos.)

  2. Corre cada test aislado y compáralo con la suite. Selecciona unos cuantos tests y córrelos de a uno (pytest ruta::test_x). Si alguno pasa solo pero fallaba en la suite —o al revés—, tienes contaminación confirmada en ese test. Es la prueba diagnóstica de la lección, aplicada como auditoría.

Lo que ninguno de los dos experimentos necesita es leer la lógica de los 40 tests: la contaminación se detecta por su comportamiento —sensibilidad al orden y al aislamiento—, no por inspección del código. Si la suite sobrevive a los dos experimentos sin cambiar un solo veredicto, hay buena evidencia de que está aislada; si no, sabes exactamente dónde empezar a mirar.

Resumen y siguiente paso

En esta lección diseccionaste por qué el estado real compartido contamina y el fake no. La causa es una asimetría concreta: en el fake, el estado es un dict de una instancia que muere con el test; en el real, el estado son filas en una tabla cuya conexión vive afuera y sobrevive al test. Viste la contaminación en cámara lenta con salida real —un segundo test que hereda la bk-1 que nunca guardó— y aplicaste la prueba diagnóstica decisiva: correr el test solo. Y le pusiste nombre a la firma —pasa solo, falla en la suite, cambia con el orden—, el hilo que conecta este módulo con el diagnóstico de fallos, y entendiste que la cura no es abandonar lo real sino darle a cada test un estado conocido.

Antes de avanzar deberías poder: explicar dónde vive el estado en el fake y en el real y por qué eso decide el aislamiento; leer un fallo de integración e identificar si el objeto que rompe la aserción trae huellas ajenas; y usar la prueba solo-contra-suite para separar un bug de una contaminación.

Lo que sigue es la primera cura. En la lección 3 vas a instalar el rollback de transacción como técnica de aislamiento: cada test corre dentro de una transacción de SQLite que se revierte al final, de modo que lo que el test escribió desaparece y la base queda limpia para el siguiente —sin recrearla—. Verás el mecanismo con SQL crudo, la fixture que hace el rollback en el teardown, y una precondición dura que, si la ignoras, hace que el rollback no aísle nada: que el código bajo prueba no haga commit a mitad de camino. Es la técnica más elegante para cortar la herencia de estado, y la primera de las dos que este módulo te da.

Recursos