Módulo 5: Integración de verdad: componentes reales juntos

7. El costo de la integración

Descripción

La lección 6 te mostró el beneficio de la integración en todo su esplendor: caza bugs que ninguna otra herramienta ve completa. Sería deshonesto cerrar el módulo ahí, dejándote con la impresión de que la integración es puro beneficio y que, si caza más bugs, habría que integrarlo todo. No es así. Cada prueba de integración tiene una factura, y esta lección la pone sobre la mesa con números reales. Vas a medir, ejecutando de verdad, cuánto más lento es tocar SQLite que un fake —y la diferencia no es del 10% ni del doble: es de cientos de veces cuando hay disco de por medio—. Y vas a ver el otro costo, el que no se mide en milisegundos: la carga de sembrar la base de datos antes de cada test y limpiarla después, porque una base de datos real recuerda, y lo que un test deja, el siguiente lo encuentra.

Entender el costo no es para desanimarte de integrar; es para integrar con criterio. La razón por la que la pirámide de tests tiene muchos unit tests en la base y pocos de integración en el medio es exactamente este costo: si inviertes la proporción y lo integras todo, tu suite se vuelve tan lenta que nadie la corre, y tan frágil por el estado compartido que nadie confía en ella. El criterio que cierra el módulo es una pregunta que ahora puedes responder con números: para esta costura, ¿me basta con suponerla (un unit test rápido y un contrato barato) o el riesgo justifica pagar la integración? La respuesta depende de cuánto cuesta y de cuánto riesgo hay, y esta lección te da la mitad de la ecuación —el costo— medido, no intuido.

Conexión con el módulo: esta lección es la contracara de la 6 y el cierre conceptual del módulo. La 6 puso el beneficio (caza el bug del datetime); esta pone el costo (lenta, con siembra y limpieza), y juntas te dan el criterio de decisión. Es también la bisagra hacia el módulo 7: aquí nombramos la carga de sembrar y limpiar la base de datos y la sentimos como un costo, pero las técnicas para hacerlo bien —aislar con rollback, fixtures que crean y destruyen una base temporal, mantener las pruebas independientes cuando comparten estado real— son el módulo 7. Esta lección te deja convencido de que el aislamiento es un problema que hay que resolver; el módulo 7 lo resuelve.

Analogía: el crash test contra la simulación por computadora

Piensa en cómo una automotriz prueba la seguridad de un carro nuevo. Tiene dos herramientas. Una es la simulación por computadora: un modelo del choque que corre en segundos, se puede repetir mil veces con parámetros distintos, no gasta materiales y no ensucia nada. La otra es el crash test real: un carro de verdad, un maniquí de verdad, estrellado contra un muro de verdad a velocidad real. El crash test da una confianza que la simulación no puede dar —es el mundo físico, con todas sus sorpresas—, pero cuesta una fortuna: destruye un carro entero, requiere una pista, un equipo, horas de preparación, y después hay que limpiar los escombros y montar todo de nuevo para el siguiente. Nadie hace mil crash tests; hacen miles de simulaciones y unos pocos crash tests, en los escenarios donde la confianza del mundo real justifica el costo.

La integración es el crash test; el unit test con dobles es la simulación. La simulación (fake en memoria) corre en microsegundos, se repite sin límite y no deja rastro. El crash test (SQLite real, sobre todo en disco) da la confianza de la pieza de verdad, pero cuesta órdenes de magnitud más tiempo y exige preparar la pista antes (sembrar la base) y limpiar los escombros después (borrar los datos). Por eso la pirámide tiene la forma que tiene: muchas simulaciones baratas en la base, pocos crash tests caros en la punta. Esta lección mide cuánto cuesta el crash test, para que sepas cuándo vale la pena estrellar un carro de verdad y cuándo basta con simularlo.

Midiendo la lentitud: fake contra SQLite en memoria contra SQLite en disco

Basta de intuiciones; midámoslo. El siguiente script hace la misma operación —save seguido de get, cinco mil veces— contra tres repositorios: el FakeBookingRepository (un dict en memoria), un SqliteBookingRepository sobre :memory: (SQLite real, pero en RAM), y un SqliteBookingRepository sobre un archivo en disco. Cronometra cada uno y reporta el total y el tiempo por operación.

