Módulo 6: Fronteras reales: DB, archivos, HTTP

4. `:memory:` contra un archivo

Descripción

La lección 3 te dio la transacción; queda la otra gran decisión de la frontera de base de datos, y es una que tomas en cada test sin pensarlo: dónde vive la base. Cuando escribes sqlite3.connect(":memory:"), SQLite crea una base de datos completa que vive en la RAM de tu proceso y muere cuando cierras la conexión. Cuando escribes sqlite3.connect("reservo.db") o sqlite3.connect(path), SQLite crea —o abre— un archivo en el disco que persiste entre conexiones y entre corridas del programa. Las dos son SQLite de verdad: el mismo motor, el mismo SQL, las mismas transacciones, la misma serialización del datetime a texto. La diferencia no está en las reglas del recurso —son idénticas—, sino en dos ejes: la persistencia (la de memoria no sobrevive a cerrar la conexión; la de archivo sí) y la velocidad (la de memoria es instantánea; la de archivo paga el precio del disco en cada commit).

Elegir bien entre las dos es una de esas decisiones pequeñas que definen si tu suite de integración es rápida y limpia o lenta y frágil. La regla, que esta lección justifica con salida real, es simple: usa :memory: por defecto —es SQLite real, ejerce toda la frontera (serialización, transacciones, tipos), y es más de diez veces más rápida y perfectamente aislada porque cada conexión nace con una base vacía—; usa un archivo solo cuando lo que el test necesita probar es específicamente la persistencia en disco —que la reserva sobreviva a cerrar la conexión, a reabrir, a reiniciar—. La mayoría de tus integraciones prueban la costura servicio↔base de datos (serialización, transacción, lógica), y para eso :memory: es ideal; una minoría prueba que el dato dura, y para eso necesitas el archivo.

Conexión con el módulo: esta lección cierra la frontera de base de datos que la 3 abrió. La 3 te dio la transacción (commit/rollback, la visibilidad); esta te da la elección del recurso (:memory: contra archivo). Juntas te dan el dominio completo de SQLite como frontera, y preparan dos lecciones posteriores: la 5 usa un archivo real (con tmp_path) para la frontera de archivos, con la misma idea de persistencia; y la 7 vuelve sobre :memory: como la herramienta número uno para hacer las pruebas de base de datos rápidas y deterministas, y sobre el archivo temporal como el recurso que hay que crear y limpiar. La frontera con el módulo 7 se respeta: aquí eliges el recurso según lo que pruebas; cómo aislar sistemáticamente el estado de un archivo compartido es allá.

Analogía: la pizarra y el cuaderno

Piensa en dos formas de anotar algo mientras trabajas. La primera es una pizarra: escribes rápido, la lees, la borras, y cuando sales de la sala y alguien la limpia, no queda nada —cada vez que entras, la pizarra está en blanco, lista para ti, sin rastro de lo anterior—. La segunda es un cuaderno: escribes con tinta, cierras el cuaderno, lo guardas en un cajón, y mañana lo abres y todo sigue ahí; puedes prestárselo a un compañero y él lee lo mismo que tú escribiste. La pizarra es instantánea y siempre limpia, pero efímera: lo que anotas muere con la sesión. El cuaderno persiste y se comparte, pero es más lento de manejar y hay que cuidarlo —si no lo borras, la nota de ayer se mezcla con la de hoy—.

sqlite3.connect(":memory:") es la pizarra: cada conexión nace con una base en blanco, escribes y lees a toda velocidad en la RAM, y cuando cierras la conexión todo se borra sin dejar rastro —lo que la hace, de regalo, perfectamente aislada entre tests—. sqlite3.connect(path) es el cuaderno: escribes al disco con tinta, la reserva sigue ahí cuando cierras y reabres, y otra conexión al mismo archivo lee lo que escribiste —pero cada escritura cuesta el precio del disco, y si no limpias el archivo, los datos de un test se cuelan en el siguiente—. Ni la pizarra es mejor que el cuaderno ni al revés: eliges según lo que necesitas. ¿Solo hacer una cuenta rápida y tirarla? Pizarra. ¿Anotar algo que debe durar y que otro leerá? Cuaderno. Esta lección es aprender cuándo cada una, con Reservo y con números.

