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

6. Por qué el unit test no lo ve

Descripción

A lo largo de este módulo has visto seis divergencias, en cuatro familias, todas con el mismo desenlace: el unit test con el fake pasa en verde y el sistema real está roto. Podrías estar sacando la conclusión equivocada —"me faltaron unit tests", "mis unit tests eran flojos", "con más casos lo habría cazado"—. Esta lección existe para cerrarte esa salida, porque es falsa y peligrosa. El unit test no falló en cazar la divergencia por descuido ni por falta de cantidad. Falló porque es estructuralmente incapaz de cazarla. No hay número de unit tests, ni ingenio en los casos, ni cobertura del 100%, que cierre esta grieta, y entender por qué es lo que finalmente te obliga a mirar hacia el contrato y la integración como la única salida real.

La razón cabe en una frase, y conviene tenerla clara antes de la demostración: en un unit test, el doble es a la vez el sujeto de la prueba y el oráculo que la juzga. El "sujeto" es el sistema cuyo comportamiento quieres verificar; el "oráculo" es la fuente de verdad contra la que comparas para decidir si pasó o falló. Cuando pruebas cancel con el FakeBookingRepository, el fake participa en las dos cosas: es parte del sistema que ejecuta el escenario y es la referencia de comportamiento contra la que el código se escribió. Preguntas: "¿mi código funciona con el repositorio?", y el repositorio que usas para responder es el mismo fake sobre cuya suposición se construyó el código. La pregunta y la respuesta salen de la misma fuente. Es un razonamiento circular, y un razonamiento circular siempre "confirma" —no porque sea verdad, sino porque no puede hacer otra cosa—.

Conexión con el módulo: esta lección da el remate teórico de todo lo anterior. Las lecciones 3, 4 y 5 mostraron qué divergencias existen; esta explica por qué ninguna de ellas es visible desde dentro de la suite unitaria, y por qué eso no cambia por más unit tests que agregues. Es el puente definitivo hacia la solución. Si el unit test fuera capaz de cazar la divergencia con suficiente esfuerzo, la respuesta sería "esfuérzate más" y no haría falta ninguna guía. La razón por la que hacen falta el contrato (módulo 3) y la integración (módulo 5) es precisamente que la divergencia vive en un punto ciego que el unit test no puede alcanzar desde ningún ángulo. La lección 7 pondrá el precio de ese punto ciego; esta demuestra que el punto ciego es inevitable.

Analogía: la hoja de respuestas que escribió el mismo alumno

Imagina un examen donde el alumno entrega dos cosas: sus respuestas y la hoja de respuestas correctas contra la que se va a calificar, ambas escritas por él, desde el mismo entendimiento del tema. Si el alumno cree, equivocadamente, que la capital de Australia es Sídney, escribirá "Sídney" en su respuesta y "Sídney" en su hoja de correctas. A la hora de calificar, su respuesta coincide con su hoja: cien por ciento. El examen sale perfecto. Y no prueba absolutamente nada sobre si sabe geografía, porque el error está en las dos hojas a la vez: la que responde y la que juzga comparten la misma creencia falsa, así que la calificación solo mide si el alumno es consistente consigo mismo, no si tiene razón. Puedes hacerle cien preguntas más: si sigue calificándose con su propia hoja, seguirá sacando cien, aunque la mitad de sus respuestas estén mal.

Eso es exactamente un unit test con un doble. Tu código es la respuesta del alumno; el fake es la hoja de correctas —y las dos las escribiste tú, desde la misma suposición sobre cómo se comporta el repositorio—. Cuando el código asume "get devuelve None" y el fake confirma "get devuelve None", la respuesta coincide con la hoja: verde. Pero el verde solo mide que tu código es consistente con tu fake, no que tu suposición sea cierta. Y por eso agregar unit tests —hacer más preguntas del mismo examen autocalificado— no ayuda: cada nueva pregunta se sigue calificando con la hoja que comparte el error. La única forma de saber si "Sídney" es correcto es traer una fuente de verdad externa —un atlas, un profesor—: alguien que no comparta tu suposición. En software, esa fuente externa es la pieza real (la integración) o una batería que compare el fake con el real (el contrato). Sin una fuente externa, el examen se calificará a sí mismo para siempre, y siempre aprobará.

