Módulo 1: De la unidad a la integración: por qué

1. Presentación del módulo: Reservo estrena un repositorio real

Descripción

Bienvenido a la guía de contract e integration testing. Si vienes de la guía de dobles de prueba, ya sabes hacer algo poderoso: aislar una unidad de sus colaboradores. Aprendiste a reemplazar el PaymentGateway real por un doble que dice que cobró sin cobrar nada, el EmailSender por un spy que anota los envíos sin mandarlos, y la base de datos por un FakeBookingRepository que guarda las reservas en un dict en memoria. Con esos dobles, BookingService.book corre su lógica completa —validar, cobrar, guardar, confirmar— en centésimas de segundo, sin tocar una tarjeta ni llenar la bandeja de nadie. Es una herramienta que vas a seguir usando el resto de tu carrera.

Y tiene un punto ciego. Un doble no es lo real: es una suposición sobre cómo se comporta lo real, escrita por ti. Cuando escribiste el FakeBookingRepository, decidiste que save guardara el objeto tal cual y que get te lo devolviera idéntico. Es una suposición razonable —y la base de datos de verdad no funciona así—. SQLite no guarda objetos de Python: guarda texto y números en una tabla. Cuando le pides una reserva de vuelta, te la reconstruye desde esas columnas, y el datetime que guardaste vuelve como un str. El fake y el real no se comportan igual. Tu unit test, que solo habló con el fake, nunca lo notó. La producción, que habla con SQLite, sí. Esa grieta —entre lo que tu doble supone y lo que lo real hace— es el problema que las dos disciplinas de esta guía existen para cerrar.

Conexión con el módulo: esta lección es el mapa, no el territorio. Aquí todavía no vas a cerrar la brecha; vas a verla. Conocerás el Reservo de esta guía —el mismo BookingService con sus colaboradores, ahora con un SqliteBookingRepository real al lado del fake—, el orden de los ocho módulos y la frontera con las guías hermanas. Este módulo instala el porqué de la integración: por qué un unit test verde no basta, qué significa probar las piezas juntas, y por qué la costura entre dos componentes es a la vez la oportunidad de doblar y el riesgo de divergir. Los módulos 3 y 4 le dan la solución sistemática (el contrato); los módulos 5, 6 y 7 la llevan a los recursos reales (SQLite, archivos, HTTP). Todo empieza aquí, entendiendo qué se rompe cuando conectas la pieza de verdad.

Analogía: la maqueta y el edificio

Piensa en un arquitecto que diseña un edificio. Antes de construir, arma una maqueta: cada pieza —las columnas, las vigas, las losas— se dibuja y se calcula por separado, y cada una, aislada, es perfecta. La columna aguanta el peso que debe aguantar; la viga tiene la resistencia calculada; la losa cumple su norma. Revisas pieza por pieza y todo pasa. Pero un edificio no se cae porque una columna sea débil: se cae en las juntas, en el punto donde la viga se apoya en la columna y descubres que el perno es dos milímetros más corto, o que el acero de una pieza se dilata distinto que el de la otra. Cada pieza cumplía su norma por separado; nadie verificó que encajaran entre sí. Por eso, antes de que entre gente, se hace una prueba distinta: se carga la estructura ensamblada y se mira si las juntas aguantan. No prueba las piezas —eso ya se hizo—; prueba las uniones.

Un unit test prueba las piezas. price_cents calcula bien; refund_cents aplica las anclas correctas; book, con dobles, orquesta en el orden debido. Cada pieza, aislada, pasa. Un test de integración prueba las juntas: qué pasa cuando BookingService se apoya en el SqliteBookingRepository de verdad, cuando la reserva que una pieza escribe la lee la otra, cuando el datetime cruza la costura entre el objeto de Python y la tabla de SQLite. El edificio con todas las piezas correctas se cae en una junta mal calculada; el sistema con todos los unit tests verdes se rompe en una costura donde el doble suponía una cosa y lo real hace otra. Esta guía es aprender a probar las juntas.