# bench.py — cuanto cuesta la integracion: fake vs SQLite (memoria) vs SQLite (disco)
import os
import sqlite3
import tempfile
import time
from datetime import datetime

from reservo.doubles import FakeBookingRepository
from reservo.models import Booking
from reservo.sqlite_repo import SqliteBookingRepository

START = datetime(2026, 3, 10, 9)
END = datetime(2026, 3, 10, 12)
N = 5000


def a_booking(i):
    return Booking(id=f"bk-{i}", room_id="focus", member_id="m-ana",
                   start=START, end=END, status="confirmed", price_cents=6000)


def bench(repo, label):
    t0 = time.perf_counter()
    for i in range(N):
        repo.save(a_booking(i))
        repo.get(f"bk-{i}")
    dt = time.perf_counter() - t0
    print(f"{label:28s} {N} ops  {dt*1000:8.1f} ms  ({dt/N*1e6:6.1f} us/op)")


bench(FakeBookingRepository(), "fake (dict en memoria)")
bench(SqliteBookingRepository(sqlite3.connect(":memory:")), "sqlite :memory:")

fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
bench(SqliteBookingRepository(sqlite3.connect(path)), "sqlite en disco")
os.remove(path)

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

python3 bench.py
fake (dict en memoria)       5000 ops       2.8 ms  (   0.6 us/op)
sqlite :memory:              5000 ops      39.0 ms  (   7.8 us/op)
sqlite en disco              5000 ops    2002.2 ms  ( 400.4 us/op)

Los números exactos varían entre corridas y entre máquinas, pero las magnitudes se mantienen y son la lección. Lee la escalera. El fake hace las cinco mil operaciones en 2.8 ms —medio microsegundo por operación—; es un dict en tu proceso, prácticamente instantáneo. SQLite en memoria tarda 39 ms, unas catorce veces más que el fake: sigue siendo rápido en términos absolutos, pero ya paga el costo de ser una base de datos de verdad —parsear SQL, gestionar la tabla, aplicar tipos— aunque los datos vivan en RAM. Y SQLite en disco tarda 2002 ms —dos segundos enteros—, unas setecientas veces más lento que el fake y cincuenta veces más lento que en memoria. Ese salto brutal al disco tiene un culpable con nombre: cada save hace commit, y cada commit fuerza a SQLite a escribir en el disco físico (un fsync) para garantizar que el dato sobrevive. Ese viaje al disco, cinco mil veces, es lo que se come los dos segundos.

La moraleja cuantitativa: una integración contra la base de datos real no es "un poco más lenta"; es de uno a tres órdenes de magnitud más lenta, y el disco es el factor dominante. Multiplica eso por una suite de miles de tests y entiendes por qué no puedes integrarlo todo: una suite de mil unit tests con fake corre en un parpadeo; la misma suite tocando disco tardaría minutos, y una suite que tarda minutos es una suite que la gente deja de correr.

El otro costo: sembrar y limpiar

La lentitud es el costo visible; hay otro, más insidioso, que no se mide en milisegundos: una base de datos real recuerda. Un fake nace vacío en cada test —lo creas con FakeBookingRepository() y su dict empieza limpio—. Una base de datos en disco, no: lo que un test escribió sigue ahí para el siguiente, a menos que lo borres. Esto crea dos tareas que el unit test nunca tuvo:

Sembrar (setup): antes de un test de integración, muchas veces necesitas que la base tenga cierto estado de partida —una reserva ya guardada para poder cancelarla, un esquema creado, unas filas de referencia—. Ese "poner la base como el test la necesita" es la siembra, y hay que escribirla, mantenerla y correrla antes de cada test.

Limpiar (teardown): después del test, hay que dejar la base como estaba, o el siguiente test heredará tu basura. Si el test A guarda una reserva bk-1 y no la borra, el test B que asume la base vacía —o que también usa bk-1— fallará de formas confusas, y peor aún, fallará dependiendo del orden en que corran los tests. Un test que pasa solo pero falla en la suite, o que pasa hoy y falla mañana, casi siempre es un problema de limpieza.

