Módulo 2: El doble que miente: el problema que motiva los contratos

5. Divergencia de unicidad y transacciones

Descripción

Las divergencias de las lecciones 3 y 4 —comportamiento, tipos, orden— tenían algo en común: el fake, con esfuerzo, podría haberlas evitado. Podrías escribir un fake que lance en vez de devolver None, que devuelva datetime en vez de str, que ordene por start. Serían fakes más complejos y peor idea (lección 2), pero posibles. Esta lección abre dos familias distintas, y su rasgo definitorio es que el dict en memoria no puede fingirlas sin dejar de ser un dict, porque son propiedades de la maquinaria de una base de datos real, no de los datos que guarda. Son las divergencias más profundas del módulo, y las que más se acercan a la naturaleza de "el sistema real".

La primera es la unicidad, o más en general, los constraints: las reglas que la base de datos hace cumplir sobre los datos. Una base de datos real puede tener una restricción que diga "ninguna sala se reserva dos veces a la misma hora" (UNIQUE(room_id, start)), y la hará cumplir rechazando con un error cualquier intento de violarla. El dict del fake no tiene noción de reglas: acepta lo que le des, incluso una doble reserva imposible. La segunda es la transaccionalidad: la capacidad de la base de datos de agrupar varias escrituras en una unidad atómica —todo o nada— y de deshacerla (rollback) si algo falla a la mitad. El dict muta de inmediato y para siempre; no sabe qué es un commit ni un rollback, así que un fallo a mitad de un lote lo deja escrito a medias, sucio, sin forma de volver atrás. Constraints y transacciones son dos cosas que el real hace y el fake ni siquiera puede representar.

Conexión con el módulo: esta lección cierra el catálogo de divergencias que el módulo colecciona, y lo hace con las dos que más ilustran la causa "simplifica de más" de la lección 2 llevada al extremo. La unicidad y las transacciones no son garantías gratuitas que el fake regala de más (como los tipos o el orden de la lección 4); son garantías que el real da y el fake no puede dar en absoluto, porque le falta la maquinaria. Por eso son un puente perfecto hacia el módulo 3: dejan clarísimo que la fidelidad de un doble no se logra haciéndolo más gordo —no puedes meterle un motor transaccional a un dict sin convertirlo en una base de datos—, sino verificando su comportamiento contra el real con un contrato, y probando de vez en cuando contra lo real con integración. Aquí ves, por última vez y en su forma más extrema, por qué confiar en el fake sin verificar es apostar.

Analogía: el cuaderno personal y el libro contable del banco

Piensa en dos formas de llevar las cuentas. La primera es un cuaderno personal: anotas lo que quieras, como quieras. Si te equivocas y apuntas dos veces el mismo gasto, el cuaderno no protesta —es papel, acepta cualquier cosa que la mano escriba—. Y si empiezas a anotar una operación de dos pasos ("saco 100 de aquí, meto 100 allá") y te interrumpen a la mitad, el cuaderno se queda con el primer paso escrito y el segundo no: no hay forma de que el cuaderno "deshaga" el medio apunte; ahí queda, inconsistente, hasta que tú te acuerdes de tacharlo.

La segunda es el libro contable de un banco, gobernado por reglas y por transacciones. Tiene un reglamento que hace cumplir: intenta registrar dos veces la misma transferencia con el mismo folio y el sistema la rechaza en el acto —"folio duplicado"—; no te deja romper la regla. Y tiene atomicidad: una transferencia de dos pasos (debitar una cuenta, acreditar otra) se registra como una unidad, y si el segundo paso falla, el sistema deshace el primero automáticamente —la cuenta debitada vuelve a su saldo—, de modo que jamás queda medio hecha. El libro del banco no confía en que el operador se acuerde de tachar; garantiza la consistencia con su maquinaria.

El FakeBookingRepository es el cuaderno personal: acepta la doble reserva imposible, y si un lote falla a la mitad, deja lo escrito escrito. El SqliteBookingRepository real es el libro del banco: rechaza la doble reserva con un error, y deshace el lote fallido con un rollback. Si probaste tu código solo contra el cuaderno, aprendiste a vivir en un mundo sin reglas y sin deshacer —donde todo se acepta y nada se revierte—, y el día que lo conectas al banco, te encuentras con rechazos y con reversiones que tu código nunca contempló. Las dos divergencias de esta lección son las dos formas en que el banco te sorprende cuando venías del cuaderno.