La pizarra no comparte: :memory: entre conexiones

Empecemos por la propiedad que más confunde: una base :memory: es privada de su conexión. Dos sqlite3.connect(":memory:") distintos son dos bases distintas, cada una vacía, que no se ven entre sí —como dos pizarras en dos salas—. Reservar en una no aparece en la otra.

# tests/test_memory_vs_file.py — :memory: no persiste entre conexiones
import sqlite3
import pytest
from reservo.sqlite_repo import SqliteBookingRepository
# ...imports de Reservo y constantes FOCUS, ANA, START, END, CLOCK arriba...


def make_service(repo):
    return BookingService(Calendar(), FixedClock(CLOCK),
                          StubPaymentGateway(ok=True), SpyEmailSender(), repo)


def test_memory_does_not_persist_across_connections():
    # Cada connect(":memory:") es una base NUEVA y vacia.
    repo1 = SqliteBookingRepository(sqlite3.connect(":memory:"))
    booking = make_service(repo1).book(FOCUS, ANA, START, END)

    repo2 = SqliteBookingRepository(sqlite3.connect(":memory:"))
    assert repo2.find_by_room("focus") == []      # la otra base ni se entero
    with pytest.raises(KeyError):
        repo2.get(booking.id)

repo1 reserva en su base de memoria. repo2 abre otra conexión :memory: —una base nueva, en blanco— y no encuentra nada: find_by_room("focus") devuelve una lista vacía y get(booking.id) lanza KeyError, porque para repo2 esa reserva nunca existió. Esto es la pizarra: cada conexión :memory: es su propia sala con su propia pizarra limpia. Lejos de ser un defecto, es justo lo que hace a :memory: tan cómoda para tests —cada uno arranca aislado, sin que el anterior le deje datos—.

El cuaderno sí comparte: un archivo entre conexiones

Ahora la de archivo, con la misma forma de test, para ver el contraste exacto. Dos conexiones al mismo archivo comparten los datos: lo que una escribe, la otra lo lee.

def test_file_persists_across_connections(tmp_path):
    path = tmp_path / "reservo.db"

    repo1 = SqliteBookingRepository(sqlite3.connect(path))
    booking = make_service(repo1).book(FOCUS, ANA, START, END)

    repo2 = SqliteBookingRepository(sqlite3.connect(path))
    reread = repo2.get(booking.id)                 # el archivo lo comparte
    assert reread.price_cents == 6000

Idéntico al anterior salvo por una cosa: en vez de ":memory:", las dos conexiones apuntan al mismo path. Y el resultado se invierte: repo2, una conexión nueva, encuentra la reserva que repo1 guardó, con su price_cents == 6000. El archivo es el cuaderno compartido: repo1 escribió con tinta (y save hizo commit), así que repo2 lee lo mismo. Esta es la única cosa que :memory: no puede darte —la persistencia entre conexiones—, y la razón por la que, cuando lo que pruebas es precisamente que el dato dura, necesitas un archivo.

Las dos son SQLite real: la misma fila

Un punto que conviene fijar para no caer en el error de creer que :memory: es "menos real": las dos producen exactamente la misma fila, con la misma serialización. La memoria no es una versión de juguete; es el mismo motor sin disco.

def test_both_are_real_sqlite_same_row_shape(tmp_path):
    # El mismo codigo produce la misma fila en memoria y en disco.
    mem = sqlite3.connect(":memory:")
    disk = sqlite3.connect(tmp_path / "reservo.db")
    for conn in (mem, disk):
        make_service(SqliteBookingRepository(conn)).book(FOCUS, ANA, START, END)

    q = "SELECT room_id, status, start, price_cents FROM bookings"
    assert mem.execute(q).fetchall() == disk.execute(q).fetchall()