Ejemplo trabajado: una suite completa, ciega y verde

Vamos a demostrarlo con el ejemplo más honesto posible: una suite unitaria completa y bien hecha de la cancelación idempotente. No una suite floja de un solo test, sino una que cubre el camino feliz en sus tres anclas de reembolso (72 h, 36 h, 12 h) y el caso que nos importa (cancelar un id inexistente). Es la suite que un desarrollador diligente escribiría, y está toda en verde con el fake descuidado. Al final, un único test de integración con el SqliteBookingRepository real —el mismo escenario del id ausente— para ver quién dice la verdad.

# tests/test_unit_suite_is_blind.py
@pytest.mark.parametrize("hours_before, expected", [
    (72, 6000), (36, 3000), (12, 0)], ids=["72h", "36h", "12h"])
def test_cancel_refund_anchor_with_fake(hours_before, expected):
    repo = BuggyFakeBookingRepository()
    repo.save(a_booking())
    service = make_service(repo, START - timedelta(hours=hours_before))
    assert service.cancel("bk-1") == expected


def test_cancel_missing_returns_zero_with_fake():
    repo = BuggyFakeBookingRepository()
    service = make_service(repo, START)
    assert service.cancel("bk-999") == 0     # verde con el fake


# ---- el UNICO test de integracion, con el real ----
def test_cancel_missing_returns_zero_with_sqlite():
    repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
    service = make_service(repo, START)
    assert service.cancel("bk-999") == 0     # el real lanza KeyError

Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1): los cuatro unit tests pasan, y solo el de integración cae.

python3 -m pytest tests/test_unit_suite_is_blind.py -v
tests/test_unit_suite_is_blind.py::test_cancel_refund_anchor_with_fake[72h] PASSED [ 20%]
tests/test_unit_suite_is_blind.py::test_cancel_refund_anchor_with_fake[36h] PASSED [ 40%]
tests/test_unit_suite_is_blind.py::test_cancel_refund_anchor_with_fake[12h] PASSED [ 60%]
tests/test_unit_suite_is_blind.py::test_cancel_missing_returns_zero_with_fake PASSED [ 80%]
tests/test_unit_suite_is_blind.py::test_cancel_missing_returns_zero_with_sqlite FAILED [100%]
FAILED tests/test_unit_suite_is_blind.py::test_cancel_missing_returns_zero_with_sqlite - KeyError: 'bk-999'
1 failed, 4 passed in 0.04s

Mira el reparto: 4 verdes, 1 rojo, y el único rojo es el único que toca la pieza real. Los cuatro unit tests son buenos tests —cubren las tres anclas de reembolso y el caso del id ausente, exactamente lo que un revisor pediría—, y los cuatro pasan. Si esta suite fuera todo lo que tuvieras, verías "todo verde" y desplegarías con confianza. El bug —que cancel de un id inexistente revienta contra el real— está vivo, y ninguno de los cuatro unit tests lo toca, ni siquiera el que específicamente prueba el id ausente (test_cancel_missing_returns_zero_with_fake). Ese es el punto que hay que dejar clavado: no es que faltara el test del caso ausente. El test del caso ausente existe, es correcto, y pasa en verde —porque se califica con el fake, que comparte la suposición falsa—. El único que dice la verdad es el que trajo una fuente externa: el repositorio real.

Por qué agregar unit tests no cambia nada

Detengámonos en la consecuencia más contraintuitiva, porque es la que desarma el reflejo de "escribe más tests". Fíjate en que ya tienes un unit test dedicado al caso del id ausente, y aun así el bug pasa. ¿Por qué? Porque ese test hace la pregunta correcta —"¿cancelar un id inexistente devuelve 0?"— pero se la hace al oráculo equivocado: al fake, que responde que sí porque está construido sobre la misma suposición que el código. Agregar un segundo, un décimo, un centésimo unit test del mismo caso no ayuda, porque todos comparten el oráculo. Es la hoja de respuestas autocalificada: cien preguntas más, calificadas con la misma hoja errónea, dan cien aciertos más que no prueban nada.