Ya viste un adelanto de esto en la lección 3, con el finally: os.remove(path) que borraba el archivo temporal a mano. Eso es limpieza cruda. El punto de esta lección no es enseñarte a hacerlo bien —eso es el módulo 7—, sino que sientas que existe: cada integración carga con la responsabilidad de sembrar su estado y limpiarlo, una responsabilidad que el unit test con dobles no tiene porque sus dobles nacen y mueren con el test. Esa carga es parte del costo, y a veces pesa más que los milisegundos: es trabajo de escribir, es fuente de fragilidad, y es la razón por la que las suites de integración mal mantenidas se vuelven un campo minado de tests que fallan por el estado y no por el código.

El criterio de decisión, con el costo en la mano

Ahora puedes responder la pregunta que cierra el módulo: para una costura dada, ¿integras o te basta con suponer? La decisión pesa dos cosas —el riesgo de la costura contra el costo de integrarla— y ahora tienes ambas medidas.

Integra cuando el riesgo de la costura justifica el costo. La costura del repositorio tiene riesgo real: serializa datos (el datetimestr), impone restricciones de esquema, tiene un comportamiento de ida-y-vuelta que un fake no reproduce. Ese riesgo —demostrado en la lección 6 con el TypeError— justifica pagar unos pocos tests de integración, preferentemente en :memory: (catorce veces más lento que el fake, pero aún rápido) y solo en disco cuando necesitas probar la persistencia de verdad. Pocos, bien elegidos, en las costuras que pueden hacer daño.

No integres cuando el costo no compra confianza. Una costura sin frontera —la lógica de price_cents, el Calendar en memoria— no gana nada con integración: no serializa, no tiene estado externo, no puede divergir. Probarla con un unit test rápido es todo lo que necesitas. Y para verificar que el fake no miente sobre el comportamiento de una costura, muchas veces un contrato (módulos 3-4) es más barato que una batería de integraciones: corre el mismo spec contra el fake y el real una vez, y te protege sin pagar disco en cada test.

La forma que sale de este criterio es la pirámide: muchos unit tests (baratos, la lógica), un contrato por costura de riesgo (barato, mantiene honesto al fake), y unos pocos tests de integración (caros, las juntas que de verdad pueden romperse). No es "integración contra unidad"; es cada herramienta en la proporción que su costo y su beneficio dictan. El costo que mediste en esta lección es exactamente lo que le da a la pirámide su forma.

Errores comunes

Integrar todo "para estar seguros" y acabar con una suite que nadie corre. Qué pasa: un equipo, escarmentado por un bug de integración, decide probar todo contra SQLite en disco. Por qué pasa: si la integración cazó el bug, la integración parece siempre mejor. Cómo detectarlo: si tu suite tarda minutos y la gente empieza a saltársela o a correr solo "sus" tests, invertiste la pirámide. Cómo corregirlo: mide, como en esta lección. Reserva la integración para las costuras de riesgo, usa :memory: cuando el disco no aporte, y apóyate en unit tests y contratos para lo demás. Una suite que tarda segundos se corre siempre; una que tarda minutos se abandona.

Usar disco cuando :memory: bastaba. Qué pasa: alguien escribe todas sus integraciones contra un archivo en disco por costumbre, pagando el fsync en cada test. Por qué pasa: "disco es más real". Cómo detectarlo: si tu integración no necesita probar la persistencia entre conexiones (sobrevivir a reabrir el archivo), el disco solo te está costando cincuenta veces más tiempo sin comprar confianza extra. Cómo corregirlo: usa sqlite3.connect(":memory:") por defecto —es SQLite real, prueba la serialización y el esquema, y es cincuenta veces más rápido—; reserva el disco para los pocos tests que específicamente prueban la persistencia física. Elige el recurso según lo que el test necesita demostrar, no por inercia.

No limpiar y culpar al código cuando la suite es flaky. Qué pasa: los tests de integración fallan de forma intermitente, según el orden, y el equipo pierde horas buscando un bug en el código de producción. Por qué pasa: el estado real compartido y sin limpiar produce fallos que parecen bugs del código. Cómo detectarlo: si un test pasa solo pero falla en la suite, o pasa y falla sin que el código cambie, sospecha del estado, no del código. Cómo corregirlo: cada integración debe sembrar su estado y limpiarlo, dejando la base como la encontró. El cómo hacerlo bien —aislar con rollback, fixtures que crean y destruyen la base— es el módulo 7; el mínimo honesto, mientras tanto, es no compartir estado entre tests sin borrarlo.