La misma reserva, hecha en memoria y en disco, produce filas idénticas —mismo room_id, mismo status, mismo start serializado a texto, mismo price_cents entero—. Por eso :memory: sirve para probar la frontera de la base de datos: ejerce la serialización (el datetimestr aparece igual), las transacciones y los tipos. Lo único que no ejerce, por no tener disco, es la persistencia entre conexiones. Todo lo demás es idéntico.

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

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

tests/test_memory_vs_file.py::test_memory_does_not_persist_across_connections PASSED [ 33%]
tests/test_memory_vs_file.py::test_file_persists_across_connections PASSED [ 66%]
tests/test_memory_vs_file.py::test_both_are_real_sqlite_same_row_shape PASSED [100%]

============================== 3 passed in 0.01s ===============================

Tres verdes que dibujan el mapa: :memory: no comparte entre conexiones (pizarra), el archivo sí (cuaderno), y las dos producen la misma fila (el mismo motor). Con eso, la elección deja de ser un hábito ciego y se vuelve una decisión con criterio.

Cuánto cuesta el disco: la medición

Falta el segundo eje —la velocidad—, y aquí conviene un número, no una intuición. Midamos cuánto tardan 500 reservas en :memory: contra 500 en un archivo. Cada book hace un save con su commit, y ahí está el costo: confirmar al disco obliga a SQLite a asegurarse de que los bytes llegaron de verdad, cosa que la RAM no necesita.

# bench_memory_vs_file.py — cuanto cuesta el disco frente a :memory:
import sqlite3
import tempfile
import time
from datetime import datetime, timedelta
# ...imports de Reservo...

N = 500


def run(conn):
    repo = SqliteBookingRepository(conn)
    service = BookingService(Calendar(), FixedClock(datetime(2026, 3, 1, 9)),
                             StubPaymentGateway(ok=True), SpyEmailSender(), repo)
    start = datetime(2026, 3, 10, 9)
    t0 = time.perf_counter()
    for i in range(N):
        s = start + timedelta(hours=3 * i)
        service.book(FOCUS, ANA, s, s + timedelta(hours=3))
    return (time.perf_counter() - t0) * 1000


mem_ms = run(sqlite3.connect(":memory:"))
with tempfile.NamedTemporaryFile(suffix=".db") as f:
    disk_ms = run(sqlite3.connect(f.name))

print(f"{N} reservas en :memory: (RAM):   {mem_ms:8.2f} ms")
print(f"{N} reservas en archivo (disco):  {disk_ms:8.2f} ms")
print(f"el disco fue {disk_ms / mem_ms:5.1f}x mas lento")

Qué esperar. En mi máquina (Python 3.14.0), corriéndolo directamente:

python3 bench_memory_vs_file.py
500 reservas en :memory: (RAM):     12.98 ms
500 reservas en archivo (disco):   180.49 ms
el disco fue  13.9x mas lento

Los números exactos varían entre corridas y entre máquinas —el disco es lo más impredecible—, pero el orden de magnitud es estable: el archivo es más de diez veces más lento que la memoria, y a veces mucho más. La causa es el commit de cada save: confirmar al disco fuerza a SQLite a garantizar que los datos quedaron escritos físicamente, una operación cara que la RAM se salta. Multiplica esa diferencia por cientos de tests con decenas de escrituras cada uno y entiendes por qué la elección importa: una suite que usa :memory: donde no necesita el disco corre en un parpadeo; una que usa archivos por costumbre se arrastra. Por eso la regla: :memory: por defecto, archivo solo cuando pruebas la persistencia.

Errores comunes

Usar :memory: cuando el test necesita probar la persistencia en disco. Qué pasa: alguien quiere verificar que la reserva sobrevive a reiniciar el servicio, pero lo prueba con :memory:; el test pasa sin probar nada real, porque nunca cierra la conexión. Por qué pasa: :memory: es el hábito por defecto. Cómo detectarlo: si tu test afirma sobre la persistencia —"sobrevive a cerrar y reabrir"— pero usa :memory:, no está probando lo que dice: una base :memory: reabierta es una base nueva y vacía. Cómo corregirlo: la persistencia en disco solo se prueba con un archivo real (con tmp_path), escribiendo, cerrando y reabriendo. :memory: es para todo lo demás.