Reservo, ahora con un repositorio de verdad

Recordemos el dominio. No cambia respecto a las guías hermanas:

# reservo/models.py — el dominio
from dataclasses import dataclass
from datetime import datetime


@dataclass
class Room:
    id: str
    name: str            # "Focus", "Studio", "Boardroom"
    capacity: int
    hourly_cents: int    # precio por hora, EN CENTAVOS (int) — nunca float


@dataclass
class Member:
    id: str
    name: str
    tier: str            # "basic" | "pro"  (pro tiene 20% de descuento)


@dataclass
class Booking:
    id: str
    room_id: str
    member_id: str
    start: datetime
    end: datetime
    status: str          # "confirmed" | "cancelled"
    price_cents: int     # lo que se cobró por esta reserva, en centavos

El orquestador tampoco cambia: BookingService(calendar, clock, payments, emails, repo) sigue coordinando sus colaboradores. book valida disponibilidad, cobra, guarda y confirma; cancel calcula el reembolso con el reloj, devuelve el dinero, guarda el estado cancelado y avisa. Los números-ancla que vas a ver una y otra vez: Focus cuesta 2500 centavos la hora; tres horas para una socia pro (20% de descuento) cuestan 6000 centavos; y al cancelar, refund_cents devuelve 6000 a 72 h del inicio, 3000 a 36 h y 0 a 12 h.

Lo nuevo de esta guía vive en un solo colaborador: el repositorio. Hasta ahora solo tenías uno, el fake:

# reservo/doubles.py — el repositorio de mentira (ya lo conoces)
class FakeBookingRepository:
    def __init__(self):
        self._store = {}

    def save(self, booking):
        self._store[booking.id] = booking

    def get(self, booking_id):
        return self._store[booking_id]   # KeyError si no existe

    def find_by_room(self, room_id):
        return [b for b in self._store.values() if b.room_id == room_id]

Ahora aparece su gemelo de verdad, el que hace de "el sistema real" en toda la guía. Usa sqlite3, que viene con Python —cero dependencias externas—, y guarda las reservas en una tabla:

# reservo/sqlite_repo.py — el repositorio REAL, con sqlite3 de la stdlib
from reservo.models import Booking

SCHEMA = """
CREATE TABLE IF NOT EXISTS bookings (
    id          TEXT PRIMARY KEY,
    room_id     TEXT NOT NULL,
    member_id   TEXT NOT NULL,
    start       TEXT NOT NULL,
    end         TEXT NOT NULL,
    status      TEXT NOT NULL,
    price_cents INTEGER NOT NULL
)
"""


class SqliteBookingRepository:
    def __init__(self, connection):
        self._conn = connection
        self._conn.execute(SCHEMA)

    def save(self, booking):
        self._conn.execute(
            "INSERT INTO bookings "
            "(id, room_id, member_id, start, end, status, price_cents) "
            "VALUES (?, ?, ?, ?, ?, ?, ?) "
            "ON CONFLICT(id) DO UPDATE SET "
            "room_id=excluded.room_id, member_id=excluded.member_id, "
            "start=excluded.start, end=excluded.end, "
            "status=excluded.status, price_cents=excluded.price_cents",
            (
                booking.id,
                booking.room_id,
                booking.member_id,
                booking.start.isoformat(),   # datetime -> texto ISO
                booking.end.isoformat(),
                booking.status,
                booking.price_cents,
            ),
        )
        self._conn.commit()

    def get(self, booking_id):
        row = self._conn.execute(
            "SELECT id, room_id, member_id, start, end, status, price_cents "
            "FROM bookings WHERE id = ?",
            (booking_id,),
        ).fetchone()
        if row is None:
            raise KeyError(booking_id)
        return Booking(
            id=row[0], room_id=row[1], member_id=row[2],
            start=row[3], end=row[4],       # sale como str, no como datetime
            status=row[5], price_cents=row[6],
        )

    def find_by_room(self, room_id):
        rows = self._conn.execute(
            "SELECT id, room_id, member_id, start, end, status, price_cents "
            "FROM bookings WHERE room_id = ?",
            (room_id,),
        ).fetchall()
        return [Booking(*r) for r in rows]