Divergencia de unicidad: la doble reserva que el fake acepta

Empecemos por los constraints, con la regla de negocio más natural de un sistema de reservas: una sala no puede reservarse dos veces a la misma hora. En una base de datos real esto se expresa con un constraint de unicidad en el esquema —UNIQUE(room_id, start)—, y el motor lo hace cumplir: si intentas insertar una segunda reserva para la misma sala y la misma hora, la rechaza con un error. Aquí está el repositorio real con esa regla:

# reservo/sqlite_repo_unique.py — el real hace cumplir una regla de negocio con UNIQUE
SCHEMA = """
CREATE TABLE IF NOT EXISTS bookings (
    id          TEXT PRIMARY KEY,
    ...
    price_cents INTEGER NOT NULL,
    UNIQUE(room_id, start)          -- ninguna sala se reserva dos veces a la misma hora
)
"""

El FakeBookingRepository, en cambio, es un dict indexado por id: no sabe nada de room_id ni de start como clave de unicidad. Dos reservas con distinto id pero misma sala y misma hora son, para el dict, dos claves distintas: las guarda las dos sin chistar. Aquí el experimento: guardo dos reservas distintas (bk-1 y bk-2) para la misma sala a la misma hora, y verifico que hay dos.

# tests/test_uniqueness.py
def a_booking(id_):
    # DOS reservas distintas (distinto id) para la MISMA sala a la MISMA hora
    return Booking(id=id_, room_id="focus", member_id="m-ana",
                   start=START, end=datetime(2026, 3, 10, 10),
                   status="confirmed", price_cents=6000)


def test_two_bookings_same_slot_with_fake():
    repo = FakeBookingRepository()
    repo.save(a_booking("bk-1"))
    repo.save(a_booking("bk-2"))          # el fake NO conoce la regla: acepta el choque
    assert len(repo.find_by_room("focus")) == 2   # dos reservas en la misma sala/hora


def test_two_bookings_same_slot_with_sqlite():
    repo = SqliteBookingRepositoryUnique(sqlite3.connect(":memory:"))
    repo.save(a_booking("bk-1"))
    repo.save(a_booking("bk-2"))          # <-- el real rechaza el choque con UNIQUE
    assert len(repo.find_by_room("focus")) == 2

Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1), el fake acepta las dos reservas y pasa; el real rechaza la segunda con IntegrityError:

tests/test_uniqueness.py::test_two_bookings_same_slot_with_fake PASSED   [ 50%]
tests/test_uniqueness.py::test_two_bookings_same_slot_with_sqlite FAILED [100%]

=================================== FAILURES ===================================
___________________ test_two_bookings_same_slot_with_sqlite ____________________

    def test_two_bookings_same_slot_with_sqlite():
        repo = SqliteBookingRepositoryUnique(sqlite3.connect(":memory:"))
        repo.save(a_booking("bk-1"))
>       repo.save(a_booking("bk-2"))          # <-- el real rechaza el choque con UNIQUE
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

tests/test_uniqueness.py:28:
...
E       sqlite3.IntegrityError: UNIQUE constraint failed: bookings.room_id, bookings.start

reservo/sqlite_repo_unique.py:24: IntegrityError
========================= 1 failed, 1 passed in 0.04s ==========================

Fíjate en la dirección de la mentira, porque es la más peligrosa de todas. El unit test con el fake pasa afirmando que el sistema permite dos reservas para la misma sala a la misma hora —una doble reserva, que es exactamente el bug de negocio que la regla existe para impedir—. El fake, al no modelar el constraint, le dio luz verde a un estado imposible. El real, con su IntegrityError, dice la verdad: ese estado no debe existir. Aquí el verde del unit test no solo esconde un bug: certifica el bug como comportamiento correcto. Si un desarrollador escribe la lógica de reserva confiando en este verde, cree que su código permite algo que el sistema real prohíbe, y su código no está preparado para el IntegrityError que le llegará en producción cuando dos socios intenten la misma sala a la misma hora. El fake le enseñó un mundo sin reglas, y el mundo real tiene reglas.

