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

2. Por qué los dobles divergen

Descripción

En la lección anterior hice una afirmación fuerte y la dejé sin fundamentar: la divergencia entre un doble y lo real no es un accidente raro que le pasa a los descuidados, sino la tendencia natural de todo doble. Es hora de sostenerla. Si de verdad divergir fuera un accidente, la solución sería obvia y barata: "ten más cuidado", una revisión de código, una regla de estilo. Y no lo es. La divergencia tiene tres causas de fondo, y las tres son estructurales —viven en la naturaleza misma de lo que es un doble—, no en la disciplina de quien lo escribe. Entenderlas es lo que convierte "verifica tus dobles" de un consejo moralista en una necesidad de ingeniería.

Las tres causas son estas, y las vamos a desarmar una por una. Primero: un doble se escribe a mano, así que su comportamiento es lo que tú tecleaste, no lo que la pieza real hace —nada, ningún mecanismo del lenguaje, obliga a que coincidan—. Segundo: un doble se queda atrás, porque cuando la pieza real cambia (gana una columna, un constraint, un cambio de tipo), el doble no cambia con ella; nadie los mantiene sincronizados, y el tiempo los separa. Tercero: un doble simplifica de más, porque su gracia es ser más simple que lo real —un dict en vez de una base de datos—, y esa simplicidad significa, por definición, omitir cosas que en lo real sí importan. Las tres empujan en la misma dirección: separar el doble de lo real. La pregunta correcta, entonces, no es si tu doble divergirá, sino cuándo y dónde.

Conexión con el módulo: esta lección explica la causa de todo lo que el resto del módulo muestra. Las lecciones 3, 4 y 5 exhiben divergencias concretas —de comportamiento, de tipos, de orden, de unicidad, de transacciones—; esta te da el marco para entender por qué cada una era inevitable. Cuando en la lección 3 veas el get→None vs get→lanza, reconocerás la causa "se escribe a mano". Cuando en la lección 4 veas el datetime volver como str, reconocerás "simplifica de más". Y cuando pienses en cómo prevenir todo esto, entenderás por qué el cuidado individual no basta y hace falta el contrato del módulo 3: porque las tres causas son fuerzas permanentes, y contra una fuerza permanente no sirve un acto de voluntad puntual, sino un mecanismo que la contrarreste siempre.

Analogía: el mapa dibujado de memoria

Imagina que un amigo te va a visitar a un barrio que conoce poco, y en vez de darle el mapa oficial, le dibujas uno a mano en una servilleta: "sales del metro, dos cuadras derecho, giras en la panadería, mi edificio es el azul". Ese dibujo es un doble del barrio: más simple, más rápido de usar, suficiente para el camino que tú tenías en mente. Y va a divergir del barrio real por exactamente tres razones. Primero, lo dibujaste de memoria: si te equivocaste y era la tercera cuadra, no la segunda, el error está en el papel desde el minuto cero, y nada en la servilleta te avisa —el papel no sabe cómo es el barrio, solo sabe lo que tu mano trazó—. Segundo, el barrio cambia y tu servilleta no: la panadería cierra, ponen un sentido único, y tu mapa sigue diciendo "gira en la panadería" meses después, cada vez más desactualizado. Tercero, omitiste casi todo: no dibujaste los otros comercios, ni los desniveles, ni la boca de metro alternativa, porque tu mapa solo servía para tu camino; el día que tu amigo salga por la otra boca, tu servilleta no tiene nada que decirle.

El FakeBookingRepository es esa servilleta. Lo escribiste de memoria (tu suposición de cómo funciona el repositorio), no cambia cuando el repositorio real cambia, y omite todo lo que no necesitabas para el camino feliz que tenías en mente. Es útil por las mismas razones que la servilleta es útil —rápida, simple, suficiente para lo previsto— y diverge por las mismas tres razones. La lección no es "haz un mapa perfecto en la servilleta" (imposible: dejaría de ser una servilleta), sino "cuando importe de verdad llegar, contrasta tu servilleta con el mapa real". Contrastar la servilleta con el mapa oficial es el contrato; caminar el barrio de verdad es la integración.

Causa uno: se escribe a mano, y nada obliga a que coincida

