Módulo 2: El doble que miente: el problema que motiva los contratos
3. Cuando el fake devuelve `None` y el real lanza
Descripción
Esta es la lección central del módulo, la que da nombre a la guía en su versión más pura. Hasta aquí has visto la mentira del doble descrita y fundamentada; ahora la vas a ver ejecutada, completa, con las dos piezas enchufadas y la salida real de pytest lado a lado. El escenario es el más limpio posible: una divergencia de comportamiento, no de forma. No hay ningún dato que cambie de tipo, ningún datetime que se convierta en texto. Lo único que difiere entre el fake y el real es qué hacen cuando les pides una reserva que no existe: el fake descuidado devuelve None, el SqliteBookingRepository real lanza KeyError. Un comportamiento contra otro. Y sobre esa única diferencia se construye un bug que pasa el unit test en verde y explota en producción en rojo.
Lo que hace a esta divergencia tan didáctica es que el código bajo prueba está bien escrito —para el fake—. No hay un error de programación que un revisor pudiera señalar con el dedo. El desarrollador escribió una feature razonable (cancelar es idempotente), la probó a conciencia contra su repositorio de pruebas, y la vio funcionar. Su test es correcto, su código es correcto, su fake es correcto en el sentido de que hace lo que él cree. Y aun así el sistema real está roto, porque toda esa corrección descansa sobre una suposición —"get devuelve None si no existe"— que el fake confirma y el real desmiente. Vas a ver el momento exacto en que la suposición se rompe, en el número de línea, con el KeyError a la vista.
Conexión con el módulo: esta lección es la demostración que las lecciones 1 y 2 prometieron. La 1 planteó el problema y te dio un vistazo; la 2 explicó por qué la divergencia era inevitable (aquí, la causa "se escribe a mano" en su forma más clara); esta la ejecuta de principio a fin. Las lecciones 4 y 5 mostrarán otras familias de divergencia —tipos, orden, unicidad, transacciones—, pero todas son variaciones de lo que ves aquí: fake verde, real rojo, mismo escenario. La lección 6 explicará por qué el unit test es estructuralmente incapaz de ver esto, y la 7 medirá lo que cuesta. Si entiendes esta lección a fondo —no solo que falla, sino por qué el verde era una mentira y dónde exactamente se rompe—, tienes el módulo entero en la mano.
Analogía: el guardarropa que devuelve un perchero vacío
Imagina dos guardarropas de dos teatros. En los dos dejas tu abrigo y te dan un número. La diferencia aparece cuando llegas con un número equivocado —uno que no corresponde a ningún abrigo—. El primer guardarropa, atendido por alguien nuevo, mira el perchero, no encuentra nada, y en vez de decírtelo te entrega el perchero vacío: no protesta, no avisa, te da "nada" con una sonrisa. El segundo guardarropa, con el protocolo estricto, mira, no encuentra nada, y te lo dice a la cara: "ese número no existe". Los dos hicieron su trabajo; solo difieren en cómo tratan el caso del número sin abrigo. Uno devuelve vacío en silencio; el otro levanta la voz.
Ahora imagina que construyes una máquina automática que recoge abrigos, y la ajustas probándola solo con el primer guardarropa. Tu máquina aprende: "si me dan el perchero vacío, es que no había abrigo, así que sigo de largo". Funciona de maravilla, la pruebas cien veces, perfecta. El día que conectas tu máquina al segundo guardarropa —el del protocolo estricto—, le das un número equivocado, y el guardarropa te grita "ese número no existe" en vez de darte el perchero vacío. Tu máquina no sabe qué hacer con un grito: se programó para esperar un perchero vacío, no una protesta. Se traba. El FakeBookingRepository descuidado es el primer guardarropa (devuelve None, el perchero vacío); el SqliteBookingRepository real es el segundo (lanza KeyError, el grito); y tu código —la máquina— se ajustó al primero y se traba con el segundo. Toda la lección es esa máquina trabándose, en pytest, con el número de línea.
El montaje: la feature, el fake y el real
Tres piezas. Primero, la feature que el desarrollador construyó: una cancelación idempotente. La regla de negocio es sensata: si un socio pulsa "Cancelar" dos veces, o abre un enlace viejo de una reserva ya borrada, no queremos un error feo; queremos tratarlo con calma —no hay nada que cancelar, así que el reembolso es 0—. El desarrollador escribe el guard de la única forma que tiene sentido si get devuelve None:
# reservo/idempotent.py — una feature nueva: cancelar es idempotente
from reservo.models import Member
from reservo.pricing import refund_cents
class IdempotentBookingService:
"""Como BookingService, pero cancelar una reserva inexistente
NO es un error: simplemente reembolsa 0 (por si el socio hace doble clic
o abre un enlace viejo). El dev escribió el guard asumiendo que
repo.get() devuelve None cuando la reserva no existe."""
def __init__(self, clock, payments, emails, repo):
self._clock = clock
self._payments = payments
self._emails = emails
self._repo = repo
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
refund = refund_cents(booking, booking.price_cents, self._clock.now())
member = Member(id=booking.member_id, name=booking.member_id, tier="pro")
if refund > 0:
self._payments.refund(refund, member)
booking.status = "cancelled"
self._repo.save(booking)
return refund
El corazón es la línea if booking is None: return 0. Léela con los ojos del desarrollador: es defensiva, es clara, maneja el caso raro con elegancia. No hay nada que objetar... si get cumple la suposición.
Segundo, el repositorio con el que la probó: el fake descuidado que viste en la lección 1, el que usa .get() y devuelve None:
# 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]
Y tercero, el repositorio real, el SqliteBookingRepository que ya conoces del módulo 1, cuyo get hace raise KeyError(booking_id) cuando la fila no existe. Las tres piezas están listas. Ahora las enchufamos.
Ejemplo trabajado: el mismo test, dos repositorios
El experimento es deliberadamente simétrico. Dos tests que afirman exactamente lo mismo —cancelar una reserva inexistente devuelve 0, la promesa de la cancelación idempotente— y que difieren en una sola cosa: qué repositorio recibe el servicio. Uno recibe el fake descuidado (es un unit test: la unidad aislada con un doble en memoria); el otro recibe el SqliteBookingRepository real (es un test de integración: el servicio y la pieza real, juntos).
# tests/test_none_vs_raise.py
import sqlite3
from datetime import datetime
from reservo.doubles import (BuggyFakeBookingRepository, FixedClock,
SpyEmailSender, StubPaymentGateway)
from reservo.idempotent import IdempotentBookingService
from reservo.sqlite_repo import SqliteBookingRepository
CLOCK = datetime(2026, 3, 1, 9)
def make_service(repo):
return IdempotentBookingService(
FixedClock(CLOCK), StubPaymentGateway(ok=True), SpyEmailSender(), repo)
# --- unit: el repo es el fake descuidado (get -> None) ---
def test_cancel_missing_booking_returns_zero_with_fake():
repo = BuggyFakeBookingRepository()
service = make_service(repo)
refund = service.cancel("bk-does-not-exist")
assert refund == 0 # el guard `if booking is None` funciona... con el fake
# --- integracion: el repo es SQLite de verdad (get -> lanza) ---
def test_cancel_missing_booking_returns_zero_with_sqlite():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = make_service(repo)
refund = service.cancel("bk-does-not-exist")
assert refund == 0 # <-- aqui el real LANZA antes de llegar al guard
Los dos tests cancelan "bk-does-not-exist" —un id que nunca se guardó— y esperan 0. En un mundo donde el fake y el real coincidieran, los dos pasarían o los dos fallarían. Como divergen justo en el caso del id ausente, uno pasa y el otro no. Corramos.
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1):
python3 -m pytest tests/test_none_vs_raise.py -v
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
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%]
=================================== FAILURES ===================================
_____________ test_cancel_missing_booking_returns_zero_with_sqlite _____________
def test_cancel_missing_booking_returns_zero_with_sqlite():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
service = make_service(repo)
> refund = service.cancel("bk-does-not-exist")
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_none_vs_raise.py:32:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
reservo/idempotent.py:19: in cancel
booking = self._repo.get(booking_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
self = <reservo.sqlite_repo.SqliteBookingRepository object at 0x10777b620>
booking_id = 'bk-does-not-exist'
def get(self, booking_id):
row = self._conn.execute(
"SELECT id, room_id, member_id, start, end, status, price_cents "
"FROM bookings WHERE id = ?",
(booking_id,),
).fetchone()
if row is None:
> raise KeyError(booking_id) # el real LANZA si no existe
^^^^^^^^^^^^^^^^^^^^^^^^^^
E KeyError: 'bk-does-not-exist'
reservo/sqlite_repo.py:50: KeyError
=========================== short test summary info ============================
FAILED tests/test_none_vs_raise.py::test_cancel_missing_booking_returns_zero_with_sqlite - KeyError: 'bk-does-not-exist'
========================= 1 failed, 1 passed in 0.05s ==========================
Ahí está la mentira, completa y sin retórica. El unit test con el fake pasa: el guard if booking is None: return 0 recibió el None que esperaba y devolvió 0, tal como el desarrollador diseñó. El test de integración con el SqliteBookingRepository real falla, y el traceback te cuenta la historia exacta. Sigue el rastro de arriba abajo: la llamada arranca en service.cancel("bk-does-not-exist") (línea 32 del test), entra en idempotent.py:19 —la primerísima línea de cancel, booking = self._repo.get(booking_id)—, y ahí baja al get del SqliteBookingRepository, que no encuentra la fila (row is None) y hace raise KeyError. El KeyError sube y revienta el test. La línea if booking is None nunca se ejecutó: el get lanzó antes de devolver nada, así que el guard es código muerto contra la pieza real. La máquina se ajustó al perchero vacío y se trabó con el grito.
Por qué el verde era una mentira, con precisión
Vale la pena decir con exactitud qué afirmaba cada test, porque ahí está la lección. El unit test verde afirmaba: "cancelar una reserva inexistente devuelve 0". Suena como una afirmación sobre el sistema. No lo es. Lo que en realidad verificó es más estrecho: "cancelar una reserva inexistente devuelve 0, cuando el repositorio devuelve None para ids ausentes". Esa cláusula final —la condición sobre el repositorio— estaba escondida, porque el fake la cumplía en silencio y nadie la escribió como parte de lo que el test decía probar. El desarrollador leyó el verde como "el sistema hace X"; el verde en realidad decía "el sistema hace X si el repo se comporta como mi fake". Y el repo real no se comporta como el fake. La condición escondida era falsa en producción, así que la conclusión —el verde— era una mentira sobre el sistema real, aunque fuera una verdad sobre el fake.
Esta es la anatomía general de todo verde mentiroso, y conviene grabarla: un test con un doble no prueba una propiedad del sistema; prueba una propiedad del sistema condicionada a que el doble coincida con lo real. Cuando la coincidencia se da, la condición es invisible y el verde es honesto. Cuando la coincidencia falla —por cualquiera de las tres causas de la lección 2—, la condición sigue invisible pero ahora es falsa, y el verde miente sin cambiar de color. No hay forma, mirando solo el unit test, de saber en cuál de los dos mundos estás. Para saberlo tienes que hacer una de dos cosas: correr el mismo escenario contra la pieza real (integración, lo que acabamos de hacer) o verificar por separado que el fake y el real coinciden en ese punto (contrato, módulo 3). El unit test, solo, es ciego a la diferencia.
¿De quién es la culpa? De nadie, y ese es el punto
Es tentador buscar al culpable, y el ejercicio revela por qué el problema es serio. ¿Fue el desarrollador de la feature? Escribió un guard defensivo y razonable, y lo probó. ¿Fue el autor del fake? Escribió un fake que funciona para el camino feliz, con un .get() que se ve limpio. ¿Fue el test? Afirma algo cierto sobre el sistema que probó. ¿Fue el SqliteBookingRepository? Hace exactamente lo correcto: lanzar cuando no encuentra, que es el comportamiento que el contrato pide. Cada pieza, mirada sola, es defendible. Y sin embargo el sistema está roto. El bug no vive en ninguna pieza; vive en la grieta entre dos piezas —entre el fake y el real—, en un desacuerdo sobre el comportamiento que ninguna de las dos, por sí sola, tenía la responsabilidad de detectar.
Ese es exactamente el tipo de bug que las disciplinas de esta guía atacan, y por eso ninguna cantidad de revisión de código pieza por pieza lo habría cazado: un revisor mirando idempotent.py ve código correcto; mirando doubles.py ve un fake plausible; mirando el test ve una aserción válida. El bug solo aparece cuando confrontas dos piezas que nadie confrontó. La revisión de código mira piezas; el contrato mira acuerdos entre piezas. Guárdate esta idea para el módulo 3: el contrato es la herramienta que hace explícito el acuerdo "get de un id ausente lanza" y lo verifica contra el fake y el real a la vez, de modo que el fake descuidado —que devuelve None— se pondría rojo en el contrato, en tu máquina, antes de cualquier deploy. La grieta que hoy es invisible se volvería una línea roja imposible de ignorar.
Errores comunes
Leer el if booking is None como el bug. Qué pasa: alguien ve el fallo, mira idempotent.py, y "arregla" el guard —lo quita, o lo cambia por un try/except— sin entender la causa. Por qué pasa: el guard es lo más visible en el traceback. Cómo detectarlo: pregúntate si el guard estaría bien con un repositorio que devolviera None; la respuesta es sí, es correcto para ese contrato. El bug no es el guard: es que el guard supone un contrato (get→None) que el repo real no cumple (get→lanza). Cómo corregirlo: primero decide cuál es el contrato de verdad —¿get de un id ausente devuelve None o lanza?— y luego haz que ambos lados lo cumplan y que el código bajo prueba lo respete. Cambiar el guard a ciegas puede tapar este caso y abrir otro; la cura es acordar el contrato, no parchear el síntoma.
Concluir "entonces los fakes son peligrosos, no los uses". Qué pasa: escarmentado, alguien decide probar todo contra el SqliteBookingRepository real y jubilar el fake. Por qué pasa: si el real cazó el bug, el real parece siempre mejor. Cómo detectarlo: si tu suite empieza a tardar y a depender del disco para probar lógica que no toca la base de datos, te fuiste al otro extremo. Cómo corregirlo: el fake no es el villano; el villano es creerle al fake sin verificarlo. La respuesta no es tirar el fake (perderías la velocidad de la base de la pirámide), sino verificar que coincide con el real (contrato) y añadir unos pocos tests contra lo real (integración). Se usan los dos, cada uno para lo suyo.
Suponer que un traceback claro significa un bug fácil. Qué pasa: el KeyError apunta a una línea exacta, así que alguien asume que el bug es trivial. Por qué pasa: en este ejemplo didáctico, el error salta cerca de su causa. Cómo detectarlo: cambia el guard por un manejo que no falle de inmediato —por ejemplo, si el código hiciera bookings.get(id) or default en una estructura más grande— y el None (o la excepción) viajaría lejos antes de estallar, con un traceback que apunta a un sitio inocente. Es lo que verás en la lección 7 con la depuración confusa. Cómo corregirlo: no confíes en que el próximo bug de divergencia será tan cortés como este; trátalos todos con la misma seriedad, porque el siguiente puede estallar a diez funciones de distancia de su causa real.
Ejercicios
Ejercicio 1 — Predice el color. Sin correr nada, di si cada test pasa o falla, y por qué: (a) el mismo cancel idempotente, pero probado con el FakeBookingRepository canónico (el que hace self._store[booking_id], o sea, lanza) cancelando un id ausente; (b) el cancel idempotente con el fake descuidado, cancelando un id que sí existe; (c) el cancel idempotente con el SqliteBookingRepository real, cancelando un id que sí existe.
Ver solución
- (a) Falla (rojo). El fake canónico lanza
KeyErrorpara el id ausente, igual que el real. Así que el guardif booking is Nonenunca se alcanza,cancelrevienta conKeyError, y el test que espera0falla —exactamente como el de integración—. Lección de paso: el fake canónico coincide con el real en este punto, así que con él el unit test ya habría avisado. La mentira solo aparece con el fake descuidado. Un buen fake es un buen simulador; el problema es que nada garantiza que el fake sea bueno. - (b) Pasa (verde), y es fiel. Con un id que existe,
getdevuelve la reserva en ambos repos (el.get()del dict encuentra la clave, como el[...]), el guardif booking is Noneno se cumple, ycancelsigue su curso normal calculando el reembolso. Aquí el fake descuidado y el real coinciden —la divergencia solo vive en el caso del id ausente—, así que el verde es honesto. - (c) Pasa (verde), y es fiel. El real encuentra la fila, la devuelve, y
cancelprocede. El camino feliz funciona igual con el fake y con el real; por eso la divergencia se esconde: en el 99% de los casos (ids que existen) todo coincide, y solo el caso raro (id ausente) revela la grieta.
El patrón que emerge: la divergencia no está por todas partes, está en un borde —el id que no existe—. Los bordes son justo los casos que el camino feliz no ejercita, y por eso las divergencias se esconden ahí. Probar los bordes contra la pieza real es donde más rinde la integración.
Ejercicio 2 — El contrato que lo habría cazado. Sin implementarlo todavía (es el módulo 3), escribe en una frase el acuerdo de comportamiento que, verificado contra el fake descuidado, se habría puesto rojo antes del deploy. Luego di qué línea del fake descuidado tendría que cambiar para cumplirlo.
Ver solución
El acuerdo: "get(id) de un id que no fue guardado debe lanzar (KeyError), no devolver None." Un contrato es, precisamente, una batería de tests que afirma acuerdos como este y los corre contra todas las implementaciones de BookingRepository. Contra el SqliteBookingRepository real, este acuerdo pasa (el real lanza). Contra el BuggyFakeBookingRepository, este acuerdo falla —el fake devuelve None—, y ese rojo aparece en la máquina del desarrollador, en la suite del contrato, sin necesidad de tocar producción. La divergencia deja de ser invisible: se vuelve un test rojo con nombre.
La línea que tendría que cambiar en el fake descuidado es una sola: return self._store.get(booking_id) debe volverse return self._store[booking_id]. El .get() (devuelve None) pasa a ser [...] (lanza KeyError). Con ese cambio, el fake honra el contrato, el cancel idempotente falla también contra el fake (avisando en un unit test rápido), y el desarrollador se entera de que su guard supone algo falso antes de escribir la feature entera. El contrato convierte un bug de producción en un test rojo local.
Ejercicio 3 — Arregla el sistema, no el síntoma. El equipo decide que la cancelación idempotente es una buena feature y quiere conservarla: cancelar un id inexistente debe devolver 0, no reventar. Pero el SqliteBookingRepository real lanza KeyError. Describe la corrección correcta —que respete el contrato real— y explica por qué es mejor que hacer que el repositorio devuelva None.
Ver solución
La corrección correcta vive en el código bajo prueba, no en el repositorio, y consiste en manejar la excepción que el contrato real promete, en vez de suponer un None que el contrato no da:
def cancel(self, booking_id) -> int:
try:
booking = self._repo.get(booking_id)
except KeyError:
return 0 # cancelar algo inexistente: reembolso 0
...
Ahora cancel es idempotente respetando el contrato del repositorio ("get de un id ausente lanza"), en vez de contradecirlo. Este cancel pasa tanto con el fake canónico (que lanza) como con el SqliteBookingRepository real (que lanza), porque ambos cumplen el mismo contrato y el código está escrito para ese contrato.
¿Por qué es mejor que hacer que el repositorio devuelva None? Porque cambiar el repositorio para que get devuelva None en vez de lanzar debilita el contrato para todos sus usuarios, no solo para cancel. Otros clientes del repositorio que hoy confían en que get lanza —para distinguir "no existe" de "existe pero es None", o para fallar temprano y claro— se romperían en silencio: recibirían un None inesperado que viajaría hacia dentro de su lógica y estallaría lejos, con un AttributeError confuso (justo la clase de bug tardío que la guía de dobles ya describió). Un get que lanza es un contrato más fuerte y más honesto: dice "no existe" de forma inequívoca y temprana. La feature idempotente debe manejar ese contrato, no derogarlo para todos.
Resumen y siguiente paso
En esta lección viste la mentira del doble ejecutada de principio a fin. Un desarrollador escribió una cancelación idempotente razonable, asumiendo —porque su fake descuidado se lo confirmaba— que get devuelve None para un id ausente. El unit test pasó en verde. El mismo escenario, contra el SqliteBookingRepository real que lanza KeyError, falló en rojo, y el traceback mostró el guard if booking is None como código muerto que nunca se alcanza. Y entendiste la anatomía general del verde mentiroso: un test con un doble no prueba una propiedad del sistema, sino una propiedad condicionada a que el doble coincida con lo real —y cuando la condición falla, el verde miente sin cambiar de color—.
Antes de avanzar deberías poder: explicar por qué el guard nunca se ejecuta contra el repo real; reformular qué afirmaba en realidad el unit test verde (con su condición escondida); y proponer la corrección que respeta el contrato real (try/except KeyError) en vez de derogarlo.
Esta fue una divergencia de comportamiento: nada cambiaba de tipo, solo diferían las acciones. La lección 4 abre dos familias distintas, igual de traicioneras y también ejecutadas: la divergencia de tipos —el datetime que el fake devuelve intacto y el SQLite real devuelve como str, reventando el código que lo formatea— y la de orden —el fake que promete el orden de inserción y el real que, con un índice de producción, devuelve otro—. Dos formas nuevas de que el mismo verde mienta.
Recursos
- Documentación de pytest — Cómo escribir aserciones y reportes de fallo — cómo leer el traceback que pytest imprime al fallar, incluida la cadena de llamadas que en este ejemplo va del test a
cancely de ahí algetque lanza; saber leerlo es saber diagnosticar la divergencia. sqlite3— DB-API para SQLite (documentación de Python) — la referencia delSqliteBookingRepositoryreal; en particular, cómofetchone()devuelveNonecuando no hay filas, que es lo que nuestro repo traduce a unraise KeyErrorpara honrar el contrato.dict.gety el acceso por clave (documentación de Python) — el detalle exacto de la divergencia:d.get(k)devuelveNone,d[k]lanzaKeyError. Todo el bug de esta lección cabe en esa diferencia de un método.test-doubles-and-test-data-guide— la guía hermana, cuyo capítulo sobre fakes describió por primera vez esta divergencia como un riesgo; aquí la ejecutamos entera para motivar el contrato que la neutraliza.