Módulo 3: Contract testing: el contrato consumer/provider

4. La batería parametrizada contra el fake y SQLite

Descripción

Tienes el qué del contrato (comportamiento, lección 2) y el quién (el consumer, lección 3). Esta lección entrega el cómo: el mecanismo concreto, en pytest, que hace que una sola batería de tests corra contra el FakeBookingRepository y contra el SqliteBookingRepository sin duplicar una línea. Ese mecanismo es la fixture parametrizada, y es tan importante que merece que lo desarmemos hasta el último tornillo. Ya lo has visto pasar tres lecciones seguidas —la línea @pytest.fixture(params=["fake", "sqlite"])—; aquí dejas de verla como magia y entiendes exactamente qué hace pytest cuando la encuentra.

La idea de fondo es simple y potente. Un test normal recibe un valor fijo; un test que recibe una fixture parametrizada se ejecuta una vez por cada parámetro de esa fixture, con el valor correspondiente inyectado. Si la fixture tiene dos parámetros —"fake" y "sqlite"— cada test que la use corre dos veces. Cuatro cláusulas por dos implementaciones dan ocho casos, y pytest los etiqueta con el parámetro entre corchetes: [fake] y [sqlite]. Esa etiqueta no es decorado: es tu forma de leer, de un vistazo, contra qué implementación pasó o falló cada cláusula. Y esa multiplicación —una batería, N providers— es exactamente lo que convierte "espero que el fake y el real coincidan" en "coinciden, o hay un rojo con nombre y apellido".

Conexión con el módulo: esta lección es la bisagra mecánica del módulo. Las lecciones 1 a 3 establecieron el concepto; de la 5 en adelante lo cobran. Pero para cobrarlo —para cazar la divergencia del módulo 2 en la lección 5, para distinguir estado de interacción en la 6— necesitas entender cómo se corre una batería contra varios providers, porque ese es el instrumento que usaremos una y otra vez. Aquí lo dominas: la fixture con params, el objeto request, los ids [fake]/[sqlite], y por qué "una batería, dos providers" es la garantía técnica de que el fake no puede mentir sobre lo que el contrato cubre.

Analogía: una sola línea de inspección, muchos autos

Piensa en la línea de inspección técnica vehicular. Hay un protocolo de pruebas —frenos, luces, emisiones, holgura de la dirección— y por esa misma línea pasan autos de todas las marcas: un sedán, una camioneta, un deportivo. El inspector no reescribe el protocolo para cada auto; corre el mismo protocolo, y cada auto lo pasa o lo reprueba según cumpla o no. Al final, el reporte dice para cada auto qué pruebas pasó: "sedán: frenos OK; camioneta: frenos OK; deportivo: emisiones REPROBADO". Un solo protocolo, muchos vehículos, un veredicto por cada uno.

La fixture parametrizada es esa línea de inspección, y las implementaciones son los autos. El protocolo —las cuatro cláusulas del contrato— se escribe una vez. Los "autos" —el fake y el real— se declaran en params. Pytest hace pasar a cada uno por todas las pruebas y te da un reporte con el nombre de cada uno entre corchetes: [fake] pasó las cuatro, [sqlite] pasó las cuatro. Si un día metes un auto que no cumple —el fake buggy—, el reporte lo dirá exactamente igual que la línea de inspección delata al deportivo con las emisiones altas: [buggy-fake] REPROBADO en la prueba que falla. Un protocolo, muchas implementaciones, un veredicto por cada una. Eso es una batería parametrizada.

Anatomía de la fixture parametrizada

Miremos la pieza central línea por línea. Es corta, y cada parte hace un trabajo:

import pytest

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


@pytest.fixture(params=["fake", "sqlite"])   # (1) dos parametros: dos "autos"
def repo(request):                            # (2) recibe 'request'
    if request.param == "fake":               # (3) request.param es el parametro de esta corrida
        return FakeBookingRepository()        # (4a) provider en memoria
    return SqliteBookingRepository(sqlite3.connect(":memory:"))  # (4b) provider real
  1. @pytest.fixture(params=["fake", "sqlite"]) — el decorador convierte repo en una fixture parametrizada. La lista params declara los valores; habrá una corrida de cada test por cada valor. Aquí, dos valores: "fake" y "sqlite".
  2. def repo(request): — la fixture recibe el objeto especial request, que pytest inyecta. Es el hilo que conecta la fixture con la corrida actual: por él sabemos cuál de los parámetros toca en este momento.
  3. request.param — en la corrida [fake], request.param vale "fake"; en la corrida [sqlite], vale "sqlite". Es cómo la fixture decide qué construir en cada pasada.
  4. El return — según el parámetro, la fixture construye y devuelve el provider correspondiente: un FakeBookingRepository en memoria, o un SqliteBookingRepository con una base SQLite en memoria (:memory:, una base efímera que vive solo mientras dura el test —limpia y rapidísima—).