Creer que dos conexiones :memory: comparten datos. Qué pasa: alguien abre una conexión :memory: para escribir y otra para leer, y se sorprende de que la segunda no vea nada. Por qué pasa: se asume que :memory: es "una base" global, como un archivo con nombre. Cómo detectarlo: si escribes con una conexión :memory: y otra :memory: no lo ve, no es un bug: es que son dos bases distintas. Cómo corregirlo: cada connect(":memory:") crea su propia base privada. Si necesitas que dos conexiones compartan una base en memoria, es un caso avanzado (una URI compartida) que casi nunca hace falta; lo normal es usar una sola conexión por base :memory:, o pasar a un archivo si de verdad necesitas compartir.

Usar un archivo con nombre fijo y no limpiarlo. Qué pasa: alguien usa sqlite3.connect("test.db") con un nombre fijo; el primer test pasa, pero el segundo encuentra los datos del primero y falla de forma misteriosa. Por qué pasa: el cuaderno persiste —esa es su gracia y su trampa—. Cómo detectarlo: si un test pasa en aislamiento pero falla al correr toda la suite, o depende del orden, probablemente comparte un archivo sin limpiar. Cómo corregirlo: para un archivo temporal por test, usa tmp_path de pytest (lección 5), que da una ruta única y la borra al terminar. Si de verdad no necesitas persistencia entre conexiones, usa :memory:, que se limpia sola. El aislamiento sistemático de recursos con estado es el módulo 7.

Ejercicios

Ejercicio 1 — Elige el recurso. Para cada test de Reservo, di si usarías :memory: o un archivo, y por qué: (a) verificar que bookget devuelve la reserva con price_cents == 6000; (b) verificar que una reserva guardada sobrevive a cerrar la conexión y reabrir con una nueva; (c) correr la batería de contrato del repositorio (guardar-y-leer, get ausente lanza, save dos veces actualiza); (d) medir cuánto tarda tu suite de integración en el peor caso realista de producción.

Ver solución
  • (a) :memory:. Prueba la costura servicio↔base de datos (serialización, lógica, transacción), no la persistencia en disco. :memory: la ejerce entera, es instantánea y viene aislada. Es el caso por defecto.
  • (b) Archivo. Aquí lo que se prueba es la persistencia en disco —sobrevivir a cerrar y reabrir la conexión—, y eso :memory: no lo puede dar (una base :memory: reabierta es nueva y vacía). Necesitas un archivo real, con tmp_path.
  • (c) :memory:. El contrato verifica comportamiento observable a través de la interfaz (guardar y leer, lanzar, actualizar), todo dentro de una conexión. :memory: lo cumple igual que el disco, más rápido y aislado. Salvo que una cláusula hable explícitamente de persistir entre conexiones, memoria.
  • (d) Archivo. Si el objetivo es medir el costo realista, tienes que incluir el precio del disco, porque producción usa disco. Medir con :memory: daría un número engañosamente optimista. Para la medición, archivo; para la funcionalidad de casi todos los tests, memoria.

La regla que estás afinando: :memory: por defecto (rápido, aislado, ejerce toda la frontera menos la persistencia); archivo cuando el test es sobre la persistencia en disco o cuando mides el costo real.

Ejercicio 2 — ¿Menos real por estar en memoria? Un compañero descarta :memory: para las pruebas de integración "porque no es SQLite de verdad, es una simulación en RAM". Corrígelo con precisión: ¿qué ejerce :memory: exactamente igual que el disco, y qué es lo único que no?

Ver solución

:memory: no es una simulación: es el motor de SQLite completo, corriendo con la base en RAM en vez de en un archivo. Ejerce, exactamente igual que el disco: el SQL (mismas consultas, mismo INSERT ... ON CONFLICT), la serialización (el datetime se guarda como texto y vuelve como str, el int cruza intacto —lo probó test_both_are_real_sqlite_same_row_shape, filas idénticas—), las transacciones (commit, rollback, el aislamiento entre conexiones de la lección 3), los tipos de columna, las restricciones (PRIMARY KEY, NOT NULL). Todo lo que hace de SQLite una frontera con reglas propias está presente en :memory:.

