Módulo 2: El doble que miente: el problema que motiva los contratos
1. Presentación del módulo: el doble que miente
Descripción
El módulo 1 terminó con una frase que suena casi a paradoja: un test verde puede esconder un sistema roto. La viste una vez, con el datetime que el fake devuelve intacto y el SqliteBookingRepository real devuelve como texto. Fue un vistazo. Este módulo es la investigación completa. Vamos a tomar esa grieta —la que hay entre lo que tu doble supone y lo que la pieza real hace— y la vamos a abrir, medir y ejecutar hasta que entiendas exactamente cómo se produce, en cuántas formas distintas, por qué tu suite unitaria es incapaz de detectarla, y qué cuesta cuando llega a producción sin que nadie la haya visto.
El problema tiene una raíz simple, y conviene decirla sin adornos desde el principio. Un doble no es la pieza real: es una suposición sobre cómo se comporta la pieza real, escrita a mano por ti. Cuando escribiste FakeBookingRepository, decidiste qué hace save, qué devuelve get, qué lanza y qué no. Cada una de esas decisiones es una apuesta sobre cómo se comporta el SqliteBookingRepository de verdad. Si aciertas, el doble es un espejo fiel y tu unit test dice la verdad. Si te equivocas en una sola de esas apuestas, el doble miente, y —esto es lo grave— miente en verde. El test que usa el doble no puede contradecir la suposición, porque el doble es la suposición. Le preguntas al doble si tu código funciona, el doble responde que sí, y los dos están de acuerdo en la misma creencia equivocada. Verde. Deploy. Roto.
Conexión con el módulo: esta lección es el planteamiento del problema que las dos disciplinas de la guía existen para resolver. Aquí no hay solución todavía —el contrato llega en el módulo 3—; hay diagnóstico. Vas a conocer el mapa de las seis lecciones que siguen: por qué los dobles divergen (lección 2), la divergencia central get→None contra get→lanza ejecutada de punta a punta (lección 3), las divergencias de tipos y orden (lección 4), las de unicidad y transacciones (lección 5), la razón estructural por la que el unit test no lo ve (lección 6) y el costo en producción (lección 7), antes del mini-proyecto que te pone a cazar una divergencia tú mismo (lección 8). Si el módulo 1 te dio el porqué de la integración, este te da el problema exacto que la integración y el contrato resuelven. Sin entender bien esta mentira, la solución de los módulos siguientes te parecería burocracia. Con ella entendida, te va a parecer obvia.
Analogía: el simulador de vuelo
Un piloto no aprende a volar estrellando aviones. Aprende en un simulador de vuelo: una cabina idéntica a la real, con las mismas palancas, las mismas pantallas y un modelo de física que imita cómo responde el avión. El simulador es una herramienta extraordinaria —barata, segura, repetible—: puedes practicar un aterrizaje con motor apagado cien veces sin poner en riesgo a nadie. Es, en todo sentido, un doble del avión real. Y las aerolíneas confían tanto en él que certifican pilotos con horas de simulador. Suena perfecto, y casi siempre lo es.
Pero el simulador tiene un punto ciego que es exactamente el de un doble de software: es tan bueno como la suposición con la que se programó. Alguien tuvo que modelar cómo responde el avión, y ese modelo es una suposición sobre el avión real. Si el modelo del simulador asume que cierto sensor nunca se congela, o que el motor reacciona de tal manera, y el avión de verdad hace otra cosa, entonces el piloto ha entrenado a la perfección para un avión que no existe. Aprobó cada examen en el simulador —verde, verde, verde— porque el simulador confirma su propio modelo. La grieta entre el modelo y el avión real no aparece en ningún examen de simulador; aparece a diez mil metros, con pasajeros a bordo, cuando el avión hace lo que el simulador nunca le enseñó que haría. Ha habido accidentes reales por exactamente esto: un simulador que modelaba un comportamiento distinto del avión de verdad.
El FakeBookingRepository es tu simulador; el SqliteBookingRepository es el avión real. Tu unit test es el examen en el simulador: rápido, seguro, repetible, y absolutamente confiable mientras el simulador modele bien el avión. El día que el fake supone que get devuelve None y el real lanza, entrenaste tu código para un repositorio que no existe. Todos los exámenes de simulador salen verdes. La grieta solo aparece "a diez mil metros" —en producción, con la pieza real conectada—. La guía entera es, en el fondo, aprender a no confiar ciegamente en el simulador: verificar que su modelo coincide con el avión (eso es el contrato, módulo 3) y, de vez en cuando, volar el avión de verdad (eso es la integración, módulo 5).
Un primer vistazo a la mentira
En el módulo 1 la divergencia fue de forma: un datetime que cambia de tipo al cruzar la costura. Aquí vamos a mirar una divergencia de comportamiento, que es aún más traicionera porque no cambia ningún dato: cambia lo que la pieza hace cuando le pides algo que no tiene.
El contrato implícito del repositorio, el que anotamos en el módulo 1, dice: "get de un id ausente lanza". El SqliteBookingRepository real lo cumple —si no encuentra la fila, hace raise KeyError—. Pero nada obliga a que el fake lo cumpla. Imagina que un compañero, escribiendo su fake a las apuradas, usó el método .get() del diccionario en vez de la indexación con corchetes:
# reservo/doubles.py — el fake tal como lo escribió un dev distraído
class BuggyFakeBookingRepository:
def __init__(self):
self._store = {}
def save(self, booking):
self._store[booking.id] = booking
def get(self, booking_id):
return self._store.get(booking_id) # ← .get(): devuelve None, NO lanza
def find_by_room(self, room_id):
return [b for b in self._store.values() if b.room_id == room_id]
Un solo carácter de diferencia con el fake canónico: self._store.get(booking_id) en vez de self._store[booking_id]. Y sin embargo el comportamiento diverge en el caso más importante: dict.get(clave_ausente) devuelve None en silencio, mientras que dict[clave_ausente] lanza KeyError. Esa .get() parecía razonable —Python tiene el método, se ve limpio— y acaba de romper el contrato sin que nadie lo note. Ahora un desarrollador escribe una feature nueva —cancelar es idempotente: cancelar algo que ya no existe no es un error, simplemente reembolsa 0— y la escribe asumiendo el comportamiento que ve en su fake:
# reservo/idempotent.py — cancelar asumiendo que get devuelve None
def cancel(self, booking_id) -> int:
booking = self._repo.get(booking_id)
if booking is None: # ← asume que get devuelve None si no existe
return 0 # nada que cancelar, nada que reembolsar
...
El if booking is None es un guard perfectamente sensato... si get devolviera None. Con el fake descuidado, lo hace, y todo funciona. Con el SqliteBookingRepository real, que lanza, ese get nunca devuelve None: revienta antes de llegar a la línea del if. El guard es código muerto contra la pieza real.
Qué esperar: la mentira, de un vistazo
Veamos la brecha con nuestros propios ojos —un adelanto; el desglose línea por línea es la lección 3—. Dos tests que afirman exactamente lo mismo (cancelar un id inexistente devuelve 0), y que solo difieren en qué repositorio reciben: el fake descuidado (unit) o el SqliteBookingRepository real (integración). En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_none_vs_raise.py -v
tests/test_none_vs_raise.py::test_cancel_missing_booking_returns_zero_with_fake PASSED [ 50%]
tests/test_none_vs_raise.py::test_cancel_missing_booking_returns_zero_with_sqlite FAILED [100%]
...
reservo/idempotent.py:19: in cancel
booking = self._repo.get(booking_id)
...
E KeyError: 'bk-does-not-exist'
reservo/sqlite_repo.py:50: KeyError
========================= 1 failed, 1 passed in 0.05s ==========================
Ahí está la mentira, sin adornos. El unit test con el fake pasa: el guard recibió su None y devolvió 0. El de integración con el real falla: el get lanzó KeyError en la primera línea de cancel, y el guard nunca se ejecutó. Un mismo escenario, un verde y un rojo, según qué pieza esté enchufada. Quédate con la anatomía de la mentira: el código se escribió correctamente para el fake, el unit test pasa verde, y la pieza real lo expone en rojo. El doble no cometió un error de programación; cumplió su papel de espejo. El problema es que reflejaba una suposición falsa.
El mapa del módulo
Este módulo tiene una sola tesis —los dobles divergen, y la divergencia miente en verde— y la ataca desde seis ángulos, cada uno con su ejecución real:
| Lección | Ángulo | La idea en una frase |
|---|---|---|
| 2 | Por qué divergen | Un doble se escribe a mano, se queda atrás y simplifica de más: divergir es su tendencia natural |
| 3 | get→None vs get→lanza | La divergencia de comportamiento, ejecutada: fake verde, SQLite rojo, mismo escenario |
| 4 | Tipos y orden | El datetime que vuelve como str; el orden que el fake promete y el motor no garantiza |
| 5 | Unicidad y transacciones | El IntegrityError que el fake no lanza; el rollback que el fake no puede hacer |
| 6 | Por qué el unit test no lo ve | El doble es a la vez el sujeto y el oráculo: preguntarle si tiene razón siempre da que sí |
| 7 | El costo en producción | Detección tardía, radio de impacto amplio, depuración confusa: la cuenta que paga el negocio |
Y la lección 8 te pone del otro lado: te damos una feature con una divergencia escondida y tú escribes el unit test que se queda verde, el de integración que se pone rojo, y el diagnóstico de por qué. Al terminar, no solo sabrás que los dobles mienten: sabrás olerlo, reproducirlo y explicarlo, que es la condición para valorar el contrato cuando llegue en el módulo 3.
Lo que este módulo NO es (la frontera con la guía de dobles)
Conviene un aviso honesto, porque hay un solapamiento aparente. En la guía hermana test-doubles-and-test-data-guide ya hay una lección titulada, más o menos, "un fake puede divergir de lo real". Si la leíste, la idea básica no te es nueva: un fake mal escrito devuelve None donde debería lanzar, y eso enmascara un bug. Aquí no repetimos esa lección; la usamos como punto de partida. La diferencia de foco es la que define esta guía:
- Allá, la divergencia era una anécdota dentro de un módulo sobre cómo construir buenos fakes: la moraleja era "escribe tu fake con cuidado para que honre el contrato".
- Aquí, la divergencia es el problema central que motiva una disciplina entera. La moraleja no es "ten cuidado" —tener cuidado no escala, la gente se equivoca—, sino "no confíes en el cuidado: verifica que el fake y el real coinciden con una batería de tests compartida (el contrato) y prueba de vez en cuando contra lo real (la integración)". Ese salto de "ten cuidado" a "verifica sistemáticamente" es exactamente lo que separa esta guía de la de dobles.
Si "construir un fake" o "inyectar un colaborador" te suenan flojos, ese repaso es la guía de dobles. Aquí asumimos que sabes doblar; el trabajo es dejar de creerle al doble por fe.
Errores comunes
Creer que "el fake honra el contrato" es un problema resuelto porque lo escribiste con cuidado. Qué pasa: escribiste tu FakeBookingRepository con self._store[booking_id] para que lance, sientes que hiciste bien tu trabajo, y das el asunto por cerrado. Por qué pasa: un fake correcto hoy se siente permanente. Cómo detectarlo: pregúntate quién garantiza que el fake siga coincidiendo con el real dentro de seis meses, cuando el real gane una columna, un constraint o un cambio de tipo, y el fake se quede quieto. Nadie lo garantiza, salvo un test que compare los dos. Cómo corregirlo: el cuidado individual no escala ni sobrevive al tiempo; lo que sobrevive es una verificación automatizada de que ambos lados coinciden. Ese es el contrato, y por eso el módulo 3 existe.
Confundir "mi fake tiene los mismos métodos que el real" con "mi fake se comporta como el real". Qué pasa: alguien ve que el fake y el real tienen ambos save, get y find_by_room, y concluye que son intercambiables. Por qué pasa: que dos piezas encajen en la misma costura (tengan los métodos) se siente como que se comportan igual. Cómo detectarlo: es justo la divergencia get→None vs get→lanza: los dos tienen get, los dos encajan, y hacen cosas distintas cuando el id no existe. Cómo corregirlo: la interfaz (los nombres de los métodos) es la forma del contrato; el comportamiento de esos métodos es su contenido, y es lo que hay que verificar. Encajar no es cumplir.
Pensar que este problema solo les pasa a los fakes descuidados. Qué pasa: alguien lee que un fake "mal escrito" divergió y concluye "a mí no me pasa porque yo escribo bien". Por qué pasa: la palabra "descuidado" invita a creer que la divergencia es un error de disciplina. Cómo detectarlo: la divergencia del datetime del módulo 1 no venía de un fake descuidado —el fake era impecable—; venía de que SQLite serializa y el dict no. Ese abismo no lo cierra ninguna cantidad de cuidado, porque el fake y el real son tecnologías distintas. Cómo corregirlo: acepta que la divergencia es la tendencia natural de todo doble, cuidadoso o no (es el tema de la lección 2), y trátala con herramientas, no con buenas intenciones.
Ejercicios
Ejercicio 1 — ¿Miente en verde o en rojo? Para cada situación, di si el unit test que usa el fake pasaría (verde) o fallaría (rojo), y si esa respuesta es fiel a lo que haría el sistema real: (a) el fake get devuelve None para un id ausente, el real lanza, y el código bajo prueba hace if booking is None: return 0; (b) el fake get lanza KeyError para un id ausente, el real también lanza, y el código no maneja la excepción; (c) el fake save acepta dos reservas distintas en la misma sala y hora, el real tiene un UNIQUE(room_id, start) que lo rechaza.
Ver solución
- (a) Verde, y NO es fiel. Con el fake que devuelve
None, elif booking is None: return 0se cumple y el test pasa. Pero el real lanza, así que en producción ese código nunca llega alif—revienta antes—. El verde es una mentira: afirma un comportamiento (cancelación idempotente) que el sistema real no tiene. Es exactamente la divergencia de la lección 3. - (b) Rojo, y SÍ es fiel. Con el fake que lanza (el canónico), el código que no maneja la excepción también falla en el test, igual que fallaría en producción con el real que lanza. Aquí el fake y el real coinciden, así que el rojo del test es una advertencia honesta: hay un bug de manejo de errores, y lo verías tanto con el fake como con el real. No hay mentira; hay un test haciendo bien su trabajo.
- (c) Verde, y NO es fiel. El fake, un
dict, no modela el constraint de unicidad, así que acepta las dos reservas y el test pasa. El real las rechaza conIntegrityError. El verde esconde una regla de negocio (no hay dobles reservas) que el fake es incapaz de expresar. Es la divergencia de unicidad de la lección 5.
El patrón: un test verde solo es información cuando el fake coincide con el real en el punto que el test ejerce. Cuando divergen, el verde no dice "el sistema funciona"; dice "el fake está de acuerdo consigo mismo".
Ejercicio 2 — El carácter que cambió todo. El fake descuidado difiere del canónico en un solo carácter: self._store.get(booking_id) en vez de self._store[booking_id]. Explica, en términos de Python, por qué ese cambio mínimo produce una divergencia de comportamiento, y por qué el autor del fake casi nunca lo nota al escribirlo.
Ver solución
En Python, dict[clave] y dict.get(clave) se comportan igual cuando la clave existe —ambos devuelven el valor— pero divergen cuando no existe: dict[clave_ausente] lanza KeyError, mientras que dict.get(clave_ausente) devuelve None en silencio (ese es justamente el propósito de .get(): una lectura que no falla). El fake canónico usa [...], que lanza, y así imita el raise KeyError del SqliteBookingRepository real. El fake descuidado usa .get(), que devuelve None, y rompe el contrato precisamente en el caso del id ausente.
El autor casi nunca lo nota porque, mientras escribe y prueba su fake, usa ids que sí existen —guarda una reserva y la lee de vuelta—, y en ese camino feliz [...] y .get() son idénticos. El caso del id ausente es un camino que rara vez se ejercita al construir el doble; se ejercita en producción, cuando llega un id que no está. La divergencia vive en el borde que el autor no visitó, y por eso pasa inadvertida hasta que la pieza real la revela.
Ejercicio 3 — Traduce la analogía. El simulador de vuelo es un doble del avión. Empareja cada elemento de la analogía con su equivalente en Reservo y explica el emparejamiento en una frase: (a) el modelo de física del simulador; (b) aprobar todos los exámenes de simulador; (c) el comportamiento del avión real a diez mil metros; (d) la certificación que asume que el simulador coincide con el avión.
Ver solución
- (a) El modelo de física ↔ el código del
FakeBookingRepository. Ambos son una suposición, escrita a mano, sobre cómo se comporta lo real. El modelo supone cómo responde el avión; el fake supone qué hace el repositorio. La calidad del doble es la calidad de esa suposición. - (b) Aprobar todos los exámenes de simulador ↔ una suite unitaria toda en verde. Los dos son confianza construida sobre el doble. Salen verdes porque el doble confirma su propio modelo, no porque el sistema real esté sano.
- (c) El avión real a diez mil metros ↔ el
SqliteBookingRepositoryen producción. Es donde la suposición se pone a prueba contra la realidad, y donde una divergencia —que ningún examen de simulador mostró— finalmente aparece, con consecuencias reales. - (d) La certificación que asume coincidencia ↔ desplegar confiando en la suite verde. Ambas son el acto de riesgo: dar por bueno el sistema real basándose en que el doble pasó. La certificación es segura solo si alguien verificó que el simulador coincide con el avión; el deploy es seguro solo si alguien verificó que el fake coincide con el real. Esa verificación es el contrato (módulo 3) y la integración (módulo 5).
La moraleja de la analogía: nadie propone tirar el simulador —es demasiado útil—. Se propone verificar que su modelo coincide con el avión, y complementarlo con vuelo real. Lo mismo con los dobles: no se tiran; se verifican y se complementan.
Resumen y siguiente paso
En esta lección planteaste el problema que vertebra la guía entera. Un doble es una suposición sobre lo real, escrita a mano; y cuando la suposición falla, el unit test que usa el doble no falla con ella, sino que confirma la creencia equivocada y pasa en verde. Lo viste con el simulador de vuelo —un doble excelente y peligroso justo por lo bueno que es— y con un primer vistazo a la divergencia central: un solo carácter, .get() en vez de [...], convierte un fake razonable en un mentiroso que devuelve None donde el real lanza. Y marcaste la frontera con la guía de dobles: aquí no aprendemos a construir fakes con cuidado, aprendemos a dejar de confiar en el cuidado y a verificar.
Antes de avanzar deberías poder: enunciar por qué un doble puede pasar en verde mientras el sistema real está roto; distinguir "el fake tiene los métodos del real" de "el fake se comporta como el real"; y explicar por qué este problema no se resuelve escribiendo dobles con más cuidado.
Dijimos que la divergencia es la tendencia natural de todo doble, no un accidente de los descuidados. Es una afirmación fuerte y hay que fundamentarla. La lección 2 lo hace: desglosa las tres razones de fondo por las que un doble se separa de lo real —se escribe a mano, se queda atrás y simplifica de más— para que entiendas que la pregunta correcta no es si tu doble divergirá, sino cuándo y dónde.
Recursos
- Documentación de pytest — Getting Started — la puerta de entrada oficial a pytest, la herramienta con la que ejecutamos y citamos cada salida del módulo; ú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 "avión real" en toda la guía; su forma de manejar tipos y constraints es la fuente de varias divergencias que ejecutaremos.dict.gety el acceso por clave (documentación de Python) — el detalle exacto detrás de la divergenciaNonevsKeyError:d.get(k)devuelveNonepara una clave ausente,d[k]lanza. Un carácter de diferencia, un contrato roto.test-doubles-and-test-data-guide— la guía hermana donde construiste elFakeBookingRepositoryy donde ya se mencionó que un fake puede divergir; este módulo toma esa idea y la lleva hasta motivar una disciplina entera.