Ejercicios

Ejercicio 1 — Estima la suite. Con los números medidos —fake ≈ 0.6 µs/op, SQLite :memory:7.8 µs/op, SQLite en disco ≈ 400 µs/op—, estima cuánto tardaría una suite de 2000 tests si cada uno hiciera una operación save+get, en cada uno de los tres casos. ¿Qué te dice la comparación sobre la forma de la pirámide?

Ver solución

Multiplicando el costo por operación por los 2000 tests (una operación cada uno, como aproximación gruesa; en la práctica hay más por test, pero la proporción se mantiene):

  • Fake: 2000 × 0.6 µs ≈ 1.2 ms. Instantáneo; ni lo notas.
  • SQLite :memory:: 2000 × 7.8 µs ≈ 15.6 ms. Sigue siendo un parpadeo.
  • SQLite en disco: 2000 × 400 µs ≈ 800 ms, casi un segundo —y eso contando una sola operación por test; con varias operaciones y el commit de cada una, se dispara a varios segundos o más—.

La comparación dice justo por qué la pirámide tiene su forma. Con fakes, puedes tener miles de tests y la suite corre en un abrir y cerrar de ojos —por eso van en la base ancha—. Con SQLite en memoria, cientos siguen siendo baratos —la franja del medio—. Con disco, cada test cuesta de verdad, así que quieres pocos, solo donde la persistencia física importa —la punta angosta—. La forma de la pirámide no es una convención estética: es la consecuencia directa de este costo. Invertirla (muchos tests en disco) produce una suite que tarda minutos, que es una suite que la gente deja de correr.

Ejercicio 2 — ¿Por qué el disco es tan lento? El salto de :memory: (7.8 µs/op) a disco (400 µs/op) es de unas cincuenta veces, mucho mayor que el salto de fake a :memory:. Explica la causa técnica y qué cambiarías en el repositorio para medir el efecto.

Ver solución

La causa es el commit con su fsync. Cada save de nuestro SqliteBookingRepository termina con self._conn.commit(). En :memory:, un commit no tiene que ir a ningún disco —la base vive en RAM—, así que es barato. En disco, cada commit fuerza a SQLite a escribir los cambios en el archivo físico y a pedirle al sistema operativo que los sincronice de verdad al disco (un fsync), para garantizar que el dato sobrevive incluso a un corte de energía. Ese viaje al disco físico es lentísimo comparado con una operación en memoria —es la diferencia entre anotar algo en un papel que tienes en la mano y caminar hasta el archivero, guardarlo en su carpeta y confirmar que quedó—. Cinco mil fsync son los dos segundos.

Para medir el efecto, podrías hacer save sin commit en cada operación y confirmar una sola vez al final (una transacción que agrupa las cinco mil escrituras). Verías el tiempo en disco desplomarse, acercándose al de :memory:, porque habría un solo fsync en vez de cinco mil. Eso ilustra que el costo del disco no es "escribir los datos" sino "confirmar cada escritura al disco físico una por una". Controlar cuándo confirmas —agrupar escrituras en una transacción— es justo la palanca que el módulo 6 estudia como herramienta y el 7 usa para acelerar y aislar tests (escribir sin commit, hacer rollback al final).

Ejercicio 3 — Integrar o no, cuatro costuras. Para cada costura, decide si la cubrirías con integración (y en :memory: o en disco) o si un unit test o un contrato bastan, justificando con el costo y el riesgo: (a) el cálculo de price_cents(room, member, hours); (b) el ida-y-vuelta save/get del repositorio; (c) la persistencia de una reserva entre reinicios del proceso; (d) que el FakeBookingRepository no diverja del real en las cláusulas del repositorio.

