Módulo 6: Fronteras reales: DB, archivos, HTTP
5. La frontera de archivos con `tmp_path`
Descripción
Segunda frontera: los archivos. Reservo no vive solo de su base de datos —a cada rato necesita mover reservas hacia y desde un archivo: exportar la agenda de una sala para un reporte, respaldar las reservas del mes, importar un lote desde un sistema viejo—. Cada una de esas operaciones cruza la frontera del sistema de archivos: escribe texto en un disco que sobrevive a tu proceso, o lee texto que otro dejó ahí. Es una frontera más humilde que la base de datos —no hay transacciones ni un motor con reglas complejas—, pero comparte con ella la misma trampa, la que es el hilo de todo el módulo: en un archivo, todo se vuelve texto. Un Booking con su datetime y su price_cents entero, al escribirse en un CSV, se convierte en una línea de caracteres; al leerlo de vuelta, no recuperas objetos de Python, recuperas texto, y los tipos que no son texto hay que reconstruirlos a mano.
Para probar esta frontera sin ensuciar tu disco ni pelear con rutas, pytest te da una herramienta hecha a la medida: el fixture tmp_path. Cuando un test declara un parámetro llamado tmp_path, pytest le pasa un directorio temporal único, recién creado y vacío, distinto para cada test, y lo borra automáticamente cuando la prueba termina. Es la frontera de archivos con red de seguridad: escribes archivos de verdad, en un disco de verdad, con toda la serialización de verdad, pero en un rincón desechable que nadie más toca y que se limpia solo. En esta lección vas a exportar reservas de Reservo a un archivo CSV en tmp_path, mirar el texto que quedó en el disco, importarlo de vuelta, y ver con tus ojos por qué price_cents regresa como entero solo si lo reconviertes, mientras start regresa como texto —la misma lección del datetime de la base de datos, ahora en un archivo—.
Conexión con el módulo: esta lección es la frontera de archivos, entre la de base de datos (lecciones 3 y 4) y la de HTTP (lección 6). Reencuentra la serialización que viste en SQLite —todo a texto— en un recurso distinto, para que veas que no era una maña de SQLite sino la ley de toda frontera de datos. Y estrena tmp_path, la herramienta que la lección 7 elevará a principio: usar recursos temporales, únicos y autolimpiables es una de las tres claves de una prueba de frontera rápida y determinista. La frontera con el módulo 7 se respeta: aquí usas tmp_path para tener un archivo real y desechable; las estrategias finas de aislamiento —crear y destruir recursos alrededor de cada test, mantener las pruebas independientes cuando comparten estado— son allá. tmp_path es el primer sabor de ese aislamiento, servido por pytest.
Analogía: el recibo impreso
Piensa en la diferencia entre contarle a alguien lo que compraste y entregarle el recibo impreso. Cuando se lo cuentas de viva voz, en la misma habitación, el mensaje son ideas en tu cabeza que pasan a la suya —nada se convierte en otra cosa, nada queda por escrito—. Cuando le entregas el recibo, ocurre algo distinto: la compra se imprimió en papel, se volvió una hilera de caracteres —"CAFÉ 45.00", "TOTAL 45.00"— que existe fuera de ti, que la otra persona puede guardar en su bolsillo y leer mañana, y que ya no es un objeto ni un número en tu mente: es texto en un papel. Si esa persona quiere volver a tratar el "45.00" como un número —para sumarlo a otros recibos—, tiene que reconvertirlo: leer los caracteres "4", "5", ".", "0", "0" y entender que representan la cantidad cuarenta y cinco. El papel no guarda números; guarda las marcas que los representan.
Exportar reservas a un archivo es imprimir el recibo. El Booking —un objeto con un datetime y un entero— se imprime como una línea de texto en un CSV: focus,m-ana,2026-03-10T09:00:00,...,6000. Ese archivo existe en el disco, sobrevive a tu programa, y otro sistema puede leerlo mañana. Pero cuando lo importas, no recuperas el objeto: recuperas el texto impreso, y para volver a tratar 6000 como un entero tienes que reconvertirlo con int(...), igual que quien lee el recibo reconvierte "45.00" a un número. El 2026-03-10T09:00:00, si nadie lo reconvierte, se queda como texto —una fecha impresa, no un datetime—. Esta lección es imprimir el recibo, leerlo, y entender qué se conserva solo si lo reconstruyes.
La exportación y la importación de Reservo
Aquí está el código que cruza la frontera de archivos, en las dos direcciones. Usa el módulo csv de la stdlib —cero dependencias—, que escribe y lee filas de texto separadas por comas.
# reservo/export.py — exportar e importar reservas a un archivo CSV real
import csv
from reservo.models import Booking
FIELDS = ["id", "room_id", "member_id", "start", "end", "status", "price_cents"]
def _as_text(value):
# Un datetime se serializa a ISO; un str (ya serializado) se deja igual.
return value.isoformat() if hasattr(value, "isoformat") else value
def export_bookings(bookings, path):
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
for b in bookings:
writer.writerow({
"id": b.id,
"room_id": b.room_id,
"member_id": b.member_id,
"start": _as_text(b.start),
"end": _as_text(b.end),
"status": b.status,
"price_cents": b.price_cents,
})
def import_bookings(path):
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
return [
Booking(
id=r["id"],
room_id=r["room_id"],
member_id=r["member_id"],
start=r["start"], # vuelve como str (texto del archivo)
end=r["end"],
status=r["status"],
price_cents=int(r["price_cents"]), # el archivo es texto: hay que re-convertir a int
)
for r in reader
]
Mira las dos líneas que son toda la historia. En export_bookings, el datetime se convierte a texto con _as_text (que hace .isoformat()), porque un archivo solo guarda caracteres —igual que SQLite—. En import_bookings, al leer, cada campo vuelve como texto, así que price_cents=int(r["price_cents"]) lo reconvierte al entero que era; pero start=r["start"] se deja como texto, sin reconvertir a datetime —la misma decisión (u omisión) que produce el str en el repositorio de SQLite—. El CSV es el recibo impreso: todo sale como caracteres, y solo recuperas los tipos que reconstruyes explícitamente.
Ejemplo trabajado: el ida-y-vuelta y el texto en el disco
Probemos la frontera con dos tests. El primero hace el ida-y-vuelta completo —exportar dos reservas, importarlas, y verificar que vuelven con sus datos—. El segundo mira el archivo: lee el texto crudo que quedó en el disco, para ver con nuestros ojos en qué se convirtió la reserva. Usamos FakeBookingRepository para que las reservas conserven su datetime real hasta el momento de exportar —así la serialización a texto ocurre en la frontera de archivos, no antes, y la vemos aislada—.
# tests/test_file_boundary.py — la frontera de archivos con tmp_path
from datetime import datetime
from reservo.export import export_bookings, import_bookings
# ...imports de Reservo: FakeBookingRepository, BookingService, doubles, models...
FOCUS = Room(id="focus", name="Focus", capacity=4, hourly_cents=2500)
STUDIO = Room(id="studio", name="Studio", capacity=8, hourly_cents=4000)
ANA = Member(id="m-ana", name="Ana", tier="pro")
CLOCK = datetime(2026, 3, 1, 9)
def make_two_bookings():
repo = FakeBookingRepository()
service = BookingService(Calendar(), FixedClock(CLOCK),
StubPaymentGateway(ok=True), SpyEmailSender(), repo)
a = service.book(FOCUS, ANA, datetime(2026, 3, 10, 9), datetime(2026, 3, 10, 12))
b = service.book(STUDIO, ANA, datetime(2026, 3, 11, 9), datetime(2026, 3, 11, 11))
return [a, b]
def test_export_then_import_round_trips(tmp_path):
bookings = make_two_bookings()
path = tmp_path / "bookings.csv"
export_bookings(bookings, path)
restored = import_bookings(path)
assert {b.id for b in restored} == {b.id for b in bookings}
focus = next(b for b in restored if b.room_id == "focus")
assert focus.price_cents == 6000 # Focus 3 h pro
assert isinstance(focus.price_cents, int) # re-convertido desde texto
studio = next(b for b in restored if b.room_id == "studio")
assert studio.price_cents == 6400 # Studio 2 h pro: 4000*2*0.8
def test_the_exported_file_is_real_text(tmp_path):
bookings = make_two_bookings()
path = tmp_path / "bookings.csv"
export_bookings(bookings, path)
text = path.read_text(encoding="utf-8")
print("\n--- bookings.csv ---\n" + text + "--------------------")
assert path.exists()
lines = text.strip().splitlines()
assert lines[0] == "id,room_id,member_id,start,end,status,price_cents"
assert len(lines) == 3 # cabecera + 2 reservas
assert "2026-03-10T09:00:00" in text # el datetime, ya como texto
assert "6000" in text
Fíjate en path = tmp_path / "bookings.csv": tmp_path es un objeto Path a un directorio temporal único que pytest creó para este test; con el operador / armamos la ruta del archivo dentro de él. Escribimos ahí, y pytest borrará todo el directorio al terminar —no tenemos que limpiar nada—. El primer test verifica el ida-y-vuelta: las dos reservas vuelven con sus ids, y el price_cents regresa como el entero correcto (6000 para Focus 3 h pro, 6400 para Studio 2 h pro) porque import_bookings lo reconvirtió con int(...). El segundo lee el texto crudo del archivo y lo imprime, para que veas el recibo.
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1), con -s para ver el print del archivo:
python3 -m pytest tests/test_file_boundary.py -v -s
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 3 items
tests/test_file_boundary.py::test_export_then_import_round_trips PASSED
tests/test_file_boundary.py::test_the_exported_file_is_real_text
--- bookings.csv ---
id,room_id,member_id,start,end,status,price_cents
bk-m-ana-...,focus,m-ana,2026-03-10T09:00:00,2026-03-10T12:00:00,confirmed,6000
bk-m-ana-...,studio,m-ana,2026-03-11T09:00:00,2026-03-11T11:00:00,confirmed,6400
--------------------
PASSED
tests/test_file_boundary.py::test_price_survives_but_start_comes_back_as_text PASSED
============================== 3 passed in 0.02s ===============================
Ahí está el recibo impreso, hecho visible. Las dos reservas, que en Python eran objetos Booking con datetime y enteros, quedaron en el disco como tres líneas de texto: una cabecera y dos filas. El start es 2026-03-10T09:00:00 —texto, sin comillas ni tipo, solo caracteres—; el price_cents es 6000 —los dígitos, no un entero de Python—. El id de cada fila lleva un sufijo numérico que depende de tu zona horaria (lo elidimos con bk-m-ana-..., como en el resto de la guía). Este archivo existe en el disco de verdad; si no fuera por tmp_path, seguiría ahí después del test. Y cuando lo importas, todo eso vuelve como texto: solo price_cents recupera su tipo entero, porque tu código lo reconvirtió a propósito.
El precio vuelve como int, el start vuelve como texto
Vale la pena un test dedicado a la asimetría, porque es el corazón de la frontera de archivos y el eco exacto del bug del módulo 1.
def test_price_survives_but_start_comes_back_as_text(tmp_path):
bookings = make_two_bookings()
path = tmp_path / "bookings.csv"
export_bookings(bookings, path)
restored = import_bookings(path)
focus = next(b for b in restored if b.room_id == "focus")
assert isinstance(focus.price_cents, int) # lo re-convertimos con int(...)
assert isinstance(focus.start, str) # nadie lo re-convierte a datetime
assert focus.start == "2026-03-10T09:00:00"
El mismo archivo produce dos destinos distintos según lo que el importador haga con cada campo. price_cents vuelve como int porque import_bookings lo envuelve en int(...); start vuelve como str porque nadie lo envuelve en datetime.fromisoformat(...). La frontera no decide los tipos; tu código de importación los decide. El archivo entrega texto para todo; recuperas objetos solo donde reconstruyes. Si Reservo necesitara que start volviera como datetime, tendrías que reconvertirlo en el importador —exactamente el arreglo que el módulo 5 aplicó al repositorio de SQLite para el mismo problema—. Es la misma ley de frontera, en dos recursos distintos: la serialización aplana todo a texto, y la reconstrucción es responsabilidad tuya.
tmp_path: un archivo real, desechable, por test
Detengámonos un momento en tmp_path, porque es la herramienta que hace viable probar esta frontera. Cuando un test declara tmp_path como parámetro, pytest hace tres cosas por ti: crea un directorio temporal único para ese test (dos tests nunca comparten el mismo, así que no se pisan), te lo entrega como un objeto pathlib.Path (con el que armas rutas con /), y lo elimina automáticamente cuando el test termina (pytest conserva los de las últimas corridas por si necesitas inspeccionarlos, y va limpiando los viejos). El resultado: escribes y lees archivos de verdad, en el disco de verdad, con toda la serialización real, pero sin ensuciar tu proyecto, sin colisiones entre tests y sin tener que acordarte de borrar nada. Es la frontera de archivos con las tres virtudes que la lección 7 pedirá a toda prueba de frontera: real, aislada y autolimpiable. Comparado con el tempfile.mkstemp + finally: os.remove(...) crudo que viste en el módulo 5, tmp_path es la versión que pytest te regala hecha.
Errores comunes
Esperar que price_cents vuelva como entero solo. Qué pasa: alguien importa un CSV y compara booking.price_cents == 6000, y el test falla con '6000' == 6000 es False. Por qué pasa: se olvida que el archivo guarda todo como texto. Cómo detectarlo: si una comparación numérica falla contra un valor leído de un archivo, y el error muestra el número entre comillas ('6000'), es un str que no se reconvirtió. Cómo corregirlo: al importar, envuelve los campos numéricos en int(...) (o float(...)), como hace import_bookings con price_cents. La frontera de archivos, como la de la base de datos, aplana todo a texto; los tipos se reconstruyen en la importación.
Escribir a un archivo con nombre fijo en el directorio del proyecto. Qué pasa: alguien exporta a "bookings.csv" sin ruta, el archivo aparece en la carpeta del proyecto, y queda ahí tras el test —ensuciando el repositorio, o peor, siendo leído por el siguiente test—. Por qué pasa: es lo más rápido de escribir. Cómo detectarlo: si tras correr tus tests aparecen archivos sueltos, o un test depende de otro por un archivo compartido, este es el problema. Cómo corregirlo: usa tmp_path para un archivo temporal único y autolimpiable por test. Nunca escribas archivos de prueba en rutas fijas del proyecto.
Abrir el archivo sin newline="" o sin encoding. Qué pasa: al usar el módulo csv, alguien abre el archivo con open(path, "w") a secas; en algunas plataformas aparecen líneas en blanco entre filas, o caracteres no ASCII se rompen. Por qué pasa: el módulo csv maneja sus propios saltos de línea, y el encoding por defecto varía según el sistema. Cómo detectarlo: filas separadas por líneas vacías, o un UnicodeError con acentos. Cómo corregirlo: al escribir CSV, abre con open(path, "w", newline="", encoding="utf-8"), como en export_bookings —newline="" deja que csv controle los saltos y encoding="utf-8" fija la codificación—. Es un detalle de la frontera de archivos que un doble en memoria nunca te obliga a considerar, y otra razón para probar contra el archivo real.
Ejercicios
Ejercicio 1 — Predice el archivo. Sin correr nada, escribe las tres líneas exactas que export_bookings produciría para dos reservas: Focus 3 h para Ana pro (inicio 2026-03-10T09:00) y Boardroom 1 h para Ana pro (inicio 2026-03-12T15:00), sabiendo que Boardroom cuesta 8000 centavos la hora. Presta atención a la cabecera y al precio de cada una.
Ver solución
Las tres líneas serían (con el id elidido, que depende de la zona horaria):
id,room_id,member_id,start,end,status,price_cents
bk-m-ana-...,focus,m-ana,2026-03-10T09:00:00,2026-03-10T12:00:00,confirmed,6000
bk-m-ana-...,boardroom,m-ana,2026-03-12T15:00:00,2026-03-12T16:00:00,confirmed,6400
- La cabecera es siempre la lista de
FIELDS:id,room_id,member_id,start,end,status,price_cents. - Focus 3 h pro:
2500 * 3 = 7500, con 20% de descuento pro →7500 * 80 // 100 = 6000. Losstart/endvan como texto ISO. - Boardroom 1 h pro:
8000 * 1 = 8000, con descuento pro →8000 * 80 // 100 = 6400. Una hora, así queendes las16:00:00.
Lo que hay que ver: todo sale como texto plano, sin comillas ni tipos —el datetime como cadena ISO, el precio como dígitos—. Es el recibo impreso: caracteres, no objetos.
Ejercicio 2 — El campo que se rompe al reimportar. Un compañero añade a Reservo una función next_hour(booking) que hace booking.start + timedelta(hours=1). Funciona con reservas recién creadas, pero explota con reservas que vienen de import_bookings. Sin correr nada, explica el error exacto y cómo lo arreglarías en el importador.
Ver solución
next_hour explota con las reservas importadas porque su start es un str, no un datetime. import_bookings deja start=r["start"] como texto ("2026-03-10T09:00:00"), sin reconvertirlo. Cuando next_hour intenta booking.start + timedelta(hours=1), está haciendo str + timedelta, y Python lanza TypeError: can only concatenate str (not "datetime.timedelta") to str (o un unsupported operand type(s) según la operación). Es el gemelo exacto del bug del módulo 5, donde cancel no podía restar un str que venía de SQLite: la misma serialización a texto, el mismo TypeError al intentar usar el texto como si fuera un datetime.
El arreglo está en el importador: reconstruir el tipo al leer, igual que se hace con price_cents. En vez de start=r["start"], poner start=datetime.fromisoformat(r["start"]) (y lo mismo para end). Así el objeto Booking importado vuelve a tener datetime reales, y next_hour funciona. La moraleja de la frontera de archivos, otra vez: el archivo entrega texto para todo; los tipos que tu código no reconstruye explícitamente se quedan como texto y explotan cuando alguien intenta usarlos como el tipo original. La frontera no te avisa; el TypeError en tiempo de uso, sí —y por eso se prueba contra el archivo real, no contra un doble que devolvería el objeto intacto—.
Ejercicio 3 — Por qué tmp_path y no un nombre fijo. El test escribe a tmp_path / "bookings.csv" en vez de a "bookings.csv". Describe dos problemas concretos que aparecerían si dos tests distintos exportaran, cada uno, a un archivo fijo llamado "bookings.csv" en el directorio del proyecto, y cómo tmp_path los evita.
Ver solución
Dos problemas concretos con un archivo fijo compartido:
- Interferencia entre tests (estado que se filtra). Si el test A exporta tres reservas a
"bookings.csv"y el test B exporta dos al mismo archivo, el que corra segundo sobreescribe —o, si uno lee el archivo esperando lo suyo, encuentra los datos del otro—. Peor: si un test importa"bookings.csv"sin haberlo escrito él, puede leer lo que dejó un test anterior y pasar (o fallar) por la razón equivocada. El resultado son tests que dependen del orden de ejecución, el síntoma clásico de estado real sin aislar. - Basura en el proyecto (falta de limpieza). El archivo
"bookings.csv"queda en la carpeta del proyecto después de correr los tests, ensuciando el repositorio y arriesgándose a ser commiteado por error. Y si un test asume que el archivo no existe al empezar, fallará la segunda vez que corras la suite.
tmp_path evita los dos: da a cada test un directorio temporal único (así A y B nunca comparten bookings.csv —cada uno tiene el suyo, en su propio directorio—, eliminando la interferencia) y lo borra automáticamente al terminar (así no queda basura ni dependes de un estado inicial). Es exactamente el aislamiento que la lección 7 generaliza: recursos reales pero únicos y desechables por test. Usar un nombre fijo compartido es cambiar ese aislamiento por una fuente de fallos intermitentes.
Resumen y siguiente paso
En esta lección cruzaste la segunda frontera, la de los archivos, con el fixture tmp_path de pytest. Con el recibo impreso entendiste que escribir a un archivo convierte tus objetos en texto que existe fuera de tu proceso, y que al leerlo recuperas texto —no objetos—, así que los tipos hay que reconstruirlos. Lo probaste con salida real: exportaste dos reservas de Reservo a un CSV, miraste el texto crudo en el disco (...,2026-03-10T09:00:00,...,6000), y comprobaste que price_cents vuelve como entero solo porque import_bookings lo reconvierte con int(...), mientras start vuelve como texto —el mismo bug del datetime del módulo 1, ahora en un archivo—. Y viste cómo tmp_path te da un archivo real, único por test y autolimpiable, sin ensuciar nada.
Antes de avanzar deberías poder: exportar e importar datos a un archivo y explicar por qué todo vuelve como texto; reconstruir los tipos correctos en la importación (int, datetime.fromisoformat); y usar tmp_path para un archivo temporal aislado en vez de una ruta fija.
Lo que sigue es la tercera y última frontera, la más "externa" de todas: HTTP. En la lección 6 vas a levantar un PaymentGateway de mentira servido por HTTP de verdad con http.server de la stdlib —en un hilo, en un puerto efímero— y a probar el cliente HttpPaymentGateway contra ese servidor real: un POST que cruza TCP, la respuesta que vuelve, y un timeout que corta una espera lenta. Es la frontera donde el no-determinismo (la red que tarda o falla) se vuelve protagonista, y donde más importa la línea con testing-backend-applications-guide.
Recursos
- Documentación de pytest — Cómo usar directorios y archivos temporales (
tmp_path) — la referencia oficial del fixture con el que probamos la frontera de archivos: qué te da, que es único por test y que se limpia solo. La herramienta central de esta lección. csv— Lectura y escritura de archivos CSV (documentación de Python) — la referencia del módulo con el que Reservo exporta e importa; en particularDictWriteryDictReader, y la nota sobre abrir connewline="".datetime.fromisoformat(documentación de Python) — la función que reconstruye undatetimedesde el texto ISO que guarda el archivo; el arreglo del ejercicio 2 y el eco del arreglo del módulo 5 para SQLite.pathlib— Rutas de archivos orientadas a objetos (documentación de Python) — la referencia del tipoPathquetmp_pathte entrega y con el que armas rutas con el operador/.