Módulo 5: Rápido y en paralelo — caché y paralelismo
6. El aislamiento: requisito para paralelizar
Descripción
La lección 4 te dio el paralelismo y un speedup de cinco veces; la lección 5 te dio la forma de aplicarlo donde duele. Las dos asumieron, en silencio, algo que ahora toca hacer explícito y demostrar: que los tests se pueden repartir entre workers sin que se estorben. Esa suposición no siempre se cumple. Hay una clase de test que pasa perfecto en serie —uno tras otro, en un solo proceso— y se rompe en cuanto lo repartes, no por azar sino de forma determinista, porque depende de algo que otro test dejó atrás. El aislamiento es la propiedad que garantiza que eso no pase, y es la precondición del paralelismo: sin él, -n auto no acelera, corrompe.
Al terminar vas a entender por qué los tests deben ser independientes para correr en paralelo, y lo vas a ver ejecutado de verdad: un test de Reservo con un Calendar compartido entre tests pasa en serie (3 passed) y falla bajo -n 3 (2 failed, con assert 1 == 2 y assert 1 == 3), porque cada worker es un proceso separado con su propia memoria y el estado global no viaja entre ellos. Vas a entender exactamente por qué falla, y lo vas a arreglar: reemplazar el estado compartido por una fixture que le da a cada test un Calendar fresco, de modo que pase en serie y en paralelo. Y vas a fijar una distinción crítica: este fallo no es un flaky —falla siempre que paralelizas, no a veces—; es un problema de aislamiento, y su remedio es de aquí, no del módulo 7.
Conexión con el módulo: esta es la lección bisagra. La 4 encendió el paralelismo; la 6 explica qué requisito debe cumplir tu suite para que ese paralelismo funcione. Es también la más ligada al concepto de estado compartido y orden que atraviesa toda la testing: un test bien aislado no depende del orden en que corra ni de lo que otro test hizo. Frontera, otra vez, porque es fácil confundirse: el fallo que verás es determinista bajo -n —siempre ocurre—, así que es un defecto de aislamiento (este módulo). Un flaky es un fallo intermitente sin causa aparente —a veces sí, a veces no—, y su tratamiento (retry, cuarentena) es el módulo 7. "Siempre que paralelizo" ≠ "a veces sin razón". No confundas el requisito que aquí aprendes con el problema que allá se trata.
Los cocineros que se pasaban la misma tabla
Imagina dos cocineros que comparten una sola tabla de picar. Trabajando por turnos —uno pica, termina, limpia, y le pasa la tabla al otro—, el sistema funciona: cada uno usa la tabla cuando le toca, encuentra lo que el anterior dejó, y todo fluye. Es lento (van de a uno), pero correcto.
Ahora pon a los dos cocineros a trabajar a la vez, cada uno en su estación, y diles que sigan usando "la tabla". El problema es evidente: solo hay una tabla, y no pueden usarla los dos al mismo tiempo. Si les das una tabla a cada uno para que puedan trabajar en paralelo, aparece un problema más sutil: la receta del segundo cocinero decía "usa la cebolla que ya está picada en la tabla" —dando por hecho que el primero la dejó ahí—, pero ahora el segundo tiene su propia tabla, vacía, sin la cebolla del primero. Su paso falla, no porque cocine mal, sino porque dependía de un estado que otro dejó en un recurso compartido, y al separarlos ese estado ya no está.
Esa es, exacta, la trampa del paralelismo en los tests. Correr en serie es un cocinero usando la tabla por turnos: cada test encuentra lo que el anterior dejó en el estado global, y todo pasa. Correr en paralelo con pytest-xdist es darle a cada worker su propia estación con su propia tabla: cada worker es un proceso de Python separado, con su propia memoria, sus propias variables globales. Un test que decía "usa lo que el test anterior dejó en la variable global" ahora corre en un worker donde esa variable está vacía —el "test anterior" corrió en otro worker, en otra memoria—, y falla. No falla por azar: falla siempre que lo repartes, porque la dependencia que asumía nunca se cumple entre procesos distintos.
Cada worker de pytest-xdist es un proceso separado con su propia memoria. Un test que depende del estado que otro test dejó en una variable global pasa en serie (misma memoria, por turnos) y falla en paralelo (memorias distintas). El aislamiento —que cada test construya su propio mundo— es lo que hace posible repartir.
El anti-patrón: un Calendar compartido
Veámoslo en Reservo, con un ejemplo que es un error real y común. Alguien escribe tres tests para el Calendar, y —para "ahorrarse" crear uno en cada test— usa un solo Calendar a nivel de módulo, compartido por los tres:
# isolation_demo/test_shared_calendar.py
from datetime import datetime, timedelta
from reservo.calendar import Calendar
from reservo.models import Room, Member
from reservo.schedule import book
ROOM = Room(id="r1", name="Focus", capacity=4, hourly_cents=2500)
PRO = Member(id="m2", name="Ben", tier="pro")
DAY = datetime(2026, 8, 1, 9, 0)
# El error: UN calendario reusado por cada test, en vez de uno fresco cada vez.
shared_cal = Calendar()
def test_first_booking_is_recorded():
book(shared_cal, ROOM, PRO, DAY, DAY + timedelta(hours=1), hours=1)
assert len(shared_cal.all_bookings()) == 1
def test_second_booking_is_recorded():
book(shared_cal, ROOM, PRO, DAY + timedelta(hours=1), DAY + timedelta(hours=2), hours=1)
assert len(shared_cal.all_bookings()) == 2
def test_third_booking_is_recorded():
book(shared_cal, ROOM, PRO, DAY + timedelta(hours=2), DAY + timedelta(hours=3), hours=1)
assert len(shared_cal.all_bookings()) == 3
Fíjate en la trampa. shared_cal = Calendar() se crea una vez, al importar el módulo, y los tres tests lo reusan. Cada test agrega una reserva y afirma cuántas hay en total: el primero espera 1, el segundo espera 2, el tercero espera 3. Pero el segundo solo llega a 2 si el primero corrió antes en el mismo proceso y dejó su reserva en shared_cal; el tercero solo llega a 3 si los dos anteriores corrieron antes, ahí mismo. Cada test depende del estado que el anterior dejó en la variable compartida. Es la cebolla que el primer cocinero dejó en la tabla.
En serie funciona, porque pytest corre los tests en orden, uno tras otro, en un solo proceso —una sola tabla, por turnos—:
python -m pytest isolation_demo/test_shared_calendar.py
Qué esperar (salida real):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
plugins: xdist-3.8.0
collected 3 items
isolation_demo/test_shared_calendar.py ... [100%]
============================== 3 passed in 0.01s ===============================
3 passed. Verde. El primero deja una reserva, el segundo la encuentra y agrega la suya (2), el tercero encuentra las dos y agrega la suya (3). Todo cuadra... mientras haya una sola tabla.
El fallo bajo -n: la demo real
Ahora corre exactamente los mismos tres tests, sin cambiar una línea, repartidos entre tres workers con -n 3:
python -m pytest isolation_demo/test_shared_calendar.py -n 3
Qué esperar (salida real):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
plugins: xdist-3.8.0
created: 3/3 workers
3 workers [3 items]
.FF [100%]
=================================== FAILURES ===================================
________________________ test_third_booking_is_recorded ________________________
[gw2] darwin -- Python 3.14.0 /private/tmp/reservo-m5/.venv/bin/python
def test_third_booking_is_recorded():
book(shared_cal, ROOM, PRO, DAY + timedelta(hours=2), DAY + timedelta(hours=3), hours=1)
> assert len(shared_cal.all_bookings()) == 3
E AssertionError: assert 1 == 3
E + where 1 = len([Booking(id='bk-1', ...)])
isolation_demo/test_shared_calendar.py:35: AssertionError
_______________________ test_second_booking_is_recorded ________________________
[gw1] darwin -- Python 3.14.0 /private/tmp/reservo-m5/.venv/bin/python
def test_second_booking_is_recorded():
book(shared_cal, ROOM, PRO, DAY + timedelta(hours=1), DAY + timedelta(hours=2), hours=1)
> assert len(shared_cal.all_bookings()) == 2
E AssertionError: assert 1 == 2
E + where 1 = len([Booking(id='bk-1', ...)])
isolation_demo/test_shared_calendar.py:30: AssertionError
=========================== short test summary info ============================
FAILED isolation_demo/test_shared_calendar.py::test_third_booking_is_recorded - AssertionError: assert 1 == 3
FAILED isolation_demo/test_shared_calendar.py::test_second_booking_is_recorded - AssertionError: assert 1 == 2
========================= 2 failed, 1 passed in 0.28s ==========================
Lee el veredicto: 2 failed, 1 passed. Los mismos tres tests que en serie daban 3 passed ahora se rompen bajo -n 3. Y lee cómo se rompen, porque cuenta toda la historia:
test_second_booking_is_recorded:AssertionError: assert 1 == 2. Esperaba encontrar 2 reservas enshared_cal, pero encontró 1 —la que él mismo agregó—. La reserva del primer test no estaba, porque el primer test corrió en otro worker (gw0), con otra copia deshared_cal.test_third_booking_is_recorded:AssertionError: assert 1 == 3. Esperaba 3, encontró 1 —solo la suya—. Las de los dos anteriores corrieron en otros workers, en otras memorias.
Las etiquetas [gw1] y [gw2] son la pista definitiva: pytest-xdist reparte cada test a un worker distinto (gw0, gw1, gw2 son los "group workers"). Cada worker importó el módulo por su cuenta, así que cada uno ejecutó shared_cal = Calendar() por separado y tiene su propio calendario vacío. Cuando test_second corre en gw1, su shared_cal solo tiene la reserva que él agregó —una—, no la del primer test, que vive en la memoria de gw0. El assert 1 == 2 es el retrato exacto de la cebolla que no está en la tabla del segundo cocinero.
Y algo crucial: esto no es azar. Corre -n 3 diez veces y las diez fallará, porque la dependencia entre tests jamás se cumple cuando cada uno vive en un proceso distinto. Es un fallo determinista del paralelismo, causado por un test mal aislado. No es un flaky.
El arreglo: una fixture que da un mundo fresco
La cura es quitar el estado compartido: que cada test construya su propio Calendar, sin depender de ningún otro. pytest tiene la herramienta exacta, una fixture: una función que prepara algo fresco para cada test que la pida.
# isolation_demo/test_isolated_calendar.py
from datetime import datetime, timedelta
import pytest
from reservo.calendar import Calendar
from reservo.models import Room, Member
from reservo.schedule import book
ROOM = Room(id="r1", name="Focus", capacity=4, hourly_cents=2500)
PRO = Member(id="m2", name="Ben", tier="pro")
DAY = datetime(2026, 8, 1, 9, 0)
@pytest.fixture
def cal():
# Un Calendar nuevo y vacio para CADA test.
return Calendar()
def test_one_booking_is_recorded(cal):
book(cal, ROOM, PRO, DAY, DAY + timedelta(hours=1), hours=1)
assert len(cal.all_bookings()) == 1
def test_two_bookings_are_recorded(cal):
book(cal, ROOM, PRO, DAY, DAY + timedelta(hours=1), hours=1)
book(cal, ROOM, PRO, DAY + timedelta(hours=1), DAY + timedelta(hours=2), hours=1)
assert len(cal.all_bookings()) == 2
def test_three_bookings_are_recorded(cal):
for i in range(3):
book(cal, ROOM, PRO, DAY + timedelta(hours=i), DAY + timedelta(hours=i + 1), hours=1)
assert len(cal.all_bookings()) == 3
Mira las dos diferencias, que son la lección entera. Primera: la fixture cal —marcada con @pytest.fixture— devuelve un Calendar() nuevo cada vez que un test la pide (poniéndola como parámetro: def test_...(cal)). No hay ningún shared_cal a nivel de módulo; cada test recibe su propio calendario, recién creado, vacío. Segunda: cada test construye todo el estado que necesita. El que quiere probar dos reservas hace las dos reservas él mismo, en su propio cal, y afirma 2; no da por hecho que otro test dejó la primera. Cada test es autosuficiente: su propia tabla de picar, su propia cebolla.
En serie pasa, como antes. Y ahora, bajo -n 3:
python -m pytest isolation_demo/test_isolated_calendar.py -n 3
Qué esperar (salida real):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
rootdir: /private/tmp/reservo-m5
configfile: pyproject.toml
plugins: xdist-3.8.0
created: 3/3 workers
3 workers [3 items]
... [100%]
============================== 3 passed in 0.24s ===============================
3 passed, en paralelo. Ahora no importa en qué worker caiga cada test: cada uno crea su propio Calendar fresco y verifica lo que él mismo construyó, sin depender de nadie. Repartirlos entre tres procesos ya no rompe nada, porque no había estado que repartir. Ese es el aislamiento: un test que pasa igual en serie que en paralelo, en cualquier orden, en cualquier worker, porque no comparte tabla con nadie.
Por qué el aislamiento es la precondición, no un lujo
Vale la pena decirlo directo: el aislamiento no es una optimización del paralelismo, es su requisito. No puedes "paralelizar y después, si acaso, aislar". Si tus tests comparten estado, -n auto no los acelera —los corrompe, como acabas de ver—, y la única salida sana es aislarlos primero.
La buena noticia es que el aislamiento es, además, una propiedad que quieres de todos modos, tengas paralelismo o no. Un test que depende de lo que otro dejó es frágil por muchas razones más allá de xdist: se rompe si reordenas los tests, si borras uno del medio, si corres uno solo para depurarlo (y de pronto "falla aislado" porque le faltaba el estado que otro le dejaba). Los tests bien aislados son más robustos, más fáciles de leer (cada uno cuenta su historia completa) y más fáciles de depurar (fallan por su propia causa, no por la de un vecino). El paralelismo simplemente expone sin piedad la falta de aislamiento que ya era un problema latente. -n auto es, entre otras cosas, un excelente detector de tests mal aislados.
Por eso el orden del módulo es este: primero el paralelismo (lección 4), que te da el speedup y, de paso, el detector; luego el aislamiento (esta lección), que es el requisito para cobrar ese speedup sin romper nada. Si -n auto te descubre tests que fallan, no apagues el paralelismo: agradécele el diagnóstico y aísla.
Errores comunes
Culpar a xdist ("el paralelismo es inestable") en vez de al test mal aislado. Qué pasa: alguien enciende -n auto, ve tests rojos que en serie pasaban, y concluye "los tests en paralelo no son confiables, mejor los corro en serie". Por qué pasa: es más fácil culpar a la herramienta nueva que sospechar del código propio. Cómo detectarlo: si el fallo es determinista —ocurre siempre que paralelizas, con assert 1 == 2 o similar por estado que no está—, no es la herramienta, es un test que comparte estado. Cómo corregirlo: aísla el test (fixture con estado fresco por test) en vez de apagar el paralelismo. Volver a serie esconde el problema; no lo arregla, y encima renuncias al speedup.
Confundir este fallo determinista con un flaky. Qué pasa: alguien ve el rojo bajo -n y lo cataloga como flaky —"falla a veces"—, y va a buscar la solución en el retry del módulo 7. Por qué pasa: "falla en unas corridas sí y en otras no" (según si corres en serie o en paralelo) parece intermitencia. Cómo detectarlo: la prueba es si el fallo es reproducible: en serie pasa siempre, en paralelo falla siempre. Eso es determinista, no flaky. Un flaky de verdad falla de forma impredecible con el mismo comando. Cómo corregirlo: trata el fallo bajo -n como un problema de aislamiento (esta lección), no de flakiness (módulo 7); el retry no lo arreglaría, solo lo escondería.
Compartir estado "para ahorrar" y crear dependencias de orden. Qué pasa: alguien usa un objeto a nivel de módulo (un Calendar, una lista, una conexión) reusado por varios tests "para no crearlo cada vez", y sin querer teje una cadena donde cada test depende de lo que el anterior dejó. Por qué pasa: crear estado fresco en cada test parece un desperdicio, y compartir se siente eficiente. Cómo detectarlo: si un test afirma algo sobre un estado que otro test construyó (como assert len == 2 cuando él solo agregó una), tienes una dependencia de orden. Cómo corregirlo: dale a cada test su propio estado con una fixture; el costo de crear un Calendar vacío por test es despreciable, y a cambio ganas tests aislados que corren en cualquier orden y en paralelo.
Ejercicios
Ejercicio 1 — Explica el assert 1 == 2. Bajo -n 3, test_second_booking_is_recorded falló con AssertionError: assert 1 == 2. Explica, en términos de procesos y memoria, por qué encontró 1 reserva en vez de 2, y por qué el mismo test pasaba en serie.
Ver solución
El test esperaba 2 reservas porque asumía que test_first_booking_is_recorded corrió antes y dejó su reserva en shared_cal. Bajo -n 3, cada worker es un proceso de Python separado con su propia memoria. test_second corrió en el worker gw1, que importó el módulo por su cuenta y por tanto ejecutó shared_cal = Calendar() en su memoria, obteniendo un calendario vacío. El primer test corrió en otro worker (gw0), con otra copia de shared_cal, y su reserva quedó en esa memoria —inalcanzable para gw1—. Así que cuando test_second agregó su reserva y contó, encontró 1 (la suya), no 2, y el assert 1 == 2 falló.
En serie pasaba porque los tres tests corrían en el mismo proceso, en orden: una sola copia de shared_cal, compartida por turnos. El primero dejaba su reserva, el segundo la encontraba (2), el tercero encontraba las dos (3). Misma memoria = el estado viaja de un test al siguiente. Procesos distintos = no viaja. Esa es toda la diferencia.
Ejercicio 2 — Aísla el test. Un compañero tiene este test que pasa en serie y falla bajo -n. Reescríbelo con una fixture para que pase en ambos, y explica qué quitaste.
totals = []
def test_january_total():
totals.append(30000)
assert sum(totals) == 30000
def test_year_total():
totals.append(24000)
assert sum(totals) == 54000
Ver solución
El problema es la lista totals a nivel de módulo, compartida: test_year_total espera 54000 solo si test_january_total corrió antes y dejó su 30000 en la lista. Bajo -n, cada worker tiene su propia totals vacía, así que test_year_total ve solo su 24000 y falla (assert 24000 == 54000).
Reescrito, cada test construye su propio estado:
import pytest
@pytest.fixture
def totals():
return [] # una lista nueva y vacia para cada test
def test_january_total(totals):
totals.append(30000)
assert sum(totals) == 30000
def test_year_total(totals):
totals.append(30000) # este test arma los datos que necesita
totals.append(24000)
assert sum(totals) == 54000
Lo que quité: la lista totals global compartida. Lo que puse: una fixture totals que devuelve una lista vacía por test, y —clave— hice que test_year_total construya él mismo los dos totales que quiere sumar, en vez de depender de que otro test dejara el primero. Ahora cada test es autosuficiente: pasa en serie, en paralelo, y en cualquier orden, porque no comparte estado con nadie.
Ejercicio 3 — ¿Aislamiento o flaky? Para cada situación, di si es un problema de aislamiento (esta lección) o un flaky (módulo 7), y justifica con la prueba de reproducibilidad. (a) "Un test pasa en serie siempre y falla bajo -n auto siempre, con assert 0 == 1." (b) "Un test falla una de cada veinte corridas del mismo comando, sin patrón." (c) "Corro un test solo para depurarlo y falla, pero pasa cuando corro la suite entera."
Ver solución
- (a) Aislamiento. La prueba: pasa en serie siempre y falla en paralelo siempre. Es determinista (reproducible al 100% según el modo), así que es un test que comparte estado con otro, y su cura es aislarlo (fixture). No es flaky.
- (b) Flaky (módulo 7). La prueba: falla de forma impredecible con el mismo comando, sin patrón —una de cada veinte—. Eso es intermitencia genuina, la definición de flaky, y su tratamiento (diagnosticar la fuente de no-determinismo, quizá retry o cuarentena) es del módulo 7.
- (c) Aislamiento. La prueba: el resultado depende de qué otros tests corren con él —falla solo, pasa acompañado—. Eso significa que dependía del estado que otro test dejaba; corrido solo, ese estado no está. Es lo mismo que el fallo bajo
-n, visto desde otro ángulo, y la cura es la misma: aislar. (Un test bien aislado pasa igual solo que acompañado.)
La regla que separa las dos categorías: si el fallo es reproducible según una condición clara (serie vs paralelo, solo vs acompañado), es aislamiento —de aquí—. Si es impredecible con el mismo comando, es flaky —del módulo 7—.
Resumen y siguiente paso
En esta lección demostraste, ejecutando de verdad, la precondición del paralelismo: el aislamiento. Lo viste con los dos cocineros y la tabla de picar: por turnos, uno encuentra lo que el otro dejó y todo fluye; en paralelo, con una tabla cada uno, el que dependía de la cebolla del otro se queda sin ella. Un Calendar compartido a nivel de módulo hizo pasar tres tests en serie (3 passed) y los rompió bajo -n 3 (2 failed, assert 1 == 2 y assert 1 == 3), porque cada worker es un proceso con su propia memoria y el estado global no viaja entre procesos —las etiquetas [gw1], [gw2] lo delataron—. Y lo arreglaste: una fixture que da un Calendar fresco por test hizo que los mismos casos pasaran en serie y en paralelo (3 passed con -n 3), porque ya no había estado que repartir.
Fijaste dos ideas que valen para toda tu carrera de testing. Una: el aislamiento no es un lujo del paralelismo, es su requisito —y una propiedad que quieres de todos modos, porque hace los tests robustos, legibles y depurables—. Dos: un fallo determinista bajo -n es un problema de aislamiento (de aquí), no un flaky (módulo 7); la prueba es la reproducibilidad. -n auto es, de yapa, un detector implacable de tests mal aislados: si te descubre rojos, agradécele el diagnóstico y aísla, no apagues el paralelismo.
Antes de avanzar deberías poder: explicar por qué un test con estado compartido pasa en serie y falla en paralelo, en términos de procesos y memoria; reconocer el anti-patrón del objeto compartido a nivel de módulo; arreglarlo con una fixture que da estado fresco por test; y distinguir un fallo de aislamiento (determinista) de un flaky (intermitente).
Lo que sigue, en la lección 7, es ponerle precio a todo este speedup. Paralelizar no es gratis: cada worker consume CPU, y en CI los runners más grandes cuestan más dinero. Vas a ver la curva real del retorno —-n 2, -n 4, -n 8, -n 12, medidos— y cómo se aplana: llega un punto donde sumar workers casi no acelera pero sí encarece. Vas a aprender cuánto paralelizar con cabeza, por qué la caché casi siempre paga mientras el paralelismo se sopesa, y cuándo -n auto es de más. El aislamiento te dio permiso de paralelizar; el trade-off te dice cuánto.
Recursos
- Cómo usar fixtures — documentación de pytest — la referencia oficial de las fixtures, la herramienta con la que le das a cada test su propio estado fresco. La base del arreglo de esta lección.
- Buenas prácticas de aislamiento de tests — pytest-xdist — las limitaciones conocidas de correr en paralelo y por qué el estado compartido rompe, escrito por los autores del plugin. La confirmación oficial de lo que demostramos.
- Acerca de las fixtures — documentación de pytest — la explicación conceptual de por qué las fixtures dan aislamiento y estado fresco por test. Útil para entender el porqué detrás del arreglo, no solo el cómo.