No hace falta que memorices el SQL —lo desglosaremos con calma en los módulos 5 y 6—. Fíjate solo en dos líneas que son toda la historia de este módulo. En save, guardamos booking.start.isoformat(): el datetime se convierte a texto para caber en una columna TEXT. En get, leemos start=row[3] sin convertirlo de vuelta: sale tal como se guardó, un str. El fake devolvía el datetime intacto; el real devuelve texto. Dos implementaciones de la misma interfaz BookingRepository (save, get, find_by_room), con un comportamiento que no coincide. Ese desajuste es el que un unit test no puede ver y un test de integración sí. (Esta versión de get todavía no reconstruye el datetime —lo dejamos roto a propósito para ver la brecha—; lo arreglamos en el módulo 3, cuando toque contract-testear el repositorio.)

Ejemplo trabajado: la brecha, de un vistazo

Antes de entrar en el porqué, veamos la brecha con nuestros propios ojos —adelanto del módulo, no lo escribas todavía—. Es el mismo book, probado dos veces. Una vez con el FakeBookingRepository (un unit test: la unidad aislada con un doble en memoria); otra vez con el SqliteBookingRepository real (un test de integración: BookingService y el repositorio de verdad, juntos). Las dos pruebas afirman exactamente lo mismo: que la reserva guardada tiene price_cents == 6000 y que su start es el que reservamos.

# tests/test_book_persists_correctly.py
import sqlite3
from datetime import datetime

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(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)          # Focus 3 h
CLOCK = datetime(2026, 3, 1, 9)


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


# --- unit: el repositorio es un doble en memoria ---
def test_book_persists_the_booking_with_fake_repo():
    repo = FakeBookingRepository()
    service = make_service(repo)

    booking = service.book(FOCUS, ANA, START, END)

    saved = repo.get(booking.id)
    assert saved.price_cents == 6000     # el cobro correcto
    assert saved.start == START          # la reserva se guardo intacta


# --- integracion: el repositorio es SQLite de verdad ---
def test_book_persists_the_booking_with_sqlite_repo():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    service = make_service(repo)

    booking = service.book(FOCUS, ANA, START, END)

    saved = repo.get(booking.id)
    assert saved.price_cents == 6000     # el cobro correcto
    assert saved.start == START          # <-- aqui divergen los dos repos

Dos tests, idénticos salvo por qué repositorio reciben. Corramos.

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

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

tests/test_book_persists_correctly.py::test_book_persists_the_booking_with_fake_repo PASSED [ 50%]
tests/test_book_persists_correctly.py::test_book_persists_the_booking_with_sqlite_repo FAILED [100%]

=================================== FAILURES ===================================
_______________ test_book_persists_the_booking_with_sqlite_repo ________________
...
        saved = repo.get(booking.id)
        assert saved.price_cents == 6000     # el cobro correcto
>       assert saved.start == START          # <-- aqui divergen los dos repos
E       AssertionError: assert '2026-03-10T09:00:00' == datetime.datetime(2026, 3, 10, 9, 0)

tests/test_book_persists_correctly.py:50: AssertionError
========================= 1 failed, 1 passed in 0.02s ==========================

Ahí está la brecha, sin retórica. El unit test con el fake pasa: la reserva guardada tiene el precio correcto y el start intacto. El test de integración con el repositorio real falla, y falla exactamente en la línea del start: '2026-03-10T09:00:00' (un str) no es igual a datetime.datetime(2026, 3, 10, 9, 0). El precio, un entero, cruzó la costura sin problema en ambos repos —por eso price_cents == 6000 pasa en los dos—; el datetime no. El fake nunca podía atrapar este bug, porque el fake es la suposición que resultó falsa. Solo la pieza real lo revela. Este es el módulo entero en dos tests: aprender a ver, entender y valorar esa diferencia.

