Módulo 2: El doble que miente: el problema que motiva los contratos
4. Divergencia de tipos y de orden
Descripción
La lección anterior mostró una divergencia de comportamiento: el fake y el real difieren en lo que hacen cuando les pides algo ausente. Esta lección abre dos familias más, y las dos son de una naturaleza distinta y más sutil, porque no cambia lo que la pieza hace: cambia la forma de los datos que devuelve. La pieza real, para poder guardar tus objetos en una tabla y leerlos de vuelta, tiene que transformarlos —convertir un datetime a texto para que quepa en una columna, decidir en qué orden entrega las filas—. El fake, que guarda tus objetos tal cual en un dict, no transforma nada. Esa diferencia entre "transformar" y "no transformar" es una fuente inagotable de divergencias, y aquí vas a ejecutar dos de las más comunes.
La primera es la divergencia de tipos. Cuando un dato cruza la costura hacia la base de datos y vuelve, puede regresar con otro tipo de Python del que tenía al entrar. El caso estrella, que ya asomó en el módulo 1: un datetime entra como datetime y vuelve como str, porque SQLite no tiene un tipo nativo para fechas y hubo que serializarlo a texto. El fake, que nunca serializa, devuelve el datetime intacto. La segunda es la divergencia de orden. Cuando pides una lista de reservas, ¿en qué orden llegan? El fake, un dict, las devuelve en orden de inserción, de forma determinista. La base de datos real no promete ningún orden sin un ORDER BY explícito, y con un índice de por medio te las entrega en el orden del índice —que puede no ser el que tu fake te acostumbró a ver—. Las dos familias comparten la misma trampa: el fake te da una garantía que el real nunca prometió, y tu código aprende a depender de ella.
Conexión con el módulo: esta lección amplía el catálogo de divergencias que el módulo colecciona. La lección 3 cubrió el comportamiento; esta cubre la forma (tipos) y el orden; la lección 5 cubrirá los constraints y las transacciones. Las tres responden a la causa "simplifica de más" de la lección 2: el fake, al ser un dict, omite la serialización (de ahí la divergencia de tipos) y omite la maquinaria de consultas de un motor real (de ahí la divergencia de orden). Y las tres desembocan en la misma moraleja que prepara el módulo 3: como el fake omite por diseño lo que el real hace, no basta con confiar en él; hay que verificar la coincidencia en cada punto que importa. Aquí verás dos puntos más donde esa verificación habría cazado el bug.
Analogía: la aduana que reempaca tu equipaje
Piensa en dos formas de mandarle una caja a un amigo en otro país. La primera: se la das en mano, cerrada, y él la abre tal cual la empacaste —cada cosa en su sitio, en el orden en que la pusiste—. La segunda: la mandas por una aduana internacional. Ahí la caja se abre, cada objeto se registra en un formulario, algunos se reempacan de otra manera para cumplir las normas —el líquido va a otro recipiente, lo frágil se envuelve distinto—, y del otro lado tu amigo recibe una caja que contiene lo mismo pero reempacado: el reloj que mandaste puede llegar en una bolsa etiquetada "accesorio de muñeca, 1 unidad", y las cosas pueden venir en el orden del formulario, no en el que las metiste.
El FakeBookingRepository es dársela en mano: los objetos vuelven idénticos, en su tipo y en su orden. El SqliteBookingRepository es la aduana: para guardar tus reservas las "reempaca" en filas de una tabla, y al devolvértelas las reconstruye desde ese formulario. El datetime que mandaste vuelve como str porque la aduana solo sabía anotarlo como texto; las reservas vuelven en el orden del "formulario" (el índice), no en el que las guardaste. Si escribiste tu código probándolo solo con el amigo que recibe en mano, aprendiste a esperar objetos idénticos en orden idéntico —y el día que mandas por la aduana, tu código se encuentra un reloj etiquetado y un orden cambiado, y no sabe qué hacer con ellos—. Las dos divergencias de esta lección son dos formas en que la aduana reempaca lo que el amigo en mano te devolvía intacto.
Divergencia de tipos: el datetime que vuelve como texto
Empecemos por los tipos, con un escenario realista: un fragmento de código que renderiza la hora de inicio de una reserva para mostrarla en pantalla. Es el típico código de presentación —toma la reserva, formatea su start como "09:00"— y para formatear una hora, lo natural es usar .strftime(), el método de los objetos datetime:
# codigo de "pantalla" que renderiza la hora de inicio
def render_start(booking):
return booking.start.strftime("%H:%M") # asume datetime
Este código es correcto si booking.start es un datetime. Con el FakeBookingRepository, lo es —el fake guarda y devuelve el objeto tal cual—. Con el SqliteBookingRepository real, booking.start vuelve como str, y los str no tienen .strftime(). Aquí están los dos tests, otra vez simétricos: la misma reserva, guardada y leída, renderizada; uno con el fake, otro con el real.
# tests/test_types_and_order.py (parte de tipos)
def test_render_start_with_fake():
repo = FakeBookingRepository()
repo.save(a_booking("bk-1", 9))
assert render_start(repo.get("bk-1")) == "09:00" # con el fake: datetime.strftime
def test_render_start_with_sqlite():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
repo.save(a_booking("bk-1", 9))
assert render_start(repo.get("bk-1")) == "09:00" # <-- str no tiene .strftime
Qué esperar. En mi máquina (Python 3.14.0, pytest 9.1.1), el fake pasa y el real revienta con AttributeError:
=================================== FAILURES ===================================
________________________ test_render_start_with_sqlite _________________________
def test_render_start_with_sqlite():
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))
repo.save(a_booking("bk-1", 9))
> assert render_start(repo.get("bk-1")) == "09:00" # <-- str no tiene .strftime
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_types_and_order.py:34:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
booking = Booking(id='bk-1', room_id='focus', member_id='m-ana', start='2026-03-10T09:00:00', end='2026-03-10T10:00:00', status='confirmed', price_cents=6000)
def render_start(booking):
> return booking.start.strftime("%H:%M") # asume datetime
^^^^^^^^^^^^^^^^^^^^^^
E AttributeError: 'str' object has no attribute 'strftime'
tests/test_types_and_order.py:21: AttributeError
Lee la línea del booking en el traceback: pytest imprime la reserva entera, y ahí se ve el delator —start='2026-03-10T09:00:00', con comillas: es un str, no un datetime—. El fake habría dado start=datetime.datetime(2026, 3, 10, 9, 0), sin comillas, un objeto. La costura de SQLite reempacó el datetime como texto ISO al guardarlo (booking.start.isoformat() en el save) y lo devolvió como texto al leerlo (nadie lo convierte de vuelta), y el render_start que asumía un datetime se estrella contra un str. El unit test con el fake nunca podía ver esto, porque el fake no reempaca: te devuelve el datetime que le diste. Solo la pieza real, que sí serializa, revela que tu código de pantalla depende de un tipo que la base de datos no conserva.
Divergencia de orden: la garantía que el fake inventó
La segunda familia es más escurridiza porque el fake y el real casi siempre coinciden, hasta que dejan de hacerlo. Considera find_by_room, que devuelve las reservas de una sala. El FakeBookingRepository las devuelve en orden de inserción: un dict de Python conserva el orden en que metiste las claves, de forma determinista y documentada. Así que si guardas las reservas en cierto orden, el fake te las da en ese mismo orden, siempre. Es una garantía sólida... del fake. La pregunta es si el SqliteBookingRepository real hace la misma promesa. Y la respuesta es que no promete nada: una consulta SELECT ... WHERE room_id = ? sin un ORDER BY devuelve las filas en el orden que el motor considere conveniente, y ese orden depende de detalles internos —si hay un índice, cuál usa el planificador, cómo está físicamente la tabla—.
Para hacer la divergencia visible, uso un SqliteBookingRepository con un detalle que cualquier base de datos de producción tendría por rendimiento: un índice sobre (room_id, start). Nada exótico; es la optimización más normal del mundo para acelerar "las reservas de esta sala, por hora". Guardo tres reservas en un orden de id que no es cronológico —bk-c a las 11 h, bk-a a las 9 h, bk-b a las 10 h— y pido find_by_room. El fake me las da en orden de inserción (bk-c, bk-a, bk-b); el real, con el índice, me las da en orden del índice, o sea por start (bk-a, bk-b, bk-c).
# tests/test_types_and_order.py (parte de orden)
# insertamos ids en orden NO cronologico: bk-c(11h), bk-a(9h), bk-b(10h)
INSERT_ORDER = [("bk-c", 11), ("bk-a", 9), ("bk-b", 10)]
def test_find_by_room_order_with_fake():
repo = FakeBookingRepository()
for id_, h in INSERT_ORDER:
repo.save(a_booking(id_, h))
ids = [b.id for b in repo.find_by_room("focus")]
assert ids == ["bk-c", "bk-a", "bk-b"] # el fake promete orden de insercion
def test_find_by_room_order_with_sqlite():
repo = SqliteBookingRepositoryIndexed(sqlite3.connect(":memory:"))
for id_, h in INSERT_ORDER:
repo.save(a_booking(id_, h))
ids = [b.id for b in repo.find_by_room("focus")]
assert ids == ["bk-c", "bk-a", "bk-b"] # <-- el real devuelve orden del indice
Qué esperar. Corriendo el archivo completo (las dos familias juntas), el fake pasa en las dos y el real falla en las dos:
tests/test_types_and_order.py::test_render_start_with_fake PASSED [ 25%]
tests/test_types_and_order.py::test_render_start_with_sqlite FAILED [ 50%]
tests/test_types_and_order.py::test_find_by_room_order_with_fake PASSED [ 75%]
tests/test_types_and_order.py::test_find_by_room_order_with_sqlite FAILED [100%]
FAILED tests/test_types_and_order.py::test_render_start_with_sqlite - AttributeError: 'str' object has no attribute 'strftime'
FAILED tests/test_types_and_order.py::test_find_by_room_order_with_sqlite - AssertionError: assert ['bk-a', 'bk-b', 'bk-c'] == ['bk-c', 'bk-a', 'bk-b']
========================= 2 failed, 2 passed in 0.04s ==========================
El AssertionError del orden es elocuente: el real devolvió ['bk-a', 'bk-b', 'bk-c'] (orden cronológico, por el índice) donde el fake, y por lo tanto el código que confiaba en el fake, esperaba ['bk-c', 'bk-a', 'bk-b'] (orden de inserción). Y aquí está lo pérfido de esta divergencia: es un cambio de decorado que puede aparecer sin que nadie toque tu código. El día que se escribió el test, quizá no había índice, y el SELECT sin ORDER BY devolvía las filas por su orden físico (que coincidía con la inserción), así que el test pasaba también con el real —por pura suerte—. Meses después, alguien añade el índice (room_id, start) para acelerar una pantalla, sin tocar find_by_room ni tu código, y el orden de las filas cambia. Tu test —o peor, tu producción— se pone rojo por un cambio que parecía no tener nada que ver. El fake te había vendido una garantía de orden que el real nunca firmó, y esa garantía se cobró su factura el día menos pensado.
El patrón común: el fake promete de más
Detrás de las dos familias hay un mismo mecanismo, y vale la pena nombrarlo porque lo reconocerás en muchas divergencias futuras. El fake, por ser un objeto de Python vivo en memoria, ofrece garantías gratuitas que no le costó nada dar: conserva los tipos exactos (no serializa) y conserva el orden de inserción (es un dict). Esas garantías son ciertas del fake, pero no forman parte del contrato del BookingRepository —nadie prometió que get devuelve un datetime, ni que find_by_room respeta el orden de inserción—. El código que se prueba solo contra el fake no distingue entre "lo que el contrato promete" y "lo que el fake regala de más", y acaba dependiendo de los regalos. Cuando conectas la pieza real, que solo cumple el contrato y no reparte regalos, el código que dependía de los regalos se rompe.
La disciplina que se saca de aquí es doble. Primero, sé explícito con el contrato: si tu código necesita que start sea un datetime, entonces convertir el texto de vuelta a datetime al leer es parte del contrato del repositorio, y hay que escribirlo y verificarlo —no dejarlo al azar de que el fake lo regale—. Si tu código necesita un orden, pide ORDER BY explícito y haz que el contrato lo garantice —no confíes en el orden de inserción que solo el fake respeta—. Segundo, verifica el contrato contra ambas implementaciones. Una batería que afirme "get devuelve un Booking cuyo start es un datetime" y "find_by_room devuelve las reservas ordenadas por start", corrida contra el fake y el real, pondría rojo al que no cumpla —el real que devuelve str, o el que no ordena— antes de que el bug llegue a pantalla. Eso es el módulo 3; aquí basta con que veas que las garantías gratuitas del fake son deuda disfrazada.
Errores comunes
Confundir "pasa con el real hoy" con "el contrato garantiza el orden". Qué pasa: alguien corre el test de orden contra el SqliteBookingRepository sin índice, lo ve pasar (las filas salen en orden de inserción por coincidencia física), y concluye que el orden está garantizado. Por qué pasa: un verde contra el real se siente como una garantía. Cómo detectarlo: un SELECT sin ORDER BY no garantiza nada, por más que hoy devuelva lo que esperas; el orden es un detalle de implementación que un índice, una versión nueva del motor o una tabla reorganizada pueden cambiar. Cómo corregirlo: si te importa el orden, escríbelo (ORDER BY start) y pruébalo; si no te importa, no lo afirmes en el test. Nunca dependas de un orden que no pediste explícitamente, ni con el fake ni con el real.
"Arreglar" la divergencia de tipos en el test en vez de en el repositorio. Qué pasa: alguien ve el AttributeError del strftime y cambia el test para que acepte un str (assert booking.start == '2026-03-10T09:00:00'), dándose por satisfecho. Por qué pasa: hacer verde el test se siente como resolver. Cómo detectarlo: pregúntate quién más lee booking.start esperando un datetime —el código de pantalla, los cálculos de reembolso que restan fechas, cualquier comparación temporal—. Todos ellos siguen rotos; solo silenciaste al mensajero. Cómo corregirlo: decide el contrato ("get devuelve un Booking con start de tipo datetime") y haz que el SqliteBookingRepository lo cumpla convirtiendo el texto de vuelta a datetime al leer (con datetime.fromisoformat). Arregla la pieza, no el test.
Creer que un fake que conserva tipos y orden es "más fiel" y por tanto mejor. Qué pasa: alguien razona que, como el fake conserva el datetime y el orden, es un doble de alta calidad. Por qué pasa: "conserva más" suena a "se parece más al real". Cómo detectarlo: en estos dos casos, el fake conserva más de lo que el real garantiza, y esa generosidad es precisamente la trampa —tu código aprende a depender de lo que el real no da—. Cómo corregirlo: la fidelidad de un doble no se mide por cuánto conserva, sino por cuánto coincide con el contrato del real. Un fake que regala garantías que el real no cumple no es más fiel: es más engañoso. Lo que quieres es que el fake prometa exactamente lo que el real promete, ni más ni menos, y eso solo lo asegura un contrato compartido.
Ejercicios
Ejercicio 1 — ¿Qué regalo del fake es este? Para cada dependencia oculta, di si es una garantía de tipo o de orden que el fake regala y el real no promete, y cómo la harías explícita en el contrato: (a) el código hace booking.start - booking.end esperando restar dos datetime; (b) el código toma find_by_room("focus")[0] creyendo que es "la primera reserva que se creó"; (c) el código hace booking.price_cents + 100 esperando un int.
Ver solución
- (a) Tipo. Restar
booking.start - booking.endsolo funciona si ambos sondatetime(da untimedelta); constr, revienta conTypeError. El fake regala losdatetime; el real dastr. Para hacerlo explícito, el contrato afirma "getdevuelve unBookingconstartyendde tipodatetime", y elSqliteBookingRepositorylos reconstruye condatetime.fromisoformatal leer. - (b) Orden.
find_by_room(...)[0]como "la primera que se creó" depende del orden de inserción, que solo el fake garantiza. El real puede devolver otra en la posición0. Para hacerlo explícito, si quieres "la primera creada", pide un orden que lo defina —por ejemploORDER BY created_ato por el id si es cronológico— y garantízalo en el contrato; nunca confíes en la posición0de una consulta sin orden. - (c) Ninguno de los dos regalos falla aquí.
price_centses unint, y SQLite tiene un tipo nativoINTEGER, así que va y vuelve comointen ambos repos.booking.price_cents + 100funciona igual con el fake y con el real. Es el contraste útil: no todo diverge —los enteros cruzan la costura sin cambiar de tipo—; divergen los tipos que la base de datos no modela nativamente (comodatetime) y las garantías que solo el fake da (como el orden). Saber qué diverge y qué no es la mitad del oficio.
Ejercicio 2 — El índice que rompió el test. Un test de orden llevaba meses en verde contra el SqliteBookingRepository. Un compañero añade un índice (room_id, start) para acelerar una pantalla, sin tocar find_by_room ni el código de negocio, y el test se pone rojo. Explica qué pasó y por qué el compañero no tenía forma razonable de anticiparlo.
Ver solución
Qué pasó: el test afirmaba un orden concreto (el de inserción) sobre el resultado de un SELECT ... WHERE room_id = ? sin ORDER BY. Antes del índice, SQLite resolvía esa consulta con un escaneo de la tabla, que devolvía las filas en su orden físico —que coincidía con el orden de inserción—, así que el test pasaba. Al añadir el índice (room_id, start), el planificador de consultas eligió usarlo para satisfacer el WHERE room_id = ?, y al recorrer el índice devolvió las filas en el orden del índice —por start—, que es distinto del de inserción. El resultado cambió de orden, y el test que dependía del orden viejo se puso rojo.
Por qué el compañero no podía anticiparlo razonablemente: su cambio fue local y correcto —añadir un índice para rendimiento no toca la lógica ni la consulta—, y no hay nada en find_by_room que anuncie "alguien depende del orden de inserción de esta consulta sin ORDER BY". La dependencia era implícita, heredada del comportamiento del fake, y vivía en un test que ni siquiera está en el archivo que él editó. Es el peligro de depender de garantías no escritas: se rompen por cambios que, mirados solos, son impecables. La lección: si un test afirma un orden, la consulta debe pedir ese orden con ORDER BY; así el orden es una promesa explícita del código, robusta ante índices, y no un accidente físico que el próximo CREATE INDEX puede voltear.
Ejercicio 3 — Cierra las dos grietas en el SqliteBookingRepository. Sin escribir el contrato todavía (módulo 3), corrige las dos divergencias en la pieza real: haz que get devuelva start/end como datetime, y que find_by_room devuelva un orden garantizado. Escribe los cambios y explica por qué van en el repositorio y no en el código que lo usa.
Ver solución
Los dos cambios viven en el SqliteBookingRepository, porque la divergencia nace en cómo la pieza real serializa y consulta. Para el tipo, convertir el texto ISO de vuelta a datetime al leer:
from datetime import datetime
def get(self, booking_id):
row = self._conn.execute(...).fetchone()
if row is None:
raise KeyError(booking_id)
return Booking(
id=row[0], room_id=row[1], member_id=row[2],
start=datetime.fromisoformat(row[3]), # str ISO -> datetime
end=datetime.fromisoformat(row[4]), # str ISO -> datetime
status=row[5], price_cents=row[6],
)
Para el orden, pedirlo explícitamente en la consulta:
def find_by_room(self, room_id):
rows = self._conn.execute(
"SELECT ... FROM bookings WHERE room_id = ? ORDER BY start", # orden explicito
(room_id,)).fetchall()
return [Booking(...) for r in rows]
Por qué van en el repositorio y no en el código que lo usa: porque el trabajo del repositorio es ser un BookingRepository honesto —devolver Bookings con los tipos y las garantías que el contrato promete—, de modo que todos sus clientes (el código de pantalla, los cálculos de reembolso, cualquier consulta futura) reciban lo mismo sin tener que defenderse cada uno por su cuenta. Si "arreglaras" cada cliente para que tolere un str o un orden arbitrario, repartirías la misma corrección por decenas de sitios, la olvidarías en alguno, y volverías a divergir. La responsabilidad de cumplir el contrato es de la pieza que implementa la interfaz, no de quien la consume. Con estos cambios, el SqliteBookingRepository deja de reempacar de forma sorpresiva: entrega datetime y un orden definido, igual que el fake, y las dos grietas se cierran en un solo lugar.
Resumen y siguiente paso
En esta lección ejecutaste dos familias de divergencia que no cambian lo que la pieza hace, sino la forma de lo que devuelve. La de tipos: el datetime que el fake conserva intacto vuelve del SqliteBookingRepository real como str, y el código de pantalla que llamaba a .strftime() se estrella con un AttributeError. La de orden: el fake promete el orden de inserción, pero el real —con un índice de producción— devuelve el orden del índice, y un test que dependía del orden viejo se pone rojo por un CREATE INDEX que nadie asoció con él. Y viste el mecanismo común: el fake regala garantías (tipos exactos, orden de inserción) que no forman parte del contrato del real, y el código que se prueba solo con el fake acaba dependiendo de regalos que la pieza real no reparte.
Antes de avanzar deberías poder: explicar por qué price_cents cruza la costura sin problema y start no; distinguir "el fake conserva más" de "el fake es más fiel"; y decidir dónde va la corrección (en el repositorio, para que todos sus clientes hereden el contrato).
Quedan las divergencias más profundas, las que el dict no puede ni fingir porque no tiene la maquinaria: los constraints y las transacciones. La lección 5 las ejecuta: una doble reserva que el real rechaza con IntegrityError mientras el fake la acepta callado, y un lote que falla a la mitad y que el real deshace con un rollback mientras el fake lo deja escrito a medias. Son las divergencias de unicidad y atomicidad, invisibles para un doble en memoria.
Recursos
sqlite3— Tipos de SQLite y de Python (documentación de Python) — la tabla oficial de cómo SQLite mapea (y no mapea) los tipos de Python: por qué unintsobrevive intacto y undatetimeno tiene equivalente nativo y hay que serializarlo a texto. La raíz de la divergencia de tipos.datetime.fromisoformat(documentación de Python) — el método que reconstruye undatetimedesde el texto ISO que guardamos, la pieza que cierra la divergencia de tipos en el repositorio real.- SQLite —
ORDER BYy el orden de las filas — la documentación que deja claro que, sinORDER BY, el orden de las filas es indefinido y sujeto a la estrategia del motor; el fundamento de por qué el orden que el fake regala no está garantizado en el real. - Documentación de pytest — parametrización y aserciones — cómo pytest imprime el objeto entero en el traceback (la línea del
bookingconstart='...'entre comillas) que nos dejó ver, de un vistazo, que el tipo había cambiado dedatetimeastr.