Divergencia de transacciones: el lote que el fake no deshace

La segunda familia es la atomicidad, y la vamos a ver con una operación por lotes: guardar varias reservas de golpe, con la regla de "todo o nada". Si el lote falla a la mitad —una de las reservas viola una restricción—, lo correcto es que ninguna quede guardada: el lote se deshace entero, para no dejar el sistema a medias. Una base de datos real hace esto con una transacción y un rollback. El dict del fake no puede: cada save es una mutación inmediata y definitiva.

Para que la comparación sea justa y contundente, uso un fake que incluso modela el constraint —lanza si detecta un id duplicado—, para que no puedas decir "es que el fake ni siquiera intentó". El punto es que, aun modelando la regla, el fake no puede deshacer lo ya escrito, porque no tiene transacciones:

# reservo/batch.py
class ConstraintFakeRepository:
    def __init__(self):
        self._store = {}

    def save(self, booking):
        if booking.id in self._store:
            raise ValueError(f"id duplicado: {booking.id}")   # "constraint"
        self._store[booking.id] = booking


def save_all_fake(repo, bookings):
    for b in bookings:            # sin transaccion: cada save es definitivo
        repo.save(b)

El repositorio real, en cambio, envuelve el lote en una transacción: si algún INSERT falla, hace rollback() y no queda nada. El lote que vamos a probar tiene un choque plantado al final: [bk-1, bk-2, bk-1] —el tercer elemento repite el id bk-1, violando la unicidad del PRIMARY KEY—. La pregunta es qué queda guardado después de que el lote falla:

# tests/test_transactions.py
# El lote tiene un choque al final: bk-1 se repite (viola la unicidad del id).
BATCH = [a_booking("bk-1"), a_booking("bk-2"), a_booking("bk-1")]


def test_failed_batch_leaves_nothing_with_fake():
    repo = ConstraintFakeRepository()
    with pytest.raises(ValueError):
        save_all_fake(repo, BATCH)
    assert repo.count() == 0     # el fake deberia quedar limpio... ¿o no?


def test_failed_batch_leaves_nothing_with_sqlite():
    repo = SqliteBatchRepository(sqlite3.connect(":memory:"))
    with pytest.raises(sqlite3.IntegrityError):
        repo.save_all(BATCH)
    assert repo.count() == 0     # el real hace rollback: 0 filas

Qué esperar. Aquí la mentira cambia de dirección: es el fake el que falla, dejando estado sucio, mientras el real queda limpio:

tests/test_transactions.py::test_failed_batch_leaves_nothing_with_fake FAILED [ 50%]
tests/test_transactions.py::test_failed_batch_leaves_nothing_with_sqlite PASSED [100%]

=================================== FAILURES ===================================
__________________ test_failed_batch_leaves_nothing_with_fake __________________

    def test_failed_batch_leaves_nothing_with_fake():
        repo = ConstraintFakeRepository()
        with pytest.raises(ValueError):
            save_all_fake(repo, BATCH)
>       assert repo.count() == 0     # el fake deberia quedar limpio... ¿o no?
        ^^^^^^^^^^^^^^^^^^^^^^^^
E       assert 2 == 0
E        +  where 2 = count()

tests/test_transactions.py:25: AssertionError
========================= 1 failed, 1 passed in 0.03s ==========================

assert 2 == 0: el fake quedó con dos reservas guardadas (bk-1 y bk-2) después de que el lote falló en el tercer elemento. Como save_all_fake no tiene transacción, escribió bk-1, escribió bk-2, y al llegar al bk-1 repetido lanzó —pero los dos primeros ya estaban en el dict, y ahí se quedaron—. El sistema quedó a medias: un lote que debía ser "todo o nada" resultó "algo". El real, que envuelve el lote en una transacción, hizo rollback al fallar y quedó en cero: todo o nada, de verdad.