Esto tiene una forma precisa que vale la pena enunciar. Un unit test verifica la propiedad "el código se comporta bien dado que el doble se comporta como el doble se comporta". Esa cláusula final es una tautología —el doble se comporta como se comporta— y por eso el unit test siempre puede pasar, sin importar cuántos escribas: nunca compara el comportamiento del doble con el del real, porque el real no está en la sala. La grieta que buscas vive entre el doble y el real, y un unit test, por definición, solo tiene al doble delante. Es como buscar la diferencia entre dos fotos teniendo solo una: por mucho que la mires, la diferencia no está en la foto que tienes, sino entre las dos, y necesitas la segunda foto para verla. La segunda foto es la pieza real. Ningún examen minucioso de la primera foto la sustituye.

De aquí sale la única salida, y es doble. O traes la pieza real a la prueba —eso es la integración, y es la segunda foto: corres el escenario contra el SqliteBookingRepository y ves la diferencia—. O comparas las dos fotos de forma sistemática —eso es el contrato, una batería que corre las mismas afirmaciones contra el fake y el real y exige que ambos coincidan, poniéndose roja donde difieran—. Las dos rompen la circularidad metiendo una fuente de verdad que no comparte la suposición del código. No hay una tercera vía desde dentro del mundo de los unit tests, porque desde ahí el oráculo siempre es el doble. Por eso este módulo desemboca inevitablemente en los módulos 3 y 5: no como un lujo, sino como la única forma de ver lo que el unit test no puede.

El malentendido de la cobertura

Conviene desactivar una defensa que la gente esgrime aquí: "pero yo tengo 100% de cobertura". La cobertura mide qué líneas de tu código se ejecutaron durante los tests. En nuestra suite ciega, la cobertura de cancel podría ser altísima —el camino feliz recorre casi todas sus líneas—. Y no sirve de nada contra esta divergencia, por dos razones. Primera: la línea que falla no está en tu código, está en el get del SqliteBookingRepository, que tus unit tests nunca ejecutan porque usan el fake; la cobertura de tu suite unitaria ni siquiera mira ese archivo. Segunda, y más de fondo: la cobertura mide ejecución, no corrección del oráculo. Puedes ejecutar la línea if booking is None: return 0 al 100% —el unit test la recorre— y aun así no probar nada sobre el sistema real, porque la ejecutaste contra un oráculo que comparte tu error. Una línea cubierta por un test autocalificado está cubierta de mentira. La cobertura te dice qué miraste; no te dice si lo que usaste para juzgar era la verdad. Contra la divergencia, la cobertura da una falsa sensación de completitud: verde de tests, verde de cobertura, y el bug intacto.

Errores comunes

Responder a una divergencia escribiendo más unit tests. Qué pasa: descubierta una divergencia, el equipo se propone "cubrir mejor" ese caso con más unit tests. Por qué pasa: "más tests" es el reflejo entrenado ante cualquier bug. Cómo detectarlo: si los tests nuevos usan el mismo doble, se califican con el mismo oráculo y pasarán todos, sin tocar el bug —lo viste: ya había un unit test del caso ausente y no ayudó—. Cómo corregirlo: ante una divergencia, no agregues unit tests; agrega una prueba con una fuente de verdad externa —un test de integración contra el real, o un contrato que compare fake y real—. La cantidad de unit tests es ortogonal a la divergencia.

Confiar en la cobertura como prueba de que "todo está probado". Qué pasa: alguien ve 100% de cobertura y concluye que no quedan bugs sin cubrir. Por qué pasa: un número alto y verde se siente como completitud. Cómo detectarlo: la cobertura no incluye el código de la pieza real que tus unit tests no ejecutan (el SqliteBookingRepository), y no mide si el oráculo (el doble) dice la verdad. Un 100% de cobertura con dobles es 100% de "ejecuté mis líneas contra mis suposiciones". Cómo corregirlo: usa la cobertura para lo que sirve (encontrar código que ningún test toca) y no para lo que no puede (garantizar que las costuras con lo real están verificadas). Para eso están el contrato y la integración, que la cobertura no mide.