Cualquier test que declare repo como argumento recibirá, sin saberlo, primero el fake y luego el real. El test no cambia; pytest lo ejecuta dos veces, cada una con un provider distinto en el parámetro repo. Esa es toda la mecánica: el test se escribe una vez y se corre N veces, una por parámetro de la fixture.

Cómo pytest expande una batería

Para ver la multiplicación con tus propios ojos, pídele a pytest que solo recolecte los tests, sin correrlos. Con la batería de cuatro cláusulas y la fixture de dos parámetros:

python3 -m pytest tests/test_repository_contract.py --collect-only -q
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[fake]
tests/test_repository_contract.py::test_save_then_get_returns_the_same_booking[sqlite]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[fake]
tests/test_repository_contract.py::test_get_of_a_missing_id_raises[sqlite]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[fake]
tests/test_repository_contract.py::test_saving_the_same_id_twice_updates_not_duplicates[sqlite]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[fake]
tests/test_repository_contract.py::test_find_by_room_returns_only_that_rooms_bookings[sqlite]

8 tests collected in 0.01s

Ahí está la expansión, sin haber corrido nada aún. Escribiste cuatro funciones de test; pytest recolectó ocho casos. Cada función aparece dos veces, una con [fake] y otra con [sqlite]. El texto entre corchetes es el id del parámetro: pytest lo toma de los strings que pusiste en params ("fake", "sqlite"), por eso salieron legibles. Ese id es tu mapa: te dice, para cada caso, qué implementación se está probando. Cuando algo falle, el id te dirá contra qué provider falló —y eso, como verás en la lección 5, es media solución—.

Ejemplo trabajado: la batería completa, ocho verdes

Ahora corramos de verdad la batería del contrato del BookingRepository —las cuatro cláusulas de las lecciones anteriores— con la fixture parametrizada:

# tests/test_repository_contract.py
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)      # Focus 3 h


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)