Este caso es especialmente instructivo porque la mentira apunta al revés que en la unicidad: aquí el que da el resultado peligroso es el fake, no el real. Y sin embargo la moraleja es idéntica: el fake y el real discrepan, y creerle al fake te lleva a una conclusión falsa sobre el sistema real. Si tu test de "un lote fallido no deja estado sucio" corre contra el fake, se pone rojo y te hace pensar que tu código de lotes tiene un bug de atomicidad —cuando en realidad, contra el motor real y su transacción, el código funciona bien—. O al revés, si tu código depende de la atomicidad y solo lo pruebas contra el fake sin transacción, nunca verificaste que el rollback ocurre de verdad. La atomicidad es una propiedad del real que el fake no puede representar, así que cualquier test de atomicidad contra el fake es, en el mejor caso, ruido, y en el peor, una conclusión invertida.

Por qué estas dos no se arreglan engordando el fake

Es el momento de rematar el argumento que la lección 2 abrió. Ante las divergencias de tipos y orden, alguien podía proponer, con algo de razón, "hago el fake más fiel: que devuelva datetime, que ordene". Ante la unicidad y las transacciones, esa salida se cierra del todo, y conviene ver por qué. Para que el fake haga cumplir UNIQUE(room_id, start), tendría que llevar un índice de las combinaciones sala-hora ya usadas y comprobarlo en cada save —empiezas a reimplementar el motor de constraints de una base de datos—. Para que el fake soporte transacciones, tendría que guardar copias del estado antes de cada lote y saber restaurarlas ante un fallo —empiezas a reimplementar el motor transaccional—. En el límite, un fake que modele constraints y transacciones es una base de datos en memoria, con toda su complejidad, sus propios bugs, y ninguna garantía de coincidir con la base de datos real que sí usas en producción (que puede ser PostgreSQL, con reglas y semántica transaccional propias).

Ese callejón sin salida es la mejor prueba de que la fidelidad no se persigue reimplementando lo real dentro del doble. Se persigue de otra manera: midiéndola. Un contrato afirma "guardar dos reservas para la misma sala y hora falla" y "un lote que falla a la mitad no deja estado", y corre esas afirmaciones contra la implementación real —que es la que de verdad tiene constraints y transacciones— para verificar que las cumple. El fake simple se queda para lo que sirve (unit tests rápidos de la lógica que no depende de constraints ni transacciones), y las propiedades que solo el real puede dar se verifican contra el real, en tests de integración. No hay un fake lo bastante fiel para esto; hay que ir a la pieza real. Y esa es, exactamente, la razón de ser de los módulos 5, 6 y 7.

Errores comunes

Modelar reglas de negocio solo en el código de la aplicación y "confiar" en que el fake basta. Qué pasa: alguien pone la validación "no dobles reservas" en BookingService y prueba con el fake, sin un constraint en la base de datos. Por qué pasa: validar en el código se siente suficiente, y el fake lo confirma. Cómo detectarlo: pregúntate qué pasa si dos peticiones concurrentes pasan la validación del código a la vez y ambas intentan guardar —sin el constraint en la base de datos, las dos se guardan, y tienes la doble reserva que creías imposible—. El fake, monohilo y sin constraint, nunca te muestra esa carrera. Cómo corregirlo: las reglas de integridad de datos viven en la base de datos (constraints) además de en el código, y hay que probarlas contra el real, porque el fake no puede hacerlas cumplir ni mostrarte los casos donde importan.

Escribir un test de atomicidad contra el fake y creer su veredicto. Qué pasa: alguien prueba "un lote fallido no deja estado" contra el FakeBookingRepository y saca conclusiones sobre el rollback. Por qué pasa: es el repositorio a mano, el más fácil de instanciar en un test. Cómo detectarlo: el fake no tiene transacciones, así que su resultado en un test de atomicidad no dice nada sobre si tu código hace rollback de verdad —el fake ni siquiera puede hacer rollback—. El veredicto es ruido. Cómo corregirlo: la atomicidad solo se prueba contra algo que la tenga, es decir, contra el SqliteBookingRepository real (o el motor de producción). Cualquier test de transacciones que no toque una pieza transaccional real está probando el vacío.