El mapa de la guía: los ocho módulos

Vale la pena ver el recorrido completo, porque cada módulo apoya en el anterior. La guía te lleva de "sé aislar con dobles" a "sé verificar que las piezas reales encajan, y mantengo honestos a mis dobles con un contrato".

MóduloTemaLa idea en una frase
1De la unidad a la integración (este)Por qué existe la integración: las piezas aisladas pasan, las juntas fallan
2El doble que mintióUna divergencia concreta: el fake devuelve None donde el real lanza, y el bug pasa el unit test
3Contract testing: consumer y providerEl contrato: un spec compartido que ambos lados cumplen, hecho a mano
4Verificar el contrato desde los dos ladosEl test del consumer y el del provider; correr la misma batería contra el fake y el real
5Integración de componentes reales juntosBookingService + SqliteBookingRepository de verdad, cruzando la costura
6Fronteras reales: DB, archivos, HTTPUna transacción de SQLite, un archivo, una llamada a un http.server de la stdlib
7Datos y aislamiento en integraciónRollback para aislar, fixtures de recursos reales, mantener las pruebas repetibles
8CapstoneUn contrato consumer-driven + su verificación contra el fake y el real + una suite de integración

Este módulo es el fundamento conceptual. Si entiendes bien qué es una costura, por qué un doble puede mentir, y cuándo probar las juntas vale su costo, todo lo demás es aprender las herramientas concretas para hacer lo que ya sabrás que hay que hacer.

Lo que esta guía NO toca (la frontera)

Conviene marcar los límites desde ahora, porque hay temas vecinos que parecen de aquí y son de otra guía del ecosistema de Testing.

Los dobles no se re-enseñan. Esta guía asume que ya sabes construir y usar un stub, un spy, un mock y un fake, y que entiendes la costura y la inyección de dependencias. Todo eso es la guía hermana test-doubles-and-test-data-guide. Aquí arrancamos desde "ya sé doblar; ahora quiero verificar que mi doble no miente y probar contra lo real".

Probar una app web completa no es de aquí. Esta guía trabaja con los servicios en proceso de Reservo más un repositorio SQLite real (stdlib) y, a lo sumo, un límite HTTP mínimo con http.server de la stdlib. No enseña FastAPI, ni rutas, ni el ciclo petición-respuesta de un framework web. Probar una aplicación backend de verdad —con su framework, su servidor, su HTTP de punta a punta— es testing-backend-applications-guide. Nosotros te enseñamos a probar la costura; ella te enseña a probar la app.

Los fundamentos y el TDD son testing-fundamentals-and-tdd-guide; el property-based testing (Hypothesis) es property-based-and-advanced-testing-guide; y correr todo esto en CI es testing-in-cicd-guide. Cada vez que un tema roce esos bordes, lo enlazamos y seguimos.

Errores comunes

Creer que "todos los unit tests en verde" significa "el sistema funciona". Qué pasa: la suite unitaria está impecable, verde de punta a punta, y alguien concluye que el sistema está sano y despliega. Por qué pasa: es intuitivo pensar que si cada pieza pasa, el todo pasa. Cómo detectarlo: pregúntate si algún test toca la pieza real en la costura que te preocupa —la base de datos, la red—. Si todos usan dobles, ninguna junta se probó. Cómo corregirlo: los unit tests prueban las piezas; hace falta al menos un test que pruebe las juntas con lo real. Ese es el test de integración, y por eso existe esta guía.