Empecemos por la más básica, porque es la que la gente más subestima. En Python, un doble es un objeto que tiene los métodos que la costura espera. Eso es todo lo que el lenguaje exige. BookingService llama a repo.get(id); cualquier objeto con un método get que acepte un argumento encaja. Python no compara tu get con el get del real, no verifica que devuelvan el mismo tipo, no revisa que lancen las mismas excepciones. La coincidencia entre el doble y el real es responsabilidad tuya, y solo tuya, sostenida por nada más que tu atención en el momento de teclear.

Veámoslo en su forma más cruda, sin servicio de por medio, directamente al nivel del repositorio. Escribo dos tests que afirman comportamientos opuestos para el mismo método get de la misma interfaz: uno dice que devuelve None para un id ausente, el otro dice que lanza. Y los dos pasan.

# tests/test_raw_divergence.py
import sqlite3

import pytest

from reservo.doubles import BuggyFakeBookingRepository
from reservo.sqlite_repo import SqliteBookingRepository


def test_fake_get_missing_returns_none():
    repo = BuggyFakeBookingRepository()
    assert repo.get("nope") is None            # el fake: devuelve None


def test_sqlite_get_missing_raises():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    with pytest.raises(KeyError):
        repo.get("nope")                       # el real: lanza KeyError

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

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

tests/test_raw_divergence.py::test_fake_get_missing_returns_none PASSED  [ 50%]
tests/test_raw_divergence.py::test_sqlite_get_missing_raises PASSED      [100%]

============================== 2 passed in 0.02s ===============================

Detente en lo que acabas de ver, porque es más raro de lo que parece a primera vista. Dos tests verdes, los dos honestos —cada uno describe correctamente lo que hace su repositorio—, y sin embargo afirman lo contrario sobre el mismo método get de la misma interfaz BookingRepository. Uno dice "devuelve None", el otro dice "lanza KeyError". Ambos verdes. ¿Cómo puede ser? Porque nada obliga a que las dos implementaciones de una interfaz coincidan. Python las deja convivir tan felices, cada una con su comportamiento, y solo se enterará de que discrepan el día que un mismo código dependa de una y se ejecute contra la otra. La interfaz BookingRepository es un acuerdo de nombres (save, get, find_by_room), no de comportamiento. El comportamiento lo pusiste tú a mano, en dos lugares distintos, y lo que tu mano escribió en el fake no tiene por qué corresponder con lo que el sqlite3 de la stdlib hace por dentro. Esa es la causa uno, desnuda: el doble dice lo que tecleaste, no lo que el real hace.

Causa dos: se queda atrás cuando el real cambia

La causa uno explica cómo un doble nace divergente. La causa dos explica cómo un doble que nació fiel se vuelve divergente con el tiempo, sin que nadie lo toque. Es la más insidiosa, porque no requiere ningún error: requiere solo que pase el tiempo y que el sistema evolucione, que es lo que los sistemas hacen.

Piensa en la línea de vida de una costura. El día uno, escribes el FakeBookingRepository y el SqliteBookingRepository, y los haces coincidir con cuidado: ambos get lanzan, ambos guardan y leen igual. Fieles. Pasan tres meses. El negocio pide que ninguna sala se reserve dos veces a la misma hora, y un compañero añade un UNIQUE(room_id, start) al esquema del SqliteBookingRepository. Es un cambio correcto, bien hecho, con su propio test de integración. Pero el FakeBookingRepository —que vive en otro archivo, que nadie asoció con este cambio— no se enteró. Sigue siendo un dict que acepta cualquier reserva. En ese instante, sin que nadie escribiera una sola línea de código malo, el fake y el real dejaron de coincidir: el real ahora rechaza dobles reservas, el fake las acepta. La divergencia no la causó un descuido; la causó el hecho normal y saludable de que el software cambia, y de que un doble escrito a mano en un archivo aparte no cambia con él.

Este es el mecanismo que hace que la divergencia sea un problema permanente y no uno que se arregla una vez. Cada vez que la pieza real gana una columna, un constraint, una validación, un cambio de tipo, un caso borde nuevo, se abre una grieta con su doble, a menos que alguien se acuerde de actualizar los dos lugares a la vez. Y "acordarse siempre" no es una estrategia; es una esperanza. La cantidad de dobles crece, el equipo rota, el que escribió el fake ya no está, y la distancia entre el doble y el real se ensancha calladamente release tras release. Un test verde con ese fake desactualizado no prueba que el sistema funcione: prueba que el sistema funcionaría si el repositorio todavía fuera como era hace tres meses. La causa dos es el tiempo trabajando en tu contra.