Creer que el problema es la calidad de este fake, no la estructura. Qué pasa: alguien concluye "el bug fue por un fake descuidado; con un fake bien escrito, el unit test lo habría cazado". Por qué pasa: en el ejemplo del get→None, el fake descuidado era el culpable visible. Cómo detectarlo: recuerda la divergencia del datetime (módulo 1) y la de tipos (lección 4): ahí el fake era impecable y aun así el unit test no vio nada, porque el dict no serializa. La circularidad no depende de si el fake está bien o mal escrito; depende de que el oráculo del unit test sea el fake. Cómo corregirlo: no persigas el "fake perfecto" como cura —incluso el fake perfecto es un oráculo que comparte las suposiciones con las que lo escribiste—. La cura es una fuente de verdad externa, que ningún fake, por bueno que sea, puede ser desde dentro del unit test.

Ejercicios

Ejercicio 1 — Identifica el oráculo. Para cada prueba, di qué actúa como oráculo (la fuente de verdad contra la que se juzga) y si ese oráculo es externo al sistema bajo prueba o comparte sus suposiciones: (a) assert price_cents(FOCUS, ANA, 3) == 6000, sobre la función pura; (b) cancel de un id ausente con el fake descuidado, esperando 0; (c) cancel de un id ausente con el SqliteBookingRepository real, esperando que lance.

Ver solución
  • (a) Oráculo externo (un número que tú calculaste aparte). El 6000 no lo produce la función bajo prueba: lo derivaste tú del dominio (2500 × 3 × 0.8) de forma independiente. La fuente de verdad es externa al código —una cuenta que hiciste con lápiz—, así que el test es honesto: compara el resultado del código contra una verdad que no salió del propio código. Por eso los tests de lógica pura no sufren esta circularidad: su oráculo (el número esperado) es externo por naturaleza.
  • (b) Oráculo que comparte la suposición (el fake). El 0 esperado depende de que get devuelva None, que es justo lo que el fake hace y lo que el código asume. El oráculo (el comportamiento del fake) y el sujeto (el código escrito para ese comportamiento) comparten la misma suposición. Circular: el test confirma consistencia, no verdad.
  • (c) Oráculo externo (la pieza real). Aquí la fuente de verdad es el SqliteBookingRepository, que no comparte la suposición del código —lanza, no devuelve None—. Es la "segunda foto": trae un comportamiento independiente contra el cual el código se mide de verdad. Por eso este test caza la divergencia que los del fake no pueden.

La regla: un test dice la verdad sobre el sistema real cuando su oráculo es independiente del sistema bajo prueba. Los números calculados a mano y las piezas reales son oráculos externos; los dobles escritos con la misma suposición que el código, no.

Ejercicio 2 — El test del caso ausente que no bastó. En la suite ciega, test_cancel_missing_returns_zero_with_fake prueba exactamente el caso que falla en producción (cancelar un id inexistente) y aun así pasa en verde. Explica por qué tener el test correcto del caso correcto no fue suficiente, y qué le faltaba a ese test específico.

Ver solución

No fue suficiente porque el test correcto del caso correcto se calificó con el oráculo equivocado. El test hace la pregunta exacta —"¿cancelar un id inexistente devuelve 0?"— pero se la formula al BuggyFakeBookingRepository, que responde "sí, 0" porque su get devuelve None y el guard del código lo maneja. El test y el código comparten la suposición "get devuelve None", así que la prueba mide si son consistentes entre sí (lo son) y no si esa suposición es cierta en el sistema real (no lo es). Tener el caso cubierto no ayuda cuando la cobertura es circular: preguntas bien, pero al testigo que repite tu propia versión.

Qué le faltaba a ese test específico: una fuente de verdad externa. Bastaba con correr el mismo escenario contra el SqliteBookingRepository real —convertirlo en un test de integración— para que el oráculo dejara de compartir la suposición del código y la divergencia saltara en rojo. O, de forma más sistemática, un contrato que afirmara "get de un id ausente lanza" y se corriera contra el fake y el real, poniendo rojo al fake descuidado por no cumplirlo. El test no necesitaba ser más listo ni cubrir más casos; necesitaba un juez que no fuera cómplice.

Ejercicio 3 — Diseña la prueba que sí lo vería. Sin implementar el contrato completo (módulo 3), describe la prueba mínima que rompería la circularidad para la divergencia de unicidad de la lección 5 (el fake acepta una doble reserva, el real la rechaza). Di qué oráculo externo usa y por qué eso la hace capaz de cazar el bug.