Confundir "unit test" con "test rápido y pequeño". Qué pasa: alguien llama unit test a cualquier test corto, aunque toque SQLite o la red, mientras corra rápido. Por qué pasa: "unit" suena a "poco código", no a "aislado". Cómo detectarlo: si el test podría fallar por algo que no es tu código —el disco lleno, un archivo bloqueado, una fecha distinta—, no está aislado, y no es un unit test. Cómo corregirlo: la palabra clave es aislamiento, no tamaño. Un unit test aísla la unidad con dobles; en el momento en que dejas entrar una pieza real, cruzaste a integración —lo cual está bien, siempre que sepas que lo hiciste—.

Creer que la integración reemplaza a los unit tests. Qué pasa: alguien, escarmentado por un bug de costura, decide "probar todo contra lo real" y tira los dobles a la basura. Por qué pasa: si lo real atrapó el bug, lo real parece siempre mejor. Cómo detectarlo: si tu suite tarda minutos, es frágil y falla por el clima de la infraestructura, te pasaste al otro extremo. Cómo corregirlo: no es integración contra unidad; es integración más unidad. Los unitarios te dan velocidad y precisión; los de integración, confianza en las juntas. La pirámide de la lección 3 es exactamente la proporción entre los dos.

Ejercicios

Ejercicio 1 — ¿Unidad o integración? Para cada uno de estos tests de Reservo, di si es un test unitario (unidad aislada con dobles) o de integración (dos o más piezas reales juntas): (a) probar price_cents(FOCUS, ANA, 3) == 6000 directo; (b) probar book con FakeBookingRepository, StubPaymentGateway y SpyEmailSender; (c) probar book con SqliteBookingRepository real y verificar que la fila quedó en la tabla; (d) probar que SqliteBookingRepository.get de un id ausente lanza KeyError.

Ver solución
  • (a) price_cents directo — unitario. Es lógica pura: recibe datos, devuelve un entero, sin colaboradores. Ni siquiera necesita dobles. Es el unit test más limpio que existe.
  • (b) book con tres dobles — unitario. BookingService es la unidad; sus colaboradores están todos doblados (fake, stub, spy). Nada real cruza la costura: se prueba la lógica de orquestación aislada. Rápido y determinista.
  • (c) book con SqliteBookingRepository real — integración. Aquí BookingService y el repositorio de verdad trabajan juntos: la reserva que el servicio crea cruza la costura hacia SQLite y se guarda en una tabla real. Se prueban las dos piezas y su junta.
  • (d) SqliteBookingRepository.get de un id ausente — integración. Aunque solo interviene el repositorio, se está probando el componente real contra su recurso real (la base de datos), no un doble. Es una integración estrecha: una sola pieza real contra su frontera. (La distinción "estrecha contra amplia" es la lección 6.)

La regla que estás descubriendo: en cuanto una pieza real aparece en la costura —el repositorio de SQLite, la red, el disco—, dejaste de aislar y pasaste a integrar. Con solo dobles, es unitario; con al menos una pieza real cruzando su costura, es integración.

Ejercicio 2 — ¿Por qué el price_cents sí y el start no? En el ejemplo trabajado, el test de integración falló en start pero la aserción price_cents == 6000 pasó, aun contra el repositorio real. Explica por qué el precio cruzó la costura sin problema y el datetime no.

Ver solución

Porque price_cents es un entero, y SQLite tiene un tipo nativo para enteros: la columna price_cents INTEGER guarda 6000 como número y te devuelve 6000 como número. El valor va y vuelve por la costura sin cambiar de tipo, así que saved.price_cents == 6000 es int == int y pasa.

El start, en cambio, es un datetime, y SQLite no tiene un tipo nativo para datetime. Hay que serializarlo: en save lo convertimos a texto con .isoformat() para que quepa en la columna start TEXT. Al leerlo, sale como texto —un str— y nadie lo convierte de vuelta a datetime. Así que saved.start == START es str == datetime, que es False.