Causa tres: simplifica de más

La tercera causa es, en cierto modo, la más honesta de las tres, porque no es un defecto del doble: es su propósito. Un doble existe para ser más simple que la pieza real. El FakeBookingRepository es un dict precisamente porque no quieres el peso de una base de datos en tus unit tests: nada de conexiones, ni de SQL, ni de disco, ni de serialización. Esa simplicidad es lo que lo hace rápido y determinista, y es la razón por la que lo usas. Pero simplificar es, por definición, omitir. Y cada cosa que omites es un lugar donde el doble puede divergir de lo real, porque lo real no la omitió.

Mira todo lo que un dict en memoria omite frente al SqliteBookingRepository real, y fíjate en que cada omisión es sensata y a la vez peligrosa:

  • La serialización. El dict guarda el objeto Booking tal cual, con su datetime intacto. SQLite no puede: tiene que convertir el datetime a texto para guardarlo y devolverlo como texto al leerlo. El dict omite todo el viaje de ida y vuelta por columnas de una tabla —y con él, la divergencia de tipos de la lección 4—.
  • Los constraints. El dict no tiene noción de claves únicas de negocio, claves foráneas, columnas NOT NULL. Acepta lo que le des. SQLite hace cumplir cada regla del esquema —y ahí vive la divergencia de unicidad de la lección 5—.
  • Las transacciones. El dict muta de inmediato y para siempre; no sabe qué es un commit, un rollback o la atomicidad. SQLite envuelve las escrituras en transacciones que se pueden deshacer —y ahí vive la divergencia de transacciones de la lección 5—.
  • El orden. El dict conserva el orden de inserción de forma determinista. SQLite no promete ningún orden sin un ORDER BY, y con un índice de por medio devuelve otro —y ahí vive la divergencia de orden de la lección 4—.

Cada una de esas omisiones fue una buena decisión de diseño para el doble —incluirlas lo volvería tan pesado como lo real, y entonces para qué doblarlo—. Pero cada una es una promesa que el doble no puede hacer porque no modela la maquinaria que la sostiene. El doble no miente sobre estas cosas por descuido: miente porque para ser simple tuvo que dejarlas fuera, y lo que no modelas no lo puedes verificar. La causa tres es la paradoja del doble: es útil porque simplifica, y diverge porque simplifica. No hay forma de tener lo uno sin lo otro.

Las tres juntas: por qué el cuidado no basta

Junta las tres y verás por qué "escribe tus dobles con cuidado" es un consejo verdadero e insuficiente. El cuidado ataca, a lo sumo, la causa uno: si eres meticuloso al teclear, puedes hacer que tu fake nazca fiel. Pero el cuidado no hace nada contra la causa dos —no puedes, con voluntad, evitar que el sistema cambie y deje atrás a tu fake— ni contra la causa tres —no puedes, con voluntad, hacer que un dict modele transacciones sin dejar de ser un dict—. Dos de las tres fuerzas son inmunes a tu disciplina. Por eso la solución no puede ser humana ("acuérdate", "ten cuidado"): tiene que ser mecánica. Un mecanismo que, automáticamente y cada vez, verifique que el doble y el real siguen coincidiendo en el comportamiento que te importa, y que se ponga rojo el día que dejen de coincidir —por la causa que sea—. Ese mecanismo es el contrato: una batería de tests que se corre contra el fake y contra el real, y exige que ambos pasen. Es el tema del módulo 3, y ahora ya sabes exactamente qué problema resuelve y por qué ninguna cantidad de buenas intenciones lo resuelve en su lugar.

Errores comunes

Tratar la divergencia como un bug puntual en vez de una fuerza permanente. Qué pasa: alguien encuentra una divergencia, arregla ese fake, y da el problema por cerrado. Por qué pasa: se siente como un bug —lo encontraste, lo corregiste, desapareció—. Cómo detectarlo: pregúntate qué evita que aparezca otra divergencia la próxima vez que el real cambie. Si la respuesta es "que me acuerde de actualizar el fake", no cerraste nada; pospusiste el próximo caso. Cómo corregirlo: trata la divergencia como la corrosión, no como un clavo suelto: no la arreglas una vez, montas un mecanismo que la detecte siempre. Ese mecanismo es el contrato.

