Módulo 5: Integración de verdad: componentes reales juntos
3. `BookingService` y SQLite, juntos
Descripción
Tienes la definición; ahora vas a armar la integración a fondo y a convencerte, sin lugar a dudas, de que hay una base de datos real al otro lado de la costura. Es fácil escribir SqliteBookingRepository(sqlite3.connect(":memory:")), ver un verde y creerte que integraste algo, sin sentir de verdad que una fila entró en una tabla. Esta lección cierra esa distancia. Vas a hacer que book escriba una reserva a través del servicio real, y luego vas a mirar la fila con SQL crudo —sin pasar por el repositorio, consultando la tabla directamente— para ver con tus ojos qué quedó guardado y en qué forma. Y vas a hacer la prueba más contundente que existe de que hay persistencia real: escribir la reserva con una conexión, cerrarla, abrir una conexión nueva al mismo archivo, y comprobar que la reserva sigue ahí. Un fake en memoria no sobrevive a eso; una base de datos de verdad, sí.
El propósito no es aprender SQL —eso es tangencial y lo desglosaremos en el módulo 6—, sino sentir la costura en las manos. Cuando veas la fila ('bk-...', 'focus', 'confirmed', '2026-03-10T09:00:00', 6000) salir de una consulta directa a la tabla, vas a entender qué significa "cruzar la costura": el objeto Booking de Python se convirtió en una fila de texto y números, y esa fila vive en una estructura de datos que no es tu proceso. Y cuando la reserva sobreviva a cerrar y reabrir el archivo, vas a entender por qué la integración caza lo que el fake no puede: el fake es tu proceso; SQLite es otro sistema, con sus propias reglas de serialización, tipos y persistencia, y probar contra él es probar contra esas reglas.
Conexión con el módulo: esta lección convierte la integración abstracta de la lección 2 en una costura tangible. La 2 te dijo qué es integrar la costura del repositorio; esta te lo hace ver —la fila en la tabla, la persistencia en disco—. Con eso instalado, la lección 4 te dará el criterio de qué dejar real y qué doblar alrededor de esta costura, y la 6 usará esta misma costura, ya familiar, para cazar el bug del datetime en el flujo completo. Aquí todavía no cazamos nada: consolidamos la costura básica —book→get— y probamos, de dos maneras distintas, que la persistencia es de verdad. La frontera con el módulo 6 se respeta: las transacciones, el rollback y el manejo fino del archivo son de allá; aquí abrimos y cerramos conexiones lo justo para demostrar que hay una base de datos real, sin entrar en su manejo experto.
Analogía: la carta que echas al buzón
Piensa en la diferencia entre dictarle un recado a alguien que está en tu misma habitación y echar una carta al buzón. Si le dictas el recado a la persona de al lado, y un minuto después le preguntas qué dijiste, te lo repite: pero eso no prueba que el mensaje "salió" a ningún lado —sigue en la habitación, en la memoria de alguien que está contigo—. Es el fake: guardas la reserva en un dict de tu proceso y la lees de vuelta; nunca salió de tu programa. Echar la carta al buzón es distinto: el sobre cruza una frontera, sale de tu casa, entra en un sistema —el correo— que no controlas, se convierte en un objeto físico que viaja y se guarda en otro lado. Para probar que de verdad se envió, cierras la puerta de tu casa, te vas, vuelves al día siguiente, y encuentras la carta de respuesta en tu propio buzón. El mensaje sobrevivió a que te fueras: existió fuera de ti.
Escribir en SQLite es echar la carta al buzón. La reserva sale de tu proceso, cruza la costura, y se guarda como una fila en un archivo que sigue existiendo aunque cierres la conexión —aunque cierres el programa—. La prueba de que fue de verdad es la misma que la de la carta: cierra la conexión (vete de casa), abre una nueva (vuelve al día siguiente), y comprueba que la reserva sigue en la tabla (la carta llegó). El fake no puede pasar esa prueba, porque el fake es la habitación, no el buzón. En esta lección echas la carta y verificas que llegó.
Ver la fila con SQL crudo
Empecemos por mirar qué escribe book en la tabla, consultándola sin pasar por el repositorio. Esto es importante: si verificamos con repo.get(...), estamos confiando en el propio código del repositorio para leer lo que el propio código del repositorio escribió —si ambos comparten un error, no lo veríamos—. Consultar la tabla con SQL directo es mirar la costura desde afuera, con una herramienta que no es la que la escribió.
# tests/test_row_in_table.py — ver la fila REAL que quedo en la tabla
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(id="focus", name="Focus", capacity=4, hourly_cents=2500)
ANA = Member(id="m-ana", name="Ana", tier="pro")
START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12)
CLOCK = datetime(2026, 3, 1, 9)
def test_book_writes_one_row_readable_with_raw_sql():
conn = sqlite3.connect(":memory:")
repo = SqliteBookingRepository(conn)
service = BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
booking = service.book(FOCUS, ANA, START, END)
# Consultamos la tabla directamente, sin pasar por el repositorio.
rows = conn.execute(
"SELECT id, room_id, status, start, price_cents FROM bookings"
).fetchall()
assert len(rows) == 1 # una sola fila
print("\nFila en la tabla:", rows[0])
assert rows[0][1] == "focus" # room_id
assert rows[0][2] == "confirmed" # status
assert rows[0][3] == "2026-03-10T09:00:00" # start, guardado como TEXTO
assert rows[0][4] == 6000 # price_cents, un INTEGER real
Fíjate en el detalle clave: usamos la misma conexión conn que le pasamos al repositorio, pero la consultamos nosotros, con nuestro propio SELECT. El print está ahí para que veas la fila cruda en la salida. Y las aserciones examinan la fila tal como SQLite la guardó: el start es el texto '2026-03-10T09:00:00' —así, entre comillas, porque en la tabla es una cadena—, y el price_cents es el número 6000. Corramos con -s para que pytest no se coma el print.
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_row_in_table.py -v -s
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item
tests/test_row_in_table.py::test_book_writes_one_row_readable_with_raw_sql
Fila en la tabla: ('bk-m-ana-...', 'focus', 'confirmed', '2026-03-10T09:00:00', 6000)
PASSED
============================== 1 passed in 0.01s ===============================
Ahí está la costura, hecha visible. book corrió a través del servicio real y dejó una fila en la tabla bookings: ('bk-m-ana-...', 'focus', 'confirmed', '2026-03-10T09:00:00', 6000). Léela de izquierda a derecha y verás el objeto Booking convertido en una tupla de valores de base de datos: el id (texto), la sala (texto), el estado (texto), el start (texto ISO), el precio (entero). El id se genera del start de la reserva, por eso lo mostramos elidido con bk-m-ana-... —el número exacto depende de tu zona horaria—. Lo que no depende de nada tuyo es la forma: el start es una cadena —'2026-03-10T09:00:00', no un datetime— porque save lo serializó con .isoformat() para que cupiera en la columna TEXT. Estás viendo, con tus ojos y con una herramienta ajena al repositorio, exactamente en qué se convierte una reserva al cruzar la costura. Esa conversión —objeto de Python a fila de tabla— es lo que un fake nunca hace, y es la raíz de todo lo que el módulo 6 verá con la aserción del start.
La prueba definitiva: sobrevivir a reabrir el archivo
Consultar la tabla con SQL demuestra que la fila está ahí mientras la conexión vive. Pero un escéptico podría decir: "eso sigue siendo memoria; :memory: es una base de datos en RAM". Cierto. La prueba incontestable de que hay persistencia real es usar un archivo en disco, escribir con una conexión, cerrarla, y leer con una conexión nueva. Si la reserva sigue ahí después de cerrar la primera conexión, no era memoria de tu proceso: estaba en el archivo, en el disco.
# tests/test_survives_reopen.py — la reserva sobrevive cerrar y reabrir la BD en disco
import os
import sqlite3
import tempfile
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(id="focus", name="Focus", capacity=4, hourly_cents=2500)
ANA = Member(id="m-ana", name="Ana", tier="pro")
START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12)
CLOCK = datetime(2026, 3, 1, 9)
def test_booking_survives_closing_and_reopening_the_file():
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
try:
# Conexion 1: reserva y CIERRA la conexion.
conn1 = sqlite3.connect(path)
service = BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(),
SqliteBookingRepository(conn1))
booking = service.book(FOCUS, ANA, START, END)
conn1.close() # se va el objeto en memoria
# Conexion 2: NUEVA conexion al mismo archivo, la reserva sigue ahi.
conn2 = sqlite3.connect(path)
repo2 = SqliteBookingRepository(conn2)
reread = repo2.get(booking.id)
conn2.close()
assert reread.status == "confirmed"
assert reread.price_cents == 6000
finally:
os.remove(path)
Léelo con calma, porque cada paso importa. Creamos un archivo temporal con tempfile.mkstemp. Abrimos la conexión 1, reservamos, y la cerramos con conn1.close() —a partir de ahí, cualquier estado en la memoria de esa conexión desaparece—. Abrimos la conexión 2, completamente nueva, apuntando al mismo archivo, y pedimos la reserva. Si sale, es porque estaba en el disco, no en la conexión que cerramos. El finally con os.remove(path) limpia el archivo al terminar; es un adelanto crudo del aislamiento que el módulo 7 hará con elegancia.
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_survives_reopen.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 1 item
tests/test_survives_reopen.py::test_booking_survives_closing_and_reopening_the_file PASSED [100%]
============================== 1 passed in 0.01s ===============================
Verde, y con esto ya no hay escéptico que valga: la reserva sobrevivió a cerrar la conexión que la creó. La escribió conn1, que ya no existe cuando conn2 la lee. Esa persistencia entre conexiones es imposible para el FakeBookingRepository —su dict vive en la instancia; si tiras la instancia, se va todo—. La carta llegó al buzón: salió de tu proceso, se guardó en un archivo del disco, y siguió ahí cuando volviste con una conexión nueva. Esta es la diferencia material entre un doble y lo real, y es la razón por la que integrar contra SQLite prueba algo que ningún test con fake puede probar: que la reserva de verdad persiste, con todas las reglas de serialización, tipos y almacenamiento que SQLite trae puestas.
Por qué el commit importa (y por qué save lo hace)
Vale la pena un apunte, porque conecta con el módulo 6. Si miras save, termina con self._conn.commit(). Ese commit es lo que hace que la escritura sea permanente y visible para otras conexiones. Sin él, la reserva quedaría en una transacción abierta, viva solo dentro de conn1, y conn2 no la vería —el test de arriba fallaría—. El commit es la frontera exacta entre "lo tengo pensado" y "lo eché al buzón".
No vamos a profundizar aquí —las transacciones, el commit y el rollback como herramientas son el módulo 6, y usar el rollback para aislar tests es el módulo 7—. Pero quédate con la intuición: cruzar la costura hacia una base de datos real no es solo convertir el objeto en una fila; es también decidir cuándo esa fila se vuelve permanente. Nuestro save hace commit en cada escritura, que es lo más simple y lo que hace que la prueba de reabrir el archivo funcione. En el módulo 6 verás por qué a veces conviene controlar ese commit a mano, y en el 7, cómo usar esa misma palanca para que cada test empiece con la base limpia.
Errores comunes
Verificar la escritura solo con el mismo repositorio que la hizo. Qué pasa: para comprobar que book guardó bien, alguien usa únicamente repo.get(...), que corre el mismo código de lectura del repositorio. Por qué pasa: es lo más cómodo y suele bastar. Cómo detectarlo: pregúntate si un error compartido entre save y get —por ejemplo, ambos usando un nombre de columna equivocado de forma consistente— pasaría desapercibido. Si save y get se equivocan igual, get no lo delata. Cómo corregirlo: al menos una vez, verifica la escritura con una herramienta independiente del repositorio —un SELECT crudo sobre la tabla—, como en el primer ejemplo. No para cada test, pero sí para confiar en que la costura de verdad guarda lo que crees.
Creer que :memory: prueba la persistencia en disco. Qué pasa: alguien usa sqlite3.connect(":memory:") en todos sus tests y concluye que probó que la reserva persiste. Por qué pasa: :memory: es real SQLite y es cómodo y rápido. Cómo detectarlo: una base :memory: vive solo mientras la conexión vive; no prueba nada sobre archivos ni sobre sobrevivir a cerrar la conexión. Cómo corregirlo: :memory: es perfecto para la mayoría de las integraciones (rápido y aislado), pero si lo que quieres demostrar es la persistencia en disco, necesitas un archivo real, escribir, cerrar y reabrir —como en el segundo ejemplo—. Elige el recurso según lo que el test quiere probar.
Olvidar limpiar el archivo temporal. Qué pasa: alguien crea una base de datos en un archivo con nombre fijo (test.db) y no la borra; el siguiente test la encuentra con datos viejos y falla de forma misteriosa. Por qué pasa: el estado real persiste —esa es justo su gracia y su peligro—. Cómo detectarlo: si un test pasa la primera vez y falla la segunda, o depende del orden de ejecución, probablemente comparte un archivo que no se limpia. Cómo corregirlo: usa tempfile.mkstemp para un archivo único por test y bórralo en un finally, como en el ejemplo. El aislamiento sistemático —fixtures que crean y destruyen la base— es el módulo 7; aquí, el finally es el mínimo honesto para no dejar basura.
Ejercicios
Ejercicio 1 — Predice la fila. Sin correr nada, escribe la tupla que devolvería SELECT id, room_id, member_id, start, end, status, price_cents FROM bookings después de reservar Focus 3 h para Ana pro con START = datetime(2026, 3, 10, 9) y END = datetime(2026, 3, 10, 12). Presta atención al tipo de cada valor.
Ver solución
La fila sería, con los tipos que SQLite guarda:
('bk-m-ana-...', 'focus', 'm-ana', '2026-03-10T09:00:00', '2026-03-10T12:00:00', 'confirmed', 6000)
id: texto, generado delstart(bk-m-ana-seguido del timestamp; el número exacto depende de la zona horaria, por eso va elidido).room_id:'focus', texto.member_id:'m-ana', texto.start:'2026-03-10T09:00:00', texto —eldatetimeserializado con.isoformat()—.end:'2026-03-10T12:00:00', texto, por la misma razón.status:'confirmed', texto.price_cents:6000, entero —la única columnaINTEGER; el resto esTEXT—.
Lo que hay que ver: start y end son cadenas, no datetime. Esa es la conversión que la costura hace al escribir, y la que —sin el arreglo del módulo 6— hace que get devuelva un str. El price_cents es el único que cruza como número, porque INTEGER es su tipo nativo.
Ejercicio 2 — Rompe la persistencia a propósito. Toma el test de sobrevivir a reabrir el archivo y quítale la línea self._conn.commit() del método save del repositorio (imagina que un compañero la borró). Sin correr, predice qué pasaría con conn2.get(booking.id) y por qué.
Ver solución
Sin el commit, la escritura de save quedaría en una transacción abierta dentro de conn1, no confirmada al archivo. Cuando conn1.close() cierra la conexión, esa transacción sin confirmar se descarta (SQLite hace rollback implícito al cerrar una conexión con una transacción pendiente). Así que el archivo quedaría sin la fila.
Entonces conn2, la conexión nueva, al hacer get(booking.id) no encontraría ninguna fila con ese id, y el SELECT ... WHERE id = ? devolvería None. El repositorio, al ver row is None, lanzaría KeyError(booking_id). El test fallaría con un KeyError, no con un AssertionError —la reserva no es que esté mal, es que no está—.
La lección: el commit es la frontera entre "escrito en memoria de la transacción" y "persistido en el archivo, visible para otras conexiones". La persistencia real que probamos depende de él. Es exactamente la palanca que el módulo 6 estudia como herramienta y el 7 usa para aislar tests (escribir sin commit y hacer rollback al final, para que nada persista entre pruebas).
Ejercicio 3 — ¿Por qué SQL crudo y no repo.get? El primer ejemplo verifica la escritura con un SELECT directo en vez de con repo.get(...). Explica en qué situación concreta el SELECT crudo cazaría un bug que repo.get dejaría pasar, e inventa un ejemplo de ese bug en SqliteBookingRepository.
Ver solución
El SELECT crudo caza bugs que viven en un error compartido entre save y get: si los dos métodos se equivocan de la misma forma, get "deshace" el error de save al leer, y el ida-y-vuelta parece correcto aunque la fila en la tabla esté mal.
Un ejemplo concreto: imagina que save guardara el status en una columna equivocada —digamos que, por un error de tipeo, escribiera el status en la columna member_id y el member_id en la columna status—, y que get, con el mismo error de tipeo, leyera member_id de la columna status y status de la columna member_id. El ida-y-vuelta save→get devolvería una reserva con el status y el member_id correctos, porque el segundo error cancela al primero. Un test que solo use repo.get(...) pasaría en verde, ciego al desastre. Pero un SELECT id, member_id, status FROM bookings crudo mostraría la fila con las columnas cruzadas —el member_id diciendo 'confirmed' y el status diciendo 'm-ana'—, y la aserción rows[0][2] == "confirmed" fallaría, delatando el bug.
La moraleja: verificar una escritura con el mismo código que la hizo tiene un punto ciego (los errores simétricos). Una herramienta independiente —el SQL crudo— mira la costura desde afuera y ve lo que el ida-y-vuelta esconde. No hace falta hacerlo en cada test, pero sí al menos una vez por costura, para confiar en que lo que se guarda es de verdad lo que crees.
Resumen y siguiente paso
En esta lección tocaste la costura BookingService↔SQLite con las manos. Hiciste que book escribiera a través del servicio real y miraste la fila resultante con un SELECT crudo —('bk-m-ana-...', 'focus', 'confirmed', '2026-03-10T09:00:00', 6000)—, viendo el objeto Booking convertido en una tupla de texto y números, con el start ya como cadena. Y probaste la persistencia de la forma más contundente: escribiendo con una conexión, cerrándola, y leyendo la reserva con una conexión nueva al mismo archivo en disco —la carta que sobrevivió a que te fueras de casa—, algo que ningún fake en memoria puede lograr. De paso viste por qué el commit de save es la frontera entre pensar el mensaje y echarlo al buzón.
Antes de avanzar deberías poder: verificar una escritura con una herramienta independiente del repositorio (SQL crudo) y explicar qué bug caza que get no; demostrar la persistencia real cerrando y reabriendo una conexión a un archivo; y explicar el papel del commit en que la reserva se vuelva permanente y visible para otras conexiones.
Hasta ahora dejamos real el repositorio y doblamos el pago, el correo y el reloj, y lo hicimos casi por instinto. Es hora de convertir ese instinto en criterio. En la lección 4 viene la regla de oro de la integración: qué mantener real —la costura que pruebas— y qué doblar —lo lento, lo no determinista, lo externo—, con la razón detrás de cada decisión. Es el criterio que te deja armar una integración que prueba lo que quiere sin heredar el costo y la fragilidad de lo que no.
Recursos
sqlite3— DB-API para SQLite (documentación de Python) — la referencia del recurso real que integramos; en particular,Connection.execute,Cursor.fetchallyConnection.commit, las tres piezas con las que escribimos la fila, la leemos con SQL crudo y la hacemos permanente.sqlite3—Connection.commit(documentación de Python) — el detalle exacto de qué hacecommity por qué sin él la escritura no persiste entre conexiones, el fundamento del ejercicio 2.tempfile— Archivos y directorios temporales (documentación de Python) — la referencia demkstemp, con el que creamos un archivo de base de datos único por test para demostrar la persistencia en disco sin dejar basura.- Documentación de pytest — Captura de salida (
-s) — por qué pytest se come losprintpor defecto y cómo-slos deja pasar, para reproducir el bloque "Qué esperar" que muestra la fila cruda de la tabla.