La lección de fondo: la costura convierte los datos de un formato a otro (objeto de Python ↔ fila de tabla), y en esa conversión los tipos que no tienen equivalente nativo cambian de forma. El fake nunca serializa nada —guarda el objeto tal cual—, así que jamás expone este problema. Solo la pieza real, que sí serializa, lo revela. Por eso un doble puede mentir precisamente sobre lo que más importa probar.

Ejercicio 3 — El unit test que no bastó. Imagina que tu equipo tiene 200 unit tests de Reservo, todos verdes, todos con FakeBookingRepository. Se despliega a producción, que usa SqliteBookingRepository, y una hora después una pantalla que muestra "Tu reserva empieza el {start}" se ve rota. Sin conocer aún la solución (los módulos 3 a 7), explica: ¿por qué los 200 tests verdes no lo previnieron, y qué clase de test faltaba?

Ver solución

Los 200 tests verdes no lo previnieron porque ninguno tocó la pieza real en la costura donde estaba el bug. Todos usaban el FakeBookingRepository, que devuelve el datetime intacto, así que todos veían un start que era un datetime de verdad y una pantalla que se formateaba bien. El bug —que SqliteBookingRepository.get devuelve start como str— vive exactamente en la diferencia entre el fake y el real, y ningún test que use solo el fake puede verlo. Los unit tests probaron la pieza BookingService a la perfección; nadie probó la junta entre BookingService y la base de datos de verdad.

Lo que faltaba era un test de integración: al menos una prueba que conectara BookingService (o directamente el repositorio) con el SqliteBookingRepository real y verificara que una reserva guardada y recuperada conserva su start en la forma que la pantalla espera. Ese único test —cruzando la costura con la pieza real— habría fallado en rojo antes del despliegue, señalando la línea exacta. La guía entera es cómo escribir ese test, y cómo mantener honesto al fake con un contrato para que la próxima divergencia también salte.

Resumen y siguiente paso

En esta lección diste el salto que define la guía: de aislar una unidad con dobles a verificar que las piezas reales funcionan juntas. Conociste el Reservo de esta guía —el mismo BookingService, ahora con un SqliteBookingRepository real junto al FakeBookingRepository que ya usabas— y viste, con la maqueta y el edificio, que un sistema con todas las piezas correctas se rompe en las juntas. Y viste la brecha con salida real: el mismo book que pasa en verde con el fake falla con el repositorio real, exactamente en la línea del datetime que cruza la costura y cambia de forma.

Antes de avanzar deberías poder: distinguir un test unitario (dobles, aislado) de uno de integración (piezas reales juntas); explicar por qué "todos los unit tests verdes" no garantiza que el sistema funcione; y contar, con el ejemplo del start, cómo un doble puede mentir sobre justo lo que más importa probar.

Lo que sigue es afinar la primera de esas ideas hasta dejarla sin ninguna ambigüedad. En la lección 2 vamos a poner las dos definiciones lado a lado —qué es exactamente un test unitario, qué es exactamente uno de integración, qué pregunta responde cada uno— con el mismo book visto por ambos. Entender esa distinción con precisión es el cimiento sobre el que se para todo lo demás.

Recursos

  • Documentación de pytest — Getting Started — la puerta de entrada oficial a pytest, la herramienta con la que ejecutamos y citamos cada salida de la guía; útil para reconfirmar tu entorno (Python 3.14, pytest 9.1.1) antes de empezar.
  • sqlite3 — DB-API para SQLite (documentación de Python) — la referencia del módulo de la stdlib que hace de "sistema real" en toda la guía; en particular, la sección sobre cómo SQLite maneja (y no maneja) los tipos de Python, que es la raíz de la divergencia del datetime.
  • test-doubles-and-test-data-guide — la guía hermana donde construiste el FakeBookingRepository y los demás dobles; si algo de stubs, spies o fakes te quedó flojo, es el lugar para repasarlo antes de seguir.
  • testing-backend-applications-guide — la guía hermana del otro lado de la frontera: probar una app web de verdad (framework, rutas, HTTP de punta a punta), que esta guía deja deliberadamente fuera.