Suponer que si el fake pasa un caso más "difícil", pasará los fáciles. Qué pasa: alguien ve que el ConstraintFakeRepository incluso lanza en id duplicado y concluye "este fake es robusto, modela la regla". Por qué pasa: un fake que valida algo parece más confiable que uno que no valida nada. Cómo detectarlo: modelar el rechazo (lanzar en duplicado) es una cosa; modelar la reversión (deshacer lo ya escrito) es otra, y requiere transacciones que el dict no tiene. El ConstraintFakeRepository hace lo primero y no lo segundo, y por eso deja estado sucio. Cómo corregirlo: no supongas que "modela una parte" implica "modela el resto"; cada propiedad del real (rechazo, reversión, aislamiento, durabilidad) es independiente, y el fake puede fingir unas y ser incapaz de otras. Verifica cada una contra el real, no por analogía con las que sí finge.

Ejercicios

Ejercicio 1 — ¿Constraint o transacción? Para cada divergencia, di si es de unicidad/constraint o de transacción/atomicidad, y en qué dirección miente (qué repo da el resultado peligroso): (a) el fake permite guardar una reserva con price_cents = -500; el real la rechaza con CHECK (price_cents >= 0); (b) el código transfiere una reserva de una sala a otra en dos pasos (borrar de A, crear en B); el segundo paso falla; el fake deja la reserva borrada de A y no creada en B; (c) el fake permite dos reservas con el mismo id; el real lo rechaza con el PRIMARY KEY.

Ver solución
  • (a) Constraint (un CHECK). El real hace cumplir una regla de dominio (precio no negativo); el fake, sin constraints, acepta el valor imposible. La mentira la da el fake: certifica como válido un precio negativo que el sistema real prohíbe. Un código que confíe en ese verde no estará preparado para el IntegrityError del real.
  • (b) Transacción/atomicidad. La operación de dos pasos debía ser atómica; al fallar el segundo, sin transacción la reserva queda "borrada de A y no creada en B" —desapareció—. La mentira la da el fake (deja el estado a medias); el real, con la transacción, haría rollback y la reserva seguiría en A. Es un caso de pérdida de datos que solo la atomicidad del real previene.
  • (c) Constraint (el PRIMARY KEY). La unicidad del id la hace cumplir el real; el fake indexado por id, curiosamente, también la haría cumplir en parte (sobrescribiría en vez de duplicar), pero un fake basado en lista no. En un dict por id, guardar dos veces el mismo id sobrescribe —no duplica pero pierde la primera—, mientras el real con PRIMARY KEY lanza. La dirección de la mentira depende del fake exacto; el punto es que "guardar dos veces el mismo id" tiene comportamientos distintos, y solo el contrato fija cuál es el correcto.

Ejercicio 2 — La doble reserva concurrente. Un equipo valida "no dobles reservas" solo en BookingService (comprueba el calendario antes de guardar) y prueba con el FakeBookingRepository, todo verde. En producción, con el SqliteBookingRepository que no tiene UNIQUE(room_id, start), aparecen dobles reservas. Explica cómo, y qué dos cosas faltaban.

Ver solución

Cómo aparecen: dos peticiones casi simultáneas para la misma sala a la misma hora. Ambas ejecutan la validación de BookingService —"¿está libre el calendario?"— antes de que ninguna haya guardado, así que las dos ven la sala libre y las dos pasan la validación. Luego ambas guardan, y como no hay un UNIQUE(room_id, start) en la base de datos que las frene, las dos reservas quedan escritas: doble reserva. Es una condición de carrera clásica: la validación en el código no es atómica respecto a la escritura, y sin un constraint en la base de datos que actúe como última línea de defensa, la ventana entre "validé" y "guardé" deja pasar el choque.

Qué faltaba, dos cosas. Primera: el constraint en la base de datos (UNIQUE(room_id, start)), que es la única defensa que se aplica en el momento exacto de escribir, sin ventana de carrera —si dos escrituras chocan, el motor rechaza una con IntegrityError, y el código puede manejarlo—. Segunda: una prueba contra el real que ejerciera este caso. El fake, monohilo y sin constraint, nunca puede mostrar una condición de carrera ni el rechazo del motor; el bug vive precisamente en lo que el fake no modela. Solo un test de integración contra el SqliteBookingRepository con el constraint —intentar la doble reserva y verificar que la segunda es rechazada— habría revelado, antes del deploy, que la validación en el código no bastaba.

