Módulo 7: Datos y aislamiento en integración
5. `:memory:` contra archivo temporal
Descripción
La fixture de la lección 4 dejó una decisión abierta que ahora hay que tomar con criterio: qué recurso entrega. Usamos sqlite3.connect(":memory:"), pero la misma fixture podría dar una base en un archivo temporal, con la fixture tmp_path de pytest. No es un detalle cosmético; es una disyuntiva real con consecuencias medibles en velocidad y en qué prueba tu test. Una base :memory: vive en la RAM de tu proceso: es rapidísima, se destruye al cerrar la conexión, y por eso aísla de forma natural —cada conexión, su propia base—. Pero justamente por vivir en RAM, nunca toca disco, así que no puede probar nada que dependa del disco: la persistencia entre procesos, la serialización real a un archivo, el comportamiento con el disco lleno. Una base en archivo temporal vive en el sistema de archivos: es decenas de veces más lenta —cada commit obliga al sistema operativo a escribir en disco— pero prueba la cosa real, el archivo que sobrevive a cerrar el proceso.
La decisión, entonces, es un intercambio explícito entre velocidad y realismo, y la vas a tomar con números medidos en tu propia máquina, no con intuiciones. Vas a cronometrar la misma carga de reservas contra el fake, contra :memory: y contra un archivo en disco, y vas a ver la brecha con tus ojos: la memoria cuesta unas pocas veces lo que el fake; el disco cuesta decenas de veces lo que la memoria. Con esos números, la regla sale sola: usa :memory: por defecto —es rápido y aísla— y paga el disco solo cuando lo que el test quiere probar es precisamente algo del disco. Vas a aprender a usar tmp_path, la fixture de pytest que te da un directorio temporal único por test —con su propia limpieza incluida—, para cuando el archivo real es lo que necesitas.
Conexión con el módulo: la lección 4 te dio el esqueleto —la fixture con yield que crea y destruye—; esta decide el recurso que va adentro, y por qué. Es la misma técnica de aislamiento con dos rellenos distintos, cada uno con su costo y su alcance. La elección conecta con el módulo 5, donde ya viste que :memory: no prueba la persistencia en disco y que para eso hacía falta un archivo real: aquí conviertes esa observación en una decisión sistemática, con la fixture correcta para cada caso y con los números para justificarla. La lección 6 le sumará el sembrado de datos, que funciona igual sobre cualquiera de los dos recursos. Elegir bien el recurso es lo que mantiene tu suite de integración rápida sin renunciar a probar lo que de verdad importa probar.
Analogía: el simulador de vuelo y la pista real
Piensa en cómo se entrena a un piloto. La mayor parte del entrenamiento ocurre en un simulador de vuelo: es barato, se reinicia al instante, permite practicar cien despegues en una tarde, y no hay riesgo. El simulador reproduce fielmente los mandos, los instrumentos, la física del vuelo. Pero hay cosas que un simulador, por definición, no puede probar: el tacto real del avión en una pista con viento cruzado de verdad, el comportamiento del tren de aterrizaje sobre asfalto mojado real, lo que pasa cuando el metal real se estresa. Para eso, en algún momento, el piloto tiene que volar un avión real en una pista real: es caro, lento, arriesgado, y por eso se hace poco y solo cuando se necesita probar lo que el simulador no alcanza.
La base :memory: es el simulador: fiel, rapidísima, reiniciable al instante, perfecta para las cientos de repeticiones del entrenamiento diario —tu suite de integración normal—. El archivo en disco es la pista real: reproduce lo que el simulador no puede —la persistencia física, el archivo que sobrevive al proceso—, a cambio de ser mucho más lento. Un buen programa de entrenamiento no elige uno u otro: usa el simulador para casi todo y la pista real para lo que exige realismo físico. Tu suite hace lo mismo: :memory: para la enorme mayoría de las integraciones —rápidas y aisladas— y archivo temporal para el puñado de tests que de verdad necesitan probar el disco. Elegir siempre la pista real sería absurdamente lento; elegir siempre el simulador dejaría sin probar cosas reales. El criterio es saber qué prueba cada test.
La misma fixture, dos recursos
Primero, veamos que es literalmente la misma fixture con yield, cambiando solo lo que va adentro. La versión :memory: ya la conoces:
@pytest.fixture
def repo():
conn = sqlite3.connect(":memory:") # RAM: rapido, no toca disco
yield SqliteBookingRepository(conn)
conn.close() # cerrar destruye la base
La versión en archivo temporal usa tmp_path, una fixture que pytest te da gratis: un objeto Path que apunta a un directorio temporal único para ese test, que pytest crea antes y limpia después.
# tests/test_temp_file_db.py — la MISMA fixture, con un archivo temporal real
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(tmp_path):
db_file = tmp_path / "reservo.db" # ruta unica por test, la da pytest
conn = sqlite3.connect(db_file) # disco: un archivo real
yield SqliteBookingRepository(conn)
conn.close() # pytest borra tmp_path despues
def make_service(repo):
return BookingService(Calendar(), FixedClock(datetime(2026, 3, 1, 9)),
StubPaymentGateway(True), SpyEmailSender(), repo)
def test_persists_to_a_real_file(repo):
make_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_next_test_gets_its_own_file(repo):
assert len(repo.find_by_room("focus")) == 0 # archivo nuevo, tabla vacia
La fixture ahora pide tmp_path como parámetro y construye la base dentro de ese directorio. Lo elegante es que el aislamiento sigue siendo automático por dos razones que se refuerzan: cada test recibe un tmp_path distinto, así que su archivo reservo.db es único; y pytest borra ese directorio temporal después del test, así que no queda basura en disco. No tienes que crear el archivo con tempfile.mkstemp ni borrarlo con os.remove en un finally —como hacíamos crudamente en el módulo 5—; tmp_path lo hace por ti, con limpieza garantizada.
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_temp_file_db.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 2 items
tests/test_temp_file_db.py::test_persists_to_a_real_file PASSED [ 50%]
tests/test_temp_file_db.py::test_next_test_gets_its_own_file PASSED [100%]
============================== 2 passed in 0.02s ===============================
Los dos verdes, y aislados: el segundo test ve count == 0 porque recibe su propio archivo, distinto del que usó el primero. Fíjate en el tiempo: 0.02s, un pelín más que la versión :memory: (0.01s), porque ahora hay disco de por medio. Con dos tests la diferencia es imperceptible; con cientos, no. Midámosla.
Los números: fake contra memoria contra disco
Intuir "el disco es más lento" no basta para decidir; hay que ver cuánto. Cronometremos la misma carga —seiscientas reservas con su lectura de vuelta— contra los tres recursos: el fake (dict), SQLite en :memory:, y SQLite en un archivo de disco.
# bench_resources.py — cuanto cuesta cada recurso, medido
import sqlite3, tempfile, os, time
from datetime import datetime, timedelta
from reservo.calendar import Calendar
from reservo.doubles import (FakeBookingRepository, 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")
N = 600
def run(repo):
base = datetime(2026, 3, 10, 9)
for i in range(N):
s = base + timedelta(hours=3 * i)
svc = BookingService(Calendar(), FixedClock(datetime(2026, 3, 1, 9)),
StubPaymentGateway(True), SpyEmailSender(), repo)
b = svc.book(FOCUS, ANA, s, s + timedelta(hours=3))
repo.get(b.id)
t = time.perf_counter(); run(FakeBookingRepository()); fake = time.perf_counter() - t
t = time.perf_counter(); run(SqliteBookingRepository(sqlite3.connect(":memory:"))); mem = time.perf_counter() - t
fd, path = tempfile.mkstemp(suffix=".db"); os.close(fd)
try:
t = time.perf_counter(); run(SqliteBookingRepository(sqlite3.connect(path))); disk = time.perf_counter() - t
finally:
os.remove(path)
print(f"fake (dict): {fake*1000:8.1f} ms")
print(f"sqlite :memory: {mem*1000:8.1f} ms ({mem/fake:5.1f}x el fake)")
print(f"sqlite en disco: {disk*1000:8.1f} ms ({disk/mem:5.1f}x la :memory:)")
Qué esperar. En mi máquina (Python 3.14.0, macOS, SSD). Los números exactos varían entre corridas y entre máquinas; lo que importa son las proporciones, que se mantienen:
python3 bench_resources.py
fake (dict): 2.1 ms
sqlite :memory: 6.8 ms ( 3.2x el fake)
sqlite en disco: 217.6 ms ( 32.2x la :memory:)
Lee la escalera. El fake, un dict en RAM, es lo más rápido posible: 2.1 ms para 600 reservas. SQLite en :memory: cuesta unas tres veces más (6.8 ms): sigue siendo RAM, pero ahora hay SQL de verdad —parsear la consulta, tocar índices, serializar el datetime a texto—, y eso tiene un precio pequeño y muy pagable. El salto brutal es el disco: 217.6 ms, más de treinta veces lo que :memory:. Ese factor no es SQL siendo lento; es el disco: cada commit de save fuerza al sistema operativo a garantizar que la escritura llegó al archivo físico (un fsync), y eso es órdenes de magnitud más lento que tocar RAM. Seiscientas reservas, seiscientos commits, seiscientas escrituras a disco esperadas.
La lección de los números: :memory: te da SQLite de verdad —el mismo motor, las mismas reglas de tipos y serialización que probaste en el módulo 5— a un costo cercano al del fake. El archivo en disco te da, además, la persistencia física real, a un costo decenas de veces mayor. Para una suite de cientos de tests de integración, esa diferencia decide si tu suite corre en un segundo o en medio minuto.
La regla: memoria por defecto, disco cuando lo pruebas
Con los números, la decisión es clara y se puede enunciar en una regla:
Usa :memory: por defecto. Es rápido, aísla de forma natural (una base por conexión), y ejecuta SQLite real —el motor que quieres probar en integración—. La enorme mayoría de tus tests de integración de Reservo verifican la costura servicio↔repositorio: que book escribe una fila, que get la lee, que find_by_room filtra. Nada de eso depende del disco; :memory: los prueba igual de bien y muchísimo más rápido.
Paga el disco solo cuando lo que el test prueba es del disco. Hay cosas que :memory: no puede verificar porque, por definición, no toca disco:
- Persistencia entre procesos o conexiones: que la reserva sobreviva a cerrar la conexión que la escribió y reabrir el archivo con otra —la prueba de la carta al buzón del módulo 5—. Eso exige un archivo real.
- Comportamiento de arranque real: crear el archivo si no existe, abrir uno que ya tiene datos, migraciones que corren sobre un archivo con estado.
- Detalles del sistema de archivos: permisos, disco lleno, rutas —casos de borde que solo el archivo real reproduce—.
Para esos, tmp_path es la herramienta: te da el archivo real con la limpieza incluida. Para todo lo demás, :memory:. La proporción sana en una suite de integración suele ser: la mayoría en :memory:, un puñado deliberado en archivo para los tests que específicamente prueban persistencia en disco. Así tu suite es rápida donde puede y realista donde debe.
Un matiz sobre el aislamiento, para no confundirlo con el recurso: ni :memory: ni el archivo aíslan por sí solos; aísla la fixture que da una base nueva por test. Una :memory: compartida entre tests contamina (lección 2); un archivo con nombre fijo compartido, también. tmp_path aísla el archivo porque da una ruta única por test y la borra después, igual que :memory: aísla cuando cada test abre su propia conexión. El recurso lo eliges por velocidad y realismo; el aislamiento lo garantiza el ciclo de vida de la fixture, siempre.
Errores comunes
Creer que :memory: no sirve para integración "porque no es real". Qué pasa: alguien descarta :memory: pensando que solo el disco es "SQLite de verdad". Por qué pasa: "en memoria" suena a simulacro. Cómo detectarlo: :memory: corre el mismo motor de SQLite que el archivo —las mismas reglas de tipos, la misma serialización, el mismo SQL—; lo único que no tiene es el disco físico. La mayoría de las costuras que pruebas no dependen del disco. Cómo corregirlo: usa :memory: para todo lo que no sea específicamente sobre persistencia física; reserva el archivo para eso.
Usar un archivo con nombre fijo (test.db) y creer que aísla. Qué pasa: se hace sqlite3.connect("test.db") en los tests, y el archivo se comparte entre corridas y entre tests. Por qué pasa: es lo más directo de escribir. Cómo detectarlo: el archivo persiste en disco después de la suite, y una segunda corrida encuentra los datos de la primera —fallos misteriosos que dependen de si ya corriste antes—. Cómo corregirlo: usa tmp_path, que da una ruta única por test y la borra; nunca un nombre fijo compartido.
Pagar el disco en toda la suite "por si acaso". Qué pasa: se pone todo en archivos temporales para "probar lo real", y la suite se vuelve decenas de veces más lenta sin ganancia. Por qué pasa: parece más riguroso. Cómo detectarlo: si tu suite de integración tarda mucho y casi ningún test prueba realmente persistencia en disco, estás pagando la pista real para entrenar despegues que el simulador cubría. Cómo corregirlo: :memory: por defecto; archivo solo en los tests que verifican persistencia física. La velocidad de la suite es un recurso; no lo quemes sin ganar realismo a cambio.
Ejercicios
Ejercicio 1 — Clasifica cada test. Para cada uno, di si lo correrías en :memory: o en un archivo temporal, y por qué: (a) book escribe una fila y get la lee de vuelta con price_cents == 6000; (b) una reserva sobrevive a cerrar la conexión que la creó y reabrir el archivo con otra; (c) find_by_room("focus") devuelve solo las reservas de Focus y no las de Studio; (d) el repositorio crea el archivo de base de datos si no existe al arrancar.
Ver solución
- (a)
:memory:. Prueba la costura servicio↔repositorio y la serialización del precio; nada de eso depende del disco.:memory:lo verifica igual y más rápido. - (b) Archivo temporal. Prueba persistencia entre conexiones: que la fila sobreviva a cerrar la conexión que la escribió. Eso es, por definición, imposible en
:memory:—al cerrar la conexión, la base desaparece—. Exige un archivo real (contmp_path). - (c)
:memory:. Prueba el filtrado por sala, lógica de SQL puro sin nada de disco.:memory:. - (d) Archivo temporal. Prueba el comportamiento de arranque sobre el sistema de archivos —crear el archivo si no existe—, que solo tiene sentido con un archivo real.
tmp_path.
La regla que estás aplicando: la pregunta no es "¿es importante el test?" sino "¿lo que prueba depende del disco?". Si no depende del disco (a, c), :memory:; si depende (b, d), archivo. La importancia no decide el recurso; lo decide qué frontera cruza el test.
Ejercicio 2 — Explica el factor 30x. El disco resultó más de treinta veces más lento que :memory: para la misma carga. Explica de dónde sale ese factor —no es que el SQL sea más lento— y qué pasaría con la proporción si save no hiciera commit en cada escritura sino uno solo al final.
Ver solución
El factor no viene del SQL —el motor de SQLite es el mismo en :memory: y en disco, parsea igual, toca los mismos índices—. Viene del disco físico y del commit. Cada commit de save le pide al sistema operativo que garantice que la escritura llegó de verdad al archivo en el disco (un fsync), y esperar a que el disco confirme es órdenes de magnitud más lento que escribir en RAM. Con 600 reservas y un commit por reserva, son 600 esperas al disco; en :memory: no hay ninguna, porque no hay disco que esperar.
Si save no confirmara en cada escritura sino que se hiciera un solo commit al final de las 600, el número de fsync caería de 600 a 1, y el tiempo en disco se desplomaría —se acercaría muchísimo al de :memory:, porque el grueso del costo eran las esperas al disco, no las inserciones—. Esa es, de hecho, una optimización real: agrupar escrituras en una sola transacción para pagar el disco una vez. El precio es que, si algo falla a mitad, pierdes las 600 en vez de tener las primeras guardadas. El punto para esta lección: el costo del disco está dominado por cuántas veces confirmas, no por cuántas filas escribes.
Ejercicio 3 — Convierte la fixture de módulo 5 a tmp_path. En el módulo 5 usábamos tempfile.mkstemp(suffix=".db") con os.close(fd) y un finally con os.remove(path) para probar persistencia en disco. Reescribe esa idea como una fixture de pytest con tmp_path, y explica qué dos responsabilidades te ahorra tmp_path respecto al código crudo.
Ver solución
La fixture con tmp_path:
@pytest.fixture
def db_path(tmp_path):
return tmp_path / "reservo.db" # ruta unica por test; el archivo aun no existe
def test_survives_reopen(db_path):
conn1 = sqlite3.connect(db_path)
# ... reservar con conn1 ...
conn1.close()
conn2 = sqlite3.connect(db_path) # misma ruta, nueva conexion
# ... verificar que la reserva sigue ahi ...
conn2.close()
Las dos responsabilidades que tmp_path te ahorra:
- Crear una ruta única y no colisionar.
tempfile.mkstempte daba un archivo único pero tenías que manejar su descriptor (os.close(fd)) y su nombre.tmp_pathte da un directorio único por test directamente, y tú solo eliges el nombre del archivo dentro; dos tests nunca comparten ruta. - Borrar el archivo al final. Con
mkstemptenías que acordarte delfinally: os.remove(path), y si te olvidabas, dejabas basura en disco.tmp_pathlo borra pytest por ti después del test, sin que escribas limpieza. Ni siquiera necesitas untry/finally: la limpieza del directorio temporal es responsabilidad de pytest, garantizada.
En resumen, tmp_path es la versión "con protocolo de quirófano" de lo que en el módulo 5 hacíamos a mano: mismo archivo real en disco, pero con la unicidad y la limpieza gestionadas por pytest en vez de por tu finally.
Resumen y siguiente paso
En esta lección tomaste la decisión que la fixture de la 4 dejó abierta: qué recurso entrega. Viste que es la misma fixture con yield con dos rellenos —:memory: o un archivo temporal con tmp_path— y mediste la diferencia con números en tu máquina: el fake en 2.1 ms, :memory: unas tres veces más (6.8 ms), y el disco más de treinta veces lo que la memoria (217.6 ms), dominado por los fsync de cada commit. Con eso llegaste a la regla: :memory: por defecto —rápido, aislado, SQLite real—, y archivo temporal solo cuando el test prueba algo del disco: persistencia entre conexiones, arranque sobre el sistema de archivos, casos de borde físicos. Y aprendiste que tmp_path te da el archivo único por test con la limpieza incluida, sin el mkstemp/os.remove crudo del módulo 5.
Antes de avanzar deberías poder: decidir entre :memory: y archivo según si el test depende del disco; explicar de dónde sale el factor de treinta veces (los fsync del commit); y usar tmp_path para un archivo real aislado y auto-limpiado.
Lo que sigue es el otro ingrediente del estado conocido: los datos. Hasta aquí cada test partía de una base vacía, pero muchas integraciones necesitan partir de un estado poblado —"dado que ya hay dos reservas en Focus, cuando consulto..."—. En la lección 6 vas a sembrar datos de integración: crear un estado inicial conocido, mínimo y dicho en voz alta, antes de cada test. Verás cómo hacerlo explícito y por qué eso mantiene los tests legibles, y dónde está la frontera con el patrón Builder a fondo, que es la guía de dobles. El aislamiento del recurso ya lo tienes; ahora le pones los datos justos adentro.
Recursos
- Documentación de pytest — La fixture
tmp_path— la referencia oficial detmp_path: un directorio temporal único por test con limpieza gestionada por pytest, la herramienta para las bases en archivo de esta lección. sqlite3— Conectarse a la base de datos (documentación de Python) — la referencia desqlite3.connect, incluido el nombre especial:memory:para una base en RAM y el paso de una ruta de archivo para una base en disco.- SQLite — In-Memory Databases — la documentación oficial de SQLite sobre las bases
:memory:: qué son, que viven atadas a su conexión y que corren el mismo motor que las de archivo, el fundamento de por qué:memory:es SQLite real. - SQLite — How To Corrupt An SQLite Database File (sección sobre
fsync) — contexto sobre por qué SQLite espera al disco en cadacommit(la garantía de durabilidad), el origen del factor de treinta veces que mediste.