Ver solución

La prueba mínima es un test de integración contra el SqliteBookingRepository real (el que tiene UNIQUE(room_id, start)) que intente la doble reserva y afirme que la segunda es rechazada:

def test_double_booking_is_rejected_by_real_repo():
    repo = SqliteBookingRepositoryUnique(sqlite3.connect(":memory:"))
    repo.save(a_booking("bk-1"))                 # focus, misma hora
    with pytest.raises(sqlite3.IntegrityError):  # el oraculo externo: el motor real
        repo.save(a_booking("bk-2"))             # focus, misma hora -> debe fallar

El oráculo externo es el motor de SQLite y su constraint: no es un comportamiento que tú suposiste y escribiste en un fake, sino una regla que la base de datos real hace cumplir por su cuenta. Esa independencia es lo que la hace capaz de cazar el bug. El test le pregunta al real "¿aceptas dos reservas para la misma sala y hora?", y el real responde con la verdad de su maquinaria —IntegrityError—, no con la suposición del código. Si tu lógica de reserva creía (por el fake) que la doble reserva era posible, este test la desmiente en rojo, antes de producción.

Nota lo que no funcionaría: repetir el intento con el FakeBookingRepository, por muchos casos que agregues, siempre pasaría, porque el fake no tiene el constraint —el oráculo seguiría compartiendo la suposición "se puede duplicar"—. La única prueba capaz es la que trae la regla real. Ese es, en pequeño, el principio del contrato y la integración: juzgar con la maquinaria del real, no con la suposición del doble.

Resumen y siguiente paso

En esta lección entendiste por qué la ceguera del unit test ante la divergencia no es un accidente que se cura con esfuerzo, sino una propiedad estructural. En un unit test, el doble es a la vez el sujeto de la prueba y el oráculo que la juzga —la respuesta del alumno y su propia hoja de correctas—, así que el verde solo mide que el código es consistente con el fake, no que la suposición del fake sea cierta. Lo viste con una suite completa y bien hecha: cuatro unit tests verdes, incluido el del caso ausente, y un solo test de integración rojo, el único con una fuente de verdad externa. Y desactivaste dos reflejos: agregar unit tests no ayuda (comparten el oráculo) y la cobertura no mide la corrección del oráculo (verde de líneas, bug intacto). La única salida es traer una fuente externa: la pieza real (integración) o una comparación fake-vs-real (contrato).

Antes de avanzar deberías poder: enunciar por qué el unit test es incapaz de ver la divergencia, en términos de sujeto y oráculo; explicar por qué tener el test del caso correcto no bastó; e identificar, en una prueba dada, si su oráculo es externo o cómplice.

Ya sabes que el bug es invisible desde la suite unitaria y que seguirá invisible por más que la agrandes. Falta ponerle precio. La lección 7 hace la cuenta que paga el negocio cuando una de estas mentiras cruza el punto ciego y llega a producción: el traceback crudo del incidente —un socio que recibe un error 500 donde esperaba un "reembolso 0"—, y las tres dimensiones del costo: la detección tardía, el radio de impacto y la depuración confusa. El verde que dio luz verde al deploy tiene una factura, y vamos a leerla.

Recursos

  • Documentación de pytest — parametrización de tests — la técnica con la que la suite ciega cubre las tres anclas de reembolso en un solo test; útil para construir suites unitarias completas como la que, aun completa, no ve la divergencia.
  • Documentación de coverage.py — la herramienta que mide qué líneas ejecutan tus tests; leer qué mide (ejecución) y qué no (corrección del oráculo, código de la pieza real no ejecutado) es entender por qué el 100% no protege contra la divergencia.
  • Martin Fowler — Self Testing Code y el rol del oráculo — el trasfondo sobre qué hace que un test sea informativo; el marco conceptual detrás de "el oráculo debe ser independiente del sujeto".
  • testing-fundamentals-and-tdd-guide — la guía hermana sobre qué hace bueno a un test y sobre cobertura como herramienta y no como meta; el repaso natural si "oráculo" o "cobertura" te quedaron flojos.