@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):
    booking = a_booking()
    repo.save(booking)
    assert repo.get("bk-1") == booking


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"))     # mismo id "bk-1"
    assert repo.get("bk-1").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"))
    found = repo.find_by_room("focus")
    assert [b.id for b in found] == ["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.03s ==============================

Ocho verdes, cuatro cláusulas por dos providers. Lee la salida como un reporte de inspección: cada cláusula pasó tanto [fake] como [sqlite]. Eso es la certificación completa del contrato: el fake y el real se comportan igual en las cuatro cláusulas, verificado, no supuesto. Y quiero que notes algo que es fácil pasar por alto: no escribiste ocho tests, escribiste cuatro. La fixture parametrizada duplicó cada uno. Si mañana añades un tercer provider —digamos un repositorio sobre archivo— basta con agregar "file" a params; las cuatro cláusulas correrán también contra él, y tendrás doce casos sin tocar una sola línea de test. Esa es la economía del enfoque: el esfuerzo de escribir el contrato es fijo; verificar una implementación más es una palabra en una lista.

Por qué "una batería, dos providers" es la garantía

Detengámonos en la afirmación fuerte de todo el módulo: la batería parametrizada es lo que garantiza que el fake no pueda mentir. La palabra clave es una. Cuando el fake y el real se prueban con la misma batería —el mismo texto de test, corrido dos veces—, es imposible que se les exijan cosas distintas. No hay dos specs que puedan derivar: hay uno solo. Si una cláusula cambia, cambia para ambos a la vez, porque es la misma función. Si un provider deja de cumplir una cláusula, su corrida se pone roja mientras la del otro sigue verde, y el id entre corchetes te dice cuál falló. La única forma de que el fake "pase por bueno" es que de verdad se comporte como el real en las cuatro cláusulas —y eso es exactamente lo que queríamos garantizar—.

Compáralo con la alternativa que parece equivalente y no lo es: dos archivos de test gemelos, test_fake_repo.py y test_sqlite_repo.py. Aunque hoy afirmen lo mismo, son dos textos independientes. Mañana alguien ajusta una aserción en uno y olvida el otro, y sin que salte ningún rojo has vuelto al módulo 2 —el fake y el real probados contra expectativas distintas, libres para divergir—. La parametrización elimina esa posibilidad de raíz: no hay "el otro archivo" que olvidar, porque hay uno. La garantía no viene de la disciplina de mantener dos cosas sincronizadas; viene de que hay una sola cosa. Ese es el argumento técnico por el que este módulo insiste tanto en la batería parametrizada frente a las suites duplicadas.

Un detalle: fixture con params contra @pytest.mark.parametrize

Pytest tiene dos formas de parametrizar, y conviene saber cuándo va cada una. Usamos una fixture con params porque lo que varía es un recurso —el repositorio— que varios tests comparten y que a veces necesita construirse con cuidado (abrir una conexión, crear el esquema). La fixture centraliza esa construcción: se escribe una vez y todos los tests que pidan repo la reciben ya lista.

La otra forma, @pytest.mark.parametrize, se pone directamente sobre un test y va mejor cuando lo que varía son datos de entrada de ese test en particular —una lista de precios esperados, varios pares entrada/salida—. Podrías parametrizar la batería con @pytest.mark.parametrize sobre cada función, pasando la clase del repositorio, pero tendrías que repetir la lista en cada test y reconstruir el recurso en cada uno. La fixture con params es la herramienta correcta cuando el eje de variación es "¿qué implementación del colaborador?", que es justo nuestro caso. Regla práctica: datos que varían por test → parametrize; un recurso/colaborador que varias pruebas comparten → fixture con params.

Errores comunes

Poner params en el parametrize del test y terminar con specs duplicados. Qué pasa: para variar el repositorio, alguien copia @pytest.mark.parametrize("repo_cls", [FakeBookingRepository, SqliteBookingRepository]) sobre cada uno de los cuatro tests. Por qué pasa: parametrize es lo primero que se aprende. Cómo detectarlo: la misma lista de implementaciones repetida en cuatro decoradores es cuatro lugares donde olvidar añadir el próximo provider. Cómo corregirlo: centraliza el eje "qué implementación" en una fixture con params; los tests solo piden repo y no saben cuántos providers hay. Añadir uno es una palabra en la lista de la fixture, no un cambio en cuatro decoradores.

No leer el id entre corchetes al fallar. Qué pasa: un test se pone rojo, se lee el nombre de la función y se ignora el [fake]/[sqlite] del final. Por qué pasa: el corchete parece ruido. Cómo detectarlo: si te preguntas "¿pero contra qué implementación falló?", la respuesta estaba en el id que no leíste. Cómo corregirlo: el id es la mitad del diagnóstico. test_get_of_a_missing_id_raises[sqlite] FAILED y ...[fake] FAILED cuentan historias opuestas: la primera dice "el real está roto", la segunda "el fake diverge". Léelo siempre; en la lección 5 es la clave para saber quién miente.

Construir un recurso caro sin considerar el scope de la fixture. Qué pasa: la fixture abre una conexión nueva y crea el esquema en cada corrida, y con muchos tests eso empieza a pesar. Por qué pasa: la fixture por defecto es de scope function —se reconstruye por test—, que es lo correcto para aislar pero no siempre lo más rápido. Cómo detectarlo: si la suite de integración se vuelve lenta, mira cuántas veces se construye el recurso. Cómo corregirlo: para SQLite :memory: el costo es ínfimo y el scope function es ideal (cada test empieza con una base limpia, sin contaminación). Para recursos de verdad caros, hay scope mayores y estrategias de aislamiento —pero eso es tema del módulo 7, Datos y aislamiento en integración, no lo adelantes aquí—.

Ejercicios

Ejercicio 1 — Cuenta los casos. Tienes una batería de contrato con 5 cláusulas (cinco funciones de test) y una fixture repo con params=["fake", "sqlite", "file"]. Sin correr nada, di cuántos casos recolectará pytest y cómo se verán los ids de una de las cláusulas.

Ver solución

Pytest recolectará 15 casos: 5 cláusulas × 3 parámetros. Cada función de test se expande una vez por cada valor de params.

Para una cláusula cualquiera —digamos test_get_of_a_missing_id_raises— los ids serían tres:

test_get_of_a_missing_id_raises[fake]
test_get_of_a_missing_id_raises[sqlite]
test_get_of_a_missing_id_raises[file]

La cuenta es simple y vale la pena tenerla clara: casos = cláusulas × implementaciones. Es también la razón por la que añadir una implementación es tan barato: sumar "file" a params no añade una función de test, pero multiplica la cobertura por el número de cláusulas que ya tienes. Cinco cláusulas bien escritas se convierten, gratis, en cinco pruebas más contra cada provider nuevo.

Ejercicio 2 — Elige la herramienta. Para cada situación, di si conviene una fixture con params o un @pytest.mark.parametrize sobre el test: (a) verificar el contrato del repositorio contra el fake, SQLite y un futuro repositorio sobre archivo; (b) verificar que price_cents da 6000, 3000 y 0 para 3, 1.5 y 0 horas de descuento respectivamente; (c) verificar el contrato del PaymentGateway contra un stub y un fake.

Ver solución
  • (a) Fixture con params. El eje de variación es "¿qué implementación del repositorio?" —un colaborador que las cuatro cláusulas comparten y que necesita construirse (abrir conexión, crear esquema)—. La fixture centraliza esa construcción y cada cláusula solo pide repo. Añadir el repositorio sobre archivo es una palabra en params.
  • (b) @pytest.mark.parametrize. Aquí lo que varía son datos de entrada/salida de un mismo test de lógica pura: pares (horas, esperado). No hay recurso que construir ni colaborador que intercambiar. @pytest.mark.parametrize("hours, expected", [(3, 6000), (1.5, 3000), (0, 0)]) es la forma natural.
  • (c) Fixture con params. Igual que (a): el eje es "¿qué implementación del gateway?" —stub o fake—, un colaborador compartido por las cláusulas del contrato del gateway. Fixture con params=["stub", "fake"].

El patrón: si intercambias implementaciones de un colaborador que varias cláusulas comparten, fixture con params; si varías datos de un solo test, parametrize.

Ejercicio 3 — Añade un provider. Tienes la batería del BookingRepository con params=["fake", "sqlite"], ocho verdes. Un compañero escribió un SqliteBookingRepository que usa un archivo en disco en vez de :memory:, y quiere verificarlo con el mismo contrato. Describe el cambio mínimo para incluirlo y cuántos casos habrá después.

Ver solución

El cambio mínimo vive solo en la fixture; ninguna de las cuatro funciones de test se toca. Se añade un tercer parámetro y la rama que construye el repositorio sobre archivo:

@pytest.fixture(params=["fake", "sqlite-memory", "sqlite-file"])
def repo(request, tmp_path):
    if request.param == "fake":
        return FakeBookingRepository()
    if request.param == "sqlite-memory":
        return SqliteBookingRepository(sqlite3.connect(":memory:"))
    db_file = tmp_path / "reservo.db"                       # archivo temporal del test
    return SqliteBookingRepository(sqlite3.connect(db_file))

(La fixture tmp_path de pytest da un directorio temporal único por test, así que el archivo se crea y se destruye limpio en cada corrida —el aislamiento fino de recursos de verdad es el tema del módulo 7—.)

Después del cambio habrá 12 casos: 4 cláusulas × 3 parámetros. Las mismas cuatro cláusulas certifican ahora tres implementaciones, y en la salida verás [fake], [sqlite-memory] y [sqlite-file] para cada una. Esto es la economía del contrato parametrizado en una frase: el trabajo de escribir el spec ya está hecho; sumar una implementación es agregarla a la lista y dejar que la batería la certifique.

Resumen y siguiente paso

En esta lección dominaste el mecanismo que hace posible el contrato compartido: la fixture parametrizada de pytest. Desarmaste la línea @pytest.fixture(params=["fake", "sqlite"]) pieza por pieza —los params, el objeto request, request.param, el return que construye cada provider— y viste, con --collect-only, cómo pytest expande cuatro funciones de test en ocho casos, cada uno etiquetado con su id [fake]/[sqlite]. Corriste la batería completa: ocho verdes, la certificación de que el fake y el real cumplen las cuatro cláusulas. Y entendiste por qué "una batería, dos providers" —una, no dos suites gemelas— es la garantía técnica de que el fake no puede mentir: no hay dos specs que puedan divergir, hay uno solo corrido N veces. Con la línea de inspección vehicular fijaste la imagen: un protocolo, muchos vehículos, un veredicto por cada uno.

Antes de avanzar deberías poder: explicar qué hace request.param en cada corrida; predecir cuántos casos recolecta una batería (cláusulas × implementaciones) y cómo se ven sus ids; elegir entre fixture con params y @pytest.mark.parametrize según varíe un colaborador o unos datos; y añadir una implementación al contrato tocando solo la fixture.

Tienes el instrumento afilado. La lección 5 lo usa para lo que todo el módulo prometía: metemos en la batería el BuggyFakeBookingRepository del módulo 2 —el que devuelve None en vez de lanzar— y lo vemos cazado en rojo, mientras el real sigue verde. Ahí el id entre corchetes deja de ser un detalle y se vuelve el dedo que señala al culpable.

Recursos