Ver solución
  • (a) price_cents → unit test, sin integración. Es lógica pura: recibe datos, devuelve un entero, sin frontera, sin estado, sin serialización. No puede divergir entre un fake y lo real porque no hay "lo real": es una función. Un unit test rápido la cubre por completo; integrarla no compraría nada y solo costaría.
  • (b) save/get del repositorio → integración en :memory:. Tiene riesgo real (serialización del datetime, restricciones del esquema, ida-y-vuelta), demostrado en la lección 6. Justifica pagar la integración, pero en :memory: —catorce veces más lento que el fake, aún rápido— porque no necesitas la persistencia física para probar la serialización y el esquema. Unos pocos tests, bien elegidos.
  • (c) Persistencia entre reinicios → integración en disco. Aquí el disco aporta: lo que quieres probar es justamente que el dato sobrevive a cerrar y reabrir, que es imposible de verificar en :memory:. Pagas el costo del disco porque compra exactamente la confianza que buscas. Uno o dos tests, no más —es caro—.
  • (d) Que el fake no diverja → contrato (módulos 3-4), no una batería de integraciones. La pregunta "¿el fake se comporta como el real?" se responde mejor con un contrato: una sola batería parametrizada contra ambos, que corre casi tan rápido como un unit test y te protege de la divergencia. Escribir integraciones separadas para esto costaría más y garantizaría menos.

El patrón: unit para lógica sin frontera (a), contrato para "el fake no miente" (d), integración en :memory: para el riesgo de serialización/esquema (b), integración en disco solo para la persistencia física (c). Cada herramienta donde su costo compra la confianza que su costura necesita.

Resumen y siguiente paso

En esta lección pusiste la factura de la integración sobre la mesa, medida. Cronometraste la misma operación contra tres repositorios y viste la escalera: el fake en 0.6 µs/op, SQLite en memoria catorce veces más lento (7.8 µs/op), y SQLite en disco unas setecientas veces más lento que el fake (400 µs/op), con el commit/fsync como culpable del salto al disco. Y nombraste el costo que no se mide en milisegundos: sembrar y limpiar una base de datos que recuerda, con la fragilidad del estado compartido que produce suites flaky que fallan por el orden y no por el código. Con el crash test contra la simulación entendiste por qué la pirámide tiene su forma: muchas simulaciones baratas, pocos crash tests caros, cada uno donde su costo compra confianza. Y tienes el criterio de decisión completo: integra donde el riesgo de la costura justifica el costo; apóyate en unit y contrato donde no.

Antes de avanzar deberías poder: estimar el costo de una suite según el recurso que toca; explicar por qué el disco es tan lento (el fsync de cada commit) y cuándo :memory: basta; y decidir, con costo y riesgo en la mano, si una costura merece integración, contrato o solo un unit test.

Con este módulo cierras la primera prueba de integración real y el criterio para escribirla bien. Nombramos dos costos —las fronteras difíciles y el aislamiento del estado— y los dejamos pendientes a propósito. El módulo 6 toma el primero: las fronteras específicas a fondo —una transacción de SQLite con su commit y su rollback, un archivo real, una llamada HTTP a un http.server de la stdlib—, y cómo hacerlas rápidas y deterministas. El módulo 7 toma el segundo: los datos y el aislamiento —rollback para aislar, fixtures que crean y destruyen la base, mantener las pruebas repetibles—. La integración que aprendiste a escribir aquí, la vas a aprender a hacer en las fronteras difíciles y con el estado bajo control.

Recursos

  • time.perf_counter — documentación de Python — el reloj de alta resolución con el que cronometramos las operaciones en el benchmark; la herramienta para medir el costo de tu propia costura antes de decidir si la integras.
  • sqlite3 — Control de transacciones (documentación de Python) — la referencia de cómo SQLite maneja las transacciones y el commit, la clave de por qué el disco es tan lento (un fsync por commit) y la palanca que el módulo 7 usará para aislar y acelerar tests.
  • Documentación de pytest — Fixtures — la herramienta con la que el módulo 7 resolverá la carga de sembrar y limpiar que esta lección nombra como costo; útil para adelantar cómo se automatiza el setup y el teardown de una base de datos real.
  • Martin Fowler — TestPyramid — el marco que explica por qué la suite debe tener muchos unit tests y pocos de integración; la forma que, como viste, sale directamente del costo medido en esta lección.