Lo único que :memory: no ejerce, por no tener disco, es la persistencia entre conexiones y entre corridas: una base :memory: muere cuando cierras su conexión, así que no puede probar "la reserva sobrevive a reiniciar". Para eso —y solo para eso— hace falta un archivo. Así que el compañero tiene medio punto mal: :memory: sí es SQLite de verdad y sirve para la enorme mayoría de las pruebas de la frontera de base de datos; lo que hay que reservar para el archivo es la clase específica de test que verifica la durabilidad en disco. Descartar :memory: por "no ser real" es cambiar velocidad y aislamiento por nada.

Ejercicio 3 — Explica el número. En la medición, el disco fue más de diez veces más lento que la memoria, y el bloque de código hace un book (con su save y su commit) por iteración. Explica por qué el commit a disco es tan caro comparado con el de memoria, y qué pasaría con la diferencia si save no hiciera commit en cada llamada.

Ver solución

El commit a disco es caro porque confirmar una transacción a un archivo obliga a SQLite a garantizar que los bytes quedaron escritos físicamente en el almacenamiento —no solo en un búfer del sistema operativo que podría perderse si se corta la luz—. Esa garantía (una operación de sincronización con el disco) es lenta por naturaleza: el disco es hardware físico, órdenes de magnitud más lento que la RAM. Con 500 reservas, son 500 confirmaciones a disco, cada una esperando esa garantía. En :memory:, el commit no tiene disco que sincronizar: confirmar es casi gratis, solo mueve punteros en la RAM. De ahí la diferencia de más de 10x —a veces mucho más—.

Si save no hiciera commit en cada llamada —por ejemplo, acumulando muchas escrituras y confirmando una sola vez al final—, la diferencia se reduciría mucho, porque pagarías el costo del disco una vez en vez de 500. Esa es una técnica real de optimización (agrupar escrituras en una transacción), pero tiene un costo: si el programa muere antes del commit final, pierdes todas las escrituras acumuladas —y rompes la persistencia por reserva que Reservo garantiza hoy con su commit por save—. Es un intercambio entre velocidad y durabilidad. Para los tests, la salida más limpia no es agrupar commits, sino usar :memory: cuando no necesitas el disco: te da la velocidad sin sacrificar la garantía por escritura, porque en la RAM confirmar es barato.

Resumen y siguiente paso

En esta lección tomaste con criterio la segunda decisión de la frontera de base de datos: dónde vive la base. Con la pizarra y el cuaderno separaste los dos ejes: :memory: es instantánea y siempre limpia pero efímera (muere con la conexión, no comparte entre conexiones); un archivo persiste y se comparte pero cuesta el precio del disco. Lo probaste con salida real: dos conexiones :memory: no se ven, dos al mismo archivo sí, y las dos producen filas idénticas —porque son el mismo motor—. Y mediste el costo: el disco fue más de diez veces más lento, por el commit que sincroniza con el almacenamiento. De ahí la regla: :memory: por defecto, archivo solo cuando pruebas la persistencia en disco o mides el costo real.

Antes de avanzar deberías poder: elegir entre :memory: y archivo según lo que el test prueba; explicar por qué :memory: es SQLite real y qué es lo único que no ejerce; y explicar por qué el commit a disco es caro.

Con esto cierras la frontera de base de datos. Lo que sigue es la segunda frontera: los archivos. En la lección 5 vas a exportar reservas de Reservo a un archivo CSV real y a importarlas de vuelta, usando el fixture tmp_path de pytest —que te da un directorio temporal de verdad y lo limpia solo—. Vas a ver el texto que queda en el disco, comprobar el ida-y-vuelta, y reencontrarte con la serialización de la frontera: en un archivo, como en la base de datos, todo se vuelve texto, y los enteros hay que reconstruirlos.

Recursos