Ejercicio 3 — ¿Fake gordo o contrato? Un compañero propone: "añado a FakeBookingRepository un chequeo de UNIQUE(room_id, start) y un mecanismo de rollback casero, así el fake modela constraints y transacciones y ya no diverge". Argumenta por qué esto es un mal negocio y qué se hace en su lugar.

Ver solución

Por qué es un mal negocio: para modelar UNIQUE(room_id, start), el fake tiene que llevar y consultar un índice de combinaciones sala-hora en cada save; para modelar el rollback, tiene que fotografiar el estado antes de cada lote y saber restaurarlo. En cuanto haces eso, el fake deja de ser un dict de cinco líneas y se convierte en una reimplementación de un motor de base de datos —con tres problemas nuevos—. Uno: complejidad, el fake ahora es código no trivial que hay que leer, entender y mantener, y que puede tener sus propios bugs (un rollback casero mal hecho es peor que ninguno, porque parece que protege). Dos: no garantiza coincidencia, tu constraint casero puede diferir en un borde del constraint real de SQLite (o de PostgreSQL en producción), así que sigues sin saber si el fake y el real de verdad se comportan igual. Tres: no cierra la causa dos, cuando el esquema real gane un constraint nuevo, tu fake gordo se quedará igual de atrás que el flaco, y ahora es más trabajo actualizarlo.

Qué se hace en su lugar: mantener el fake deliberadamente simple —sin constraints ni transacciones— y usarlo solo para lo que no depende de ellos (la lógica pura de book/cancel, rápida y aislada). Las propiedades que solo el real da —unicidad, atomicidad— se verifican de dos maneras complementarias: un contrato que afirma "guardar dos reservas para la misma sala y hora debe fallar" y se corre contra la implementación real para confirmar que la cumple; y tests de integración que ejercen esas propiedades contra el SqliteBookingRepository de verdad. No persigues la fidelidad reimplementando la base de datos dentro del fake (tarea infinita y frágil); la mides contra la base de datos real (tarea acotada y honesta). Ese es el plan de los módulos 3, 5, 6 y 7.

Resumen y siguiente paso

En esta lección ejecutaste las dos divergencias más profundas del módulo, las que el dict no puede ni fingir porque le falta la maquinaria. La de unicidad: el SqliteBookingRepository real rechaza una doble reserva con IntegrityError mientras el fake la acepta callado —y ahí el verde no esconde un bug, lo certifica como correcto—. La de transacciones: un lote que falla a la mitad, que el real deshace entero con un rollback (queda en cero) mientras el fake lo deja escrito a medias (queda en dos) —una divergencia que apunta al revés pero enseña lo mismo—. Y remataste el argumento de la lección 2: estas propiedades no se logran engordando el fake (eso es reimplementar una base de datos, con sus bugs y sin garantía de coincidir), sino midiendo la fidelidad contra el real con contratos e integración.

Antes de avanzar deberías poder: explicar por qué el fake no puede modelar unicidad ni transacciones sin dejar de ser un dict; distinguir "modelar el rechazo" de "modelar la reversión"; y argumentar por qué un test de atomicidad contra el fake no dice nada.

Has visto seis divergencias en cuatro familias —comportamiento, tipos, orden, unicidad, transacciones—, todas ejecutadas, todas con el mismo desenlace: el fake y el real discrepan, y creerle al fake lleva a una conclusión falsa. Queda una pregunta de fondo que hemos rozado sin responder de frente: ¿por qué la suite de unit tests, por grande y verde que sea, es incapaz de cazar cualquiera de estas? No es mala suerte ni pocos tests; es estructural. La lección 6 lo demuestra: el doble es a la vez el sujeto de la prueba y el oráculo que la juzga, y preguntarle al doble si el doble tiene razón siempre da que sí.

Recursos