Creer que un fake más complejo (más "fiel") es la solución. Qué pasa: escarmentado por una divergencia, alguien engorda el fake para que modele constraints, orden, quizás transacciones, acercándolo cada vez más al real. Por qué pasa: si divergir viene de simplificar, parece que dejar de simplificar lo cura. Cómo detectarlo: si tu fake empieza a tener lógica de unicidad, de serialización y de orden, ya no es un doble simple y rápido: es una reimplementación de la base de datos, con sus propios bugs, y más lenta de mantener. Cómo corregirlo: no compitas con lo real haciendo el fake más gordo; eso solo mueve los bugs de sitio. Mantén el fake simple para lo que sirve (velocidad en los unit tests) y verifica su fidelidad con un contrato en vez de intentar eliminar la simplificación que lo hace útil.

Confundir "el fake pasó mis tests" con "el fake coincide con el real". Qué pasa: alguien prueba su fake, ve que se comporta como espera, y concluye que es fiel. Por qué pasa: un fake que pasa sus propios tests se siente validado. Cómo detectarlo: tus tests del fake fueron escritos con la misma suposición con la que escribiste el fake; confirman tu modelo mental, no la coincidencia con el real. Es el problema del oráculo circular que la lección 6 desarma. Cómo corregirlo: la única prueba de que el fake coincide con el real es correr la misma batería contra los dos y exigir que ambos pasen. Un fake validado solo contra sí mismo está validado contra nada.

Ejercicios

Ejercicio 1 — Clasifica la causa. Para cada divergencia, di cuál de las tres causas (se escribe a mano / se queda atrás / simplifica de más) es la dominante, y justifícalo: (a) el fake get devuelve None porque el autor usó .get() sin pensarlo; (b) el real ganó una columna cancelled_at el mes pasado y el fake sigue sin conocerla; (c) el fake acepta un price_cents negativo que el real rechaza con un CHECK (price_cents >= 0).

Ver solución
  • (a) Se escribe a mano. La divergencia estuvo en el fake desde el primer día, por lo que la mano tecleó (.get() en vez de [...]). No hubo un cambio en el real que la provocara ni una simplificación estructural: fue una elección de código, hecha a mano, que no coincidió con el comportamiento del real. Causa uno pura.
  • (b) Se queda atrás. Aquí el fake nació fiel y el tiempo lo separó: el real cambió (nueva columna), el fake no se actualizó, y la grieta se abrió sin que nadie escribiera código malo. Es el mecanismo de la causa dos en estado puro —la divergencia la produjo la evolución del real, no un descuido del fake—.
  • (c) Simplifica de más. El dict no modela constraints de dominio como un CHECK; aceptar cualquier valor es parte de su simplicidad. El real, que sí modela la maquinaria de constraints, rechaza el valor negativo. La divergencia nace de que el doble omitió, por diseño, algo que el real hace cumplir. Causa tres.

Nota que las tres producen el mismo síntoma —fake y real discrepan— pero exigen respuestas distintas de fondo. Solo la (a) se hubiera evitado con más cuidado al teclear; la (b) y la (c) no. Por eso el contrato, que las caza a las tres sin importar la causa, es la respuesta general.

Ejercicio 2 — La servilleta que envejece. Aplica la analogía del mapa dibujado de memoria a la causa dos. Describe un cambio concreto en el SqliteBookingRepository de Reservo que dejaría "atrasada" a la servilleta (el fake), y explica por qué el autor del cambio probablemente no tocaría el fake.

Ver solución

Un cambio concreto: el equipo decide que find_by_room debe devolver las reservas ordenadas por start (las próximas primero), y añade un ORDER BY start a la consulta del SqliteBookingRepository, más un índice en (room_id, start) para que sea rápido. Es una mejora legítima, con su test.

El autor de ese cambio probablemente no toca el FakeBookingRepository por dos razones muy humanas. Primera: el fake vive en otro archivo (reservo/doubles.py), no en reservo/sqlite_repo.py; el cambio está mentalmente localizado en "la consulta SQL", y el fake no aparece en su radar. Segunda: el fake, siendo un dict, ya devolvía las cosas en orden de inserción, que casi siempre coincide con el orden por start en los datos de prueba —así que ningún test del fake se pone rojo, y no hay señal que empuje a mirarlo—. La servilleta sigue diciendo "las reservas salen en el orden que las metiste", el barrio real ahora dice "salen ordenadas por hora", y nadie los confrontó. La divergencia queda latente hasta que un dato de prueba con orden de inserción distinto del cronológico —o producción— la despierta. (Es exactamente el caso que ejecutamos en la lección 4.)

