Módulo 4: Verificar el contrato desde ambos lados
4. La misma batería contra el fake y el real
Descripción
Tienes las dos sillas: el test del consumer (lección 2) contra el fake, el test del provider (lección 3) contra SQLite. Podrías salir de aquí pensando que son dos cosas separadas —una batería para el fake, otra para el real—. No lo son. Son una sola batería de contrato, corrida contra los dos providers. Y ese "dos" no es un detalle de conveniencia: es toda la garantía. La tesis de esta lección es tan corta como poderosa: si el fake y el real pasan exactamente la misma suite, el fake no puede estar mintiendo sobre lo que el contrato cubre.
Vuelve al problema del módulo 1, la razón de ser de la guía: un unit test verde con un fake puede esconder una integración rota, porque el fake diverge del real justo en la costura, y nadie lo nota. La pregunta que quedó abierta era: "¿cómo garantizo que mi fake se comporta como el real ahí?". Esta lección da la respuesta mecánica. No revisas el fake a ojo, ni prohíbes los fakes, ni pruebas todo contra lo real. Escribes el comportamiento una vez —el contrato— y lo corres contra ambos. Si divergen en cualquier cláusula, la batería lo grita: un lado verde, el otro rojo, en la cláusula exacta. Mientras los dos estén verdes en la misma suite, tienes una prueba —no una esperanza— de que el fake no miente.
Conexión con el módulo: las lecciones 2 y 3 te dieron los lados por separado para que entendieras cada punto de vista; esta los reúne y muestra que la separación era conceptual, no física. Una batería, dos providers. Es también el pivote del módulo: entender por qué correrla contra ambos es la garantía te prepara para las dos lecciones que siguen, donde esa garantía cobra vida —la 5 caza un provider que rompe el contrato, la 6 caza un consumer que supone de más—. Aquí ves el mecanismo en reposo, verde; en las próximas lo ves disparándose, rojo.
Analogía: la misma prueba de sabor a dos cocineros
Imagina una cadena de restaurantes con una receta insignia —digamos, su salsa de la casa— que debe saber idéntica en cada sucursal. La cadena no confía en que cada cocinero "cocine parecido"; escribe una prueba de sabor estandarizada: una hoja con mediciones objetivas —tanto de acidez, tanto de sal, este color, esta textura al cubrir la cuchara—. Esa hoja es el contrato de la salsa.
Ahora, lo importante: la prueba de sabor solo sirve si se aplica a las dos salsas a la vez, con la misma hoja. Si el evaluador probara la salsa del cocinero A con una hoja y la del cocinero B con otra distinta, no probaría nada sobre si saben igual. La garantía de que las dos salsas son intercambiables nace de que ambas pasan la misma prueba. Si una tiene más acidez de la que la hoja permite, la prueba la reprueba —y sabes al instante cuál sucursal se desvió y en qué medida—. Mientras las dos pasen la misma hoja, un comensal no puede distinguir en qué sucursal está: esa es la promesa.
El FakeBookingRepository y el SqliteBookingRepository son los dos cocineros; la batería de contrato es la hoja de prueba de sabor; y correrla contra los dos con la misma batería es lo que garantiza que las dos "salsas" —guardar, leer, lanzar, filtrar— saben igual. Un fake que devuelva un str donde el real devuelve un datetime es una salsa con la acidez cambiada: la misma hoja, aplicada a ambos, lo reprueba de inmediato. La intercambiabilidad no se supone; se prueba, y se prueba corriendo una hoja contra dos cocineros.
Ejemplo trabajado: una batería, dos providers, ocho veredictos
Aquí está el mecanismo completo. La pieza que hace "una batería, dos providers" es la fixture parametrizada: una fixture llamada repo que declara params=["fake", "sqlite"]. Pytest, al ver esa fixture, corre cada test que la pida una vez por cada valor del params. Cuatro tests × dos valores = ocho ejecuciones. El truco es que los cuatro test_... no saben —ni les importa— qué provider les tocó: piden repo y trabajan contra él. La misma cláusula, dos implementaciones.
# tests/test_repository_contract.py — una batería, dos providers
import sqlite3
from datetime import datetime
import pytest
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)
def a_booking(id="bk-1", room_id="focus", status="confirmed", price_cents=6000):
return Booking(id=id, room_id=room_id, member_id="m-ana",
start=START, end=END, status=status, price_cents=price_cents)
# ESTA es la pieza clave: una fixture, dos providers.
# pytest corre cada test que pida `repo` una vez por cada valor de params.
@pytest.fixture(params=["fake", "sqlite"])
def repo(request):
if request.param == "fake":
return FakeBookingRepository()
return SqliteBookingRepository(sqlite3.connect(":memory:"))
def test_save_then_get_returns_the_same_booking(repo):
repo.save(a_booking())
got = repo.get("bk-1")
assert got.id == "bk-1"
assert got.room_id == "focus"
assert got.start == START # el campo donde fake y real PODRIAN divergir
assert got.price_cents == 6000
assert got.status == "confirmed"
def test_get_of_a_missing_id_raises(repo):
with pytest.raises(KeyError):
repo.get("does-not-exist")
def test_saving_the_same_id_twice_updates_not_duplicates(repo):
repo.save(a_booking(status="confirmed"))
repo.save(a_booking(status="cancelled"))
got = repo.get("bk-1")
assert got.status == "cancelled"
assert len(repo.find_by_room("focus")) == 1
def test_find_by_room_returns_only_that_rooms_bookings(repo):
repo.save(a_booking(id="bk-1", room_id="focus"))
repo.save(a_booking(id="bk-2", room_id="studio"))
ids = {b.id for b in repo.find_by_room("focus")}
assert ids == {"bk-1"}
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_repository_contract.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 8 items
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[fake] PASSED [ 12%]
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[sqlite] PASSED [ 25%]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[fake] PASSED [ 37%]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[sqlite] PASSED [ 50%]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[fake] PASSED [ 62%]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[sqlite] PASSED [ 75%]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[fake] PASSED [ 87%]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[sqlite] PASSED [100%]
============================== 8 passed in 0.01s ===============================
Léelo como cuatro pares. Cada cláusula aparece dos veces, [fake] justo encima de [sqlite], y ambas pasan. Ese emparejamiento es la garantía hecha visible: la línea ...returns_the_same_booking[fake] PASSED y la línea ...returns_the_same_booking[sqlite] PASSED, juntas, prueban que el fake y el real están de acuerdo en la cláusula de guardar-y-leer —incluido el got.start == START, el campo del datetime donde en el módulo 1 divergían—. No es que el fake pase su propia versión y el real la suya; es que los dos pasan la misma línea de código de aserción, con el mismo provider enchufado en el mismo lugar. Ocho verdes = cuatro promesas, cada una honrada por los dos lados.
Por qué "contra ambos" es la garantía —y "contra uno" no es nada
Detengámonos en el corazón de la lección, porque es sutil y es todo. Imagina que corrieras la batería contra un solo provider. ¿Qué probaría?
- Solo contra el fake: probaría que el fake cumple el contrato. Pero el fake es tu suposición sobre lo real; que tu suposición sea consistente consigo misma no dice nada sobre si coincide con SQLite. Es como verificar que tu maqueta cumple tus propios planos: cierto, y vacío respecto al edificio.
- Solo contra el real: probaría que SQLite cumple el contrato. Útil, pero no cierra la brecha del módulo 1, porque la brecha no era "¿SQLite cumple?" sino "¿mi fake se comporta como SQLite?". Verificar solo el real deja al fake sin auditar —y el fake es el que usan tus cientos de unit tests rápidos—.
La garantía nace únicamente de correr la misma batería contra ambos. Es un argumento de transitividad: si el fake pasa el contrato C, y el real pasa el mismo contrato C, entonces el fake y el real coinciden en todo lo que C afirma. Por tanto, cualquier unit test que use el fake y se apoye solo en C está apoyándose en algo que el real también cumple. El fake deja de ser "una suposición que ojalá sea fiel" y pasa a ser "una implementación demostrablemente equivalente al real dentro del contrato". Esa es la frase que cierra la brecha: el fake no puede mentir sobre lo que el contrato cubre, porque el mismo contrato que el fake pasa lo pasa el real.
Nota el matiz final: "sobre lo que el contrato cubre". La garantía es tan amplia como el contrato. Si una cláusula no está en la batería —digamos, el orden de find_by_room—, el fake y el real podrían divergir ahí sin que nadie lo note, porque la hoja de prueba de sabor no mide ese sabor. Por eso el contrato debe cubrir cada promesa de la que un consumer real dependa; una promesa fuera del contrato es una promesa sin garantía. (Esa grieta —una promesa no escrita— es exactamente lo que la lección 6 explota desde el lado del consumer.)
La anatomía de la parametrización, en detalle
Vale la pena entender el mecanismo de pytest que hace posible todo esto, porque lo vas a usar sin parar. Tres piezas:
-
La fixture con
params.@pytest.fixture(params=["fake", "sqlite"])le dice a pytest: "esta fixture tiene dos versiones". Pytest la ejecuta una vez por cada valor, y en cada ejecuciónrequest.paramvale el valor de turno ("fake", luego"sqlite"). El cuerpo de la fixture usa ese valor para construir el provider adecuado. La fixture es la única parte del archivo que sabe que hay dos implementaciones. -
La inyección por nombre. Cada test declara un parámetro llamado
repo. Pytest ve que existe una fixture llamadarepoy se la pasa —"inyección de dependencia" en el sentido de pytest—. El test recibe un repositorio ya construido y trabaja contra él, ciego a cuál le tocó. Por eso los cuatro tests son idénticos para ambos providers: no hay ni unifde implementación en ellos. -
Los ids
[fake]/[sqlite]. Cuando una fixture está parametrizada, pytest añade el valor delparamal id de cada test, entre corchetes. Por eso vestest_get_of_a_missing_id_raises[fake]y...[sqlite]. Ese id no es cosmético: es tu mapa de diagnóstico. Cuando un test falle, el corchete te dirá cuál provider falló la cláusula —el mecanismo entero de las lecciones 5 y 6 depende de leer ese corchete—.
La belleza del arreglo es que añadir una promesa o un provider es local. Una promesa nueva = un test_... más (corre contra ambos automáticamente). Un provider nuevo = un valor más en params (todas las cláusulas lo verifican automáticamente). El contrato crece sin que tengas que duplicar nada, porque la batería y los providers están desacoplados por la fixture.
Errores comunes
Escribir dos baterías, una por provider. Qué pasa: alguien copia los cuatro tests, hace una copia "para el fake" y otra "para SQLite", y las mantiene por separado. Por qué pasa: parece más explícito tener un archivo por provider. Cómo detectarlo: si tienes dos archivos con las mismas aserciones y distinto provider, y hay que editar los dos cuando cambia una cláusula, estás duplicando. Cómo corregirlo: una sola batería, una fixture parametrizada. La duplicación no solo es más trabajo: rompe la garantía, porque nada obliga a que las dos copias afirmen exactamente lo mismo, y en cuanto divergen (un assert de más en una), dejan de probar que los providers coinciden. La garantía viene de que sea la misma aserción para ambos.
Correr habitualmente un solo lado. Qué pasa: por costumbre, el equipo corre -k fake (rápido) en el día a día y "de vez en cuando" la batería completa. Por qué pasa: el lado del fake es más rápido y da un verde cómodo. Cómo detectarlo: si el lado [sqlite] no corre en cada cambio relevante, un breaking change del provider puede vivir sin ser visto hasta que alguien corra la batería entera. Cómo corregirlo: el valor está en correr ambos lados juntos, que es lo que emparejaba las promesas. Está bien usar -k para inspeccionar un lado (lección 3), pero el veredicto que protege el deploy es la batería completa, con sus ocho.
Creer que la garantía cubre lo que el contrato no dice. Qué pasa: los ocho verdes dan la sensación de que "el fake y el real son idénticos", y alguien se apoya en un comportamiento que la batería no verifica (el orden de find_by_room, que get devuelva el mismo objeto y no una copia). Por qué pasa: "ocho verdes" se siente como equivalencia total. Cómo detectarlo: pregúntate si la cosa en que te apoyas tiene un assert en la batería. Si no lo tiene, no está garantizada. Cómo corregirlo: la garantía es exactamente tan amplia como el contrato escrito. Para extenderla, añade la cláusula (un test_... más); para no caer en la trampa, no supongas equivalencia fuera de lo que la batería afirma. La lección 6 es el caso vivo de este error.
Ejercicios
Ejercicio 1 — La transitividad, en tus palabras. Explica, sin usar la palabra "contrato" más de una vez, por qué correr la misma batería contra el fake y el real garantiza que un unit test que usa el fake no esconde una divergencia con el real —siempre que el unit test se apoye solo en lo que la batería verifica—.
Ver solución
Es un encadenamiento de tres hechos. Primero: la batería verifica un conjunto de comportamientos B (guardar-y-leer devuelve lo mismo, id ausente lanza, etc.). Segundo: el fake pasa la batería, así que el fake cumple B. Tercero: el real pasa la misma batería, así que el real cumple B. De ahí se sigue que, en todo lo que B describe, el fake y el real hacen lo mismo —no "parecido", lo mismo, porque pasaron las mismas aserciones—. Ahora toma un unit test que use el fake y se apoye solo en comportamientos de B: como el real cumple B igual que el fake, ese unit test seguiría siendo cierto si le enchufaras el real. Por tanto no puede haber una divergencia escondida dentro de B: el fake no miente sobre B. La condición "siempre que se apoye solo en lo que la batería verifica" es esencial, porque fuera de B (un orden no medido, por ejemplo) la garantía no llega, y ahí sí podrían divergir.
Ejercicio 2 — Predice la salida tras romper una cláusula. Sin correr nada: si alguien rompiera FakeBookingRepository.get para que devolviera una copia de la reserva en vez del objeto guardado, pero conservando todos los campos iguales, ¿cambiaría alguno de los ocho verdes? ¿Y si además una cláusula nueva verificara repo.get("bk-1") is repo.get("bk-1") (identidad, no igualdad)?
Ver solución
Con las cuatro cláusulas actuales: ningún verde cambia. Las aserciones de la batería comparan por igualdad (got.id == "bk-1", got.start == START, got.status == "cancelled"): miran los valores de los campos, no la identidad del objeto. Una copia con los mismos campos es igual (==) al original, así que todas las cláusulas siguen pasando. Los ocho verdes se mantienen. Y esto es correcto: el contrato no promete que get devuelva el mismísimo objeto, solo que devuelva una reserva con los campos correctos.
Con la cláusula nueva de identidad (is): el lado [fake] de esa cláusula pasaría solo si el fake devuelve el mismo objeto, y el lado [sqlite] fallaría siempre. SQLite nunca puede devolver el mismo objeto de Python: reconstruye un Booking nuevo desde las columnas en cada get, así que repo.get("bk-1") is repo.get("bk-1") es False para el real. Si añadieras esa cláusula, [sqlite] se pondría rojo. Lo cual revela una lección importante: no metas en el contrato una promesa que el real no puede cumplir. La identidad de objeto es un detalle del fake (que guarda referencias en un dict), no una promesa que un repositorio deba honrar. Un contrato debe afirmar solo lo que toda implementación razonable puede cumplir; pedir identidad rompería al real y no aportaría nada que un consumer sensato necesite.
Ejercicio 3 — Extiende la garantía. Un consumer nuevo depende de que find_by_room devuelva las reservas ordenadas por start ascendente. Hoy el contrato no lo promete, así que la garantía no lo cubre. Escribe la cláusula que lo añadiría a la batería, y explica qué tendrían que hacer el fake y el real para pasarla —y qué pasaría si uno de los dos no lo hace—.
Ver solución
La cláusula nueva:
def test_find_by_room_returns_bookings_sorted_by_start(repo):
repo.save(a_booking(id="bk-late", room_id="focus", start=datetime(2026, 3, 10, 11)))
repo.save(a_booking(id="bk-early", room_id="focus", start=datetime(2026, 3, 10, 9)))
starts = [b.start for b in repo.find_by_room("focus")]
assert starts == sorted(starts) # promete orden ascendente por start
(Requiere que a_booking acepte un start variable; es un ajuste menor del helper.)
Qué tendría que hacer cada provider para pasarla:
- El real: añadir
ORDER BY starta la consulta defind_by_room(SELECT ... WHERE room_id = ? ORDER BY start). Sin esa cláusula SQL, SQLite devuelve las filas en un orden no garantizado, y la aserción podría fallar. - El fake: ordenar la lista antes de devolverla (
sorted(..., key=lambda b: b.start)), porque el dict preserva el orden de inserción, no el destart.
Qué pasaría si uno no lo hace: su lado de la cláusula se pondría rojo, y sabrías exactamente cuál. Si añades la cláusula pero solo arreglas el real, test_find_by_room_returns_bookings_sorted_by_start[fake] falla; si solo arreglas el fake, falla [sqlite]. Ese rojo es la garantía trabajando: en el momento en que escribes la promesa como cláusula, la batería exige que ambos la cumplan, y delata al que no. Así se extiende la garantía: la promesa que antes vivía como suposición tácita del consumer (y era una bomba de tiempo, lección 6) pasa a ser una cláusula verificada contra los dos providers.
Resumen y siguiente paso
En esta lección reuniste las dos sillas y viste que no eran dos baterías, sino una, corrida contra los dos providers. La fixture parametrizada (params=["fake", "sqlite"]) hace que cada cláusula corra dos veces, y los ocho verdes se leen como cuatro pares —[fake] y [sqlite] uno junto al otro—, cada par probando que los dos providers están de acuerdo en esa promesa. Con la prueba de sabor a dos cocineros entendiste que la intercambiabilidad no se supone, se prueba, y se prueba aplicando una hoja a ambas salsas. Y llegaste a la frase que cierra la brecha del módulo 1: el fake no puede mentir sobre lo que el contrato cubre, porque el mismo contrato que el fake pasa lo pasa el real —una garantía por transitividad, tan amplia como el contrato escrito, y ni un ápice más—.
Antes de avanzar deberías poder: explicar por qué la garantía nace de correr la batería contra ambos y no contra uno; leer la salida de ocho como cuatro pares; describir las tres piezas de la parametrización (fixture con params, inyección por nombre, ids con corchetes); y reconocer que la garantía no cubre lo que el contrato no afirma.
Hasta aquí viste el mecanismo en reposo: verde, tranquilo, honrado por ambos lados. Lo que sigue es verlo dispararse. La lección 5 introduce un cambio en el provider —el get que devuelve None en vez de lanzar— y corre la batería: el lado [sqlite] se pone rojo en la cláusula exacta, antes de desplegar, mientras el [fake] sigue verde. Es el pago de oro de la guía: cazar un cambio incompatible antes de que llegue a producción.
Recursos
- Documentación de pytest — Parametrizar fixtures — la referencia exacta de
@pytest.fixture(params=[...])yrequest.param, el mecanismo que hace "una batería, dos providers"; la base técnica de toda la lección. - Documentación de pytest — Parametrizar tests (
ids) — cómo pytest genera y cómo puedes personalizar los ids[fake]/[sqlite]que aparecen entre corchetes y sirven de mapa de diagnóstico. - docs.pact.io — How Pact works — el marco conceptual de por qué un contrato compartido, verificado por ambos lados, garantiza la compatibilidad; la versión entre servicios de la garantía por transitividad de esta lección.
- Módulo 1 de esta guía — Un unit test verde puede esconder una integración rota — la brecha que esta lección cierra mecánicamente; útil para releer el problema justo antes de ver cómo el contrato lo resuelve.