Ejercicio 3 — ¿Engordar el fake o verificarlo? Un compañero, harto de divergencias, propone reescribir FakeBookingRepository para que modele el constraint de unicidad, el orden por start y hasta un rollback casero, "así el fake se comporta igual que el real y no divergen más". Evalúa la propuesta: ¿qué gana, qué pierde, y qué haría mejor?

Ver solución

Qué gana: en el papel, un fake que modela más se parece más al real, así que algunas divergencias de hoy desaparecen. La de unicidad, la de orden: si el fake las implementa bien, coincidirán con el real en esos puntos.

Qué pierde, y es más de lo que gana: (1) Velocidad y simplicidad, que eran la razón de existir del fake. Un fake con lógica de constraints, orden y transacciones ya no es un dict de cinco líneas; es una mini base de datos que hay que leer, entender y mantener. (2) Corrección: esa reimplementación tiene sus propios bugs, y ahora tienes dos implementaciones complejas que pueden divergir entre sí de formas nuevas —el fake podría implementar el rollback casero mal, y tus tests confiarían en un rollback que no funciona—. (3) No cierra la causa dos: por muy fiel que hagas el fake hoy, el día que el real cambie, el fake gordo se queda igual de atrás que el flaco —y ahora es más trabajo actualizarlo—.

Qué haría mejor: mantener el fake deliberadamente simple (es su virtud) y montar un contrato: una batería de tests de comportamiento que se corre contra el fake y contra el real, y que se pone roja en el instante en que discrepan. Así no persigues la fidelidad reimplementando la base de datos —tarea infinita y contraproducente—, sino que mides la fidelidad y te enteras cuando se rompe. El fake se queda flaco y honesto; el contrato hace el trabajo de vigilancia. Esa es, precisamente, la solución del módulo 3.

Resumen y siguiente paso

En esta lección fundamentaste la afirmación que traías pendiente: los dobles divergen por naturaleza, no por descuido. Desarmaste las tres causas —se escribe a mano (nada obliga a que coincida con lo real), se queda atrás (el real cambia y el doble no), simplifica de más (omite, por diseño, lo que lo real hace cumplir)— y viste, con dos tests verdes que afirman comportamientos opuestos, hasta qué punto Python deja convivir a un fake y un real discrepantes sin decir una palabra. Y entendiste la consecuencia práctica: como dos de las tres fuerzas son inmunes a tu disciplina, la solución no puede ser el cuidado humano, sino un mecanismo automático que verifique la coincidencia siempre.

Antes de avanzar deberías poder: nombrar las tres causas de la divergencia y dar un ejemplo de Reservo para cada una; explicar por qué "escribe tus dobles con cuidado" ataca solo una de las tres; y argumentar por qué engordar el fake no es la solución.

Ya sabes por qué divergen. Toca ver una divergencia en acción, completa, de punta a punta y ejecutada. La lección 3 toma la divergencia de comportamiento más limpia —el fake devuelve None, el real lanza— y la lleva hasta sus últimas consecuencias: el mismo escenario, el mismo código bajo prueba, pasando en verde con el fake y estallando en rojo con el SqliteBookingRepository real. Es la mentira del doble, expuesta con salida de pytest lado a lado.

Recursos

  • Documentación de pytest — Cómo escribir y reportar aserciones — cómo pytest reporta un assert y un pytest.raises, las dos formas con las que en esta lección afirmamos comportamientos opuestos (devuelve None / lanza) sobre la misma interfaz.
  • sqlite3 — DB-API para SQLite (documentación de Python) — la referencia del "real" cuyas capacidades (serialización, constraints, transacciones, orden) el dict del fake omite; leer qué hace SQLite es leer la lista de lo que tu fake no modela.
  • Martin Fowler — TestDouble — el vocabulario canónico de los dobles y una descripción clara de por qué un doble es una simplificación deliberada de lo real; el trasfondo conceptual de la causa tres.
  • test-doubles-and-test-data-guide — la guía hermana donde se construyen los dobles con cuidado; esta lección explica por qué ese cuidado, siendo necesario, no es suficiente contra las causas dos y tres.