Módulo 5: Shrinking y el ejemplo mínimo
6. La base de datos de fallos y el determinismo
Descripción
Corres tu suite, una propiedad falla con hours_before=52, paid=25, y aún no lo arreglas. La corres otra vez, sin cambiar nada, y falla idéntica: mismo caso, mismos números. ¿Cómo, si la generación es aleatoria? ¿No debería la lotería sacar otros valores esta vez? La respuesta es una pieza silenciosa de Hypothesis que trabaja sola: la base de datos de ejemplos, una carpeta .hypothesis/ que aparece en tu proyecto sin que la crees y que hace que un fallo, una vez encontrado, no se te escape hasta que lo arregles. Esta lección abre esa base de datos y, con ella, aclara qué significa —y qué no— que Hypothesis sea "determinista".
Al terminar vas a entender qué guarda la carpeta .hypothesis/, por qué aparece con un .gitignore propio, y cómo la fase de reuso (reuse) replay los fallos guardados antes de generar nada nuevo —por eso el bug reaparece igual—. Vas a distinguir con claridad tres niveles de determinismo que a menudo se confunden: la base de datos (que reproduce en tu máquina un fallo ya encontrado), derandomize (que fija la semilla para obtener los mismos valores en cualquier máquina), y @reproduce_failure/@example (que clavan un caso exacto en el código). Y vas a ver, con honestidad, el matiz que casi ninguna introducción menciona: sin fijar la semilla, dos corridas frescas pueden aterrizar en mínimos ligeramente distintos —(50, 50) en una, (52, 25) en otra— y por qué eso no es un problema.
Conexión con el módulo: esta es la segunda herramienta que rodea al encogido, y la pareja natural de @example. En la lección 5 clavaste regresiones explícitas, escritas por ti en el código; aquí conoces la memoria implícita y automática que Hypothesis mantiene sola en disco. Las dos aseguran que un fallo no se pierda, pero por vías distintas —una viaja con tu repositorio, la otra vive en tu máquina—, y entender la diferencia es clave para usarlas bien. La lección 7 cerrará el módulo con settings, incluido el ajuste derandomize que aquí presentamos y database, que controla esta misma base de datos. Seguimos con el bug del bono sin tope.
Una analogía: el marcapáginas que la biblioteca pone por ti
Imagina una biblioteca mágica donde, cada vez que encuentras un error en un libro —una errata, una página mal impresa—, un bibliotecario invisible coloca un marcapáginas en esa página exacta. La próxima vez que abres el libro, no empiezas a hojear al azar buscando el error: el libro se abre solo en la página marcada, y el error está ahí, esperándote, hasta que lo corrijas. Cuando por fin arreglas la errata, el marcapáginas desaparece —ya no hace falta—. No pusiste tú el marcapáginas; la biblioteca lo hizo por ti, automáticamente, la primera vez que tropezaste con el fallo.
La base de datos de ejemplos de Hypothesis es ese bibliotecario invisible. La primera vez que una propiedad falla, Hypothesis guarda el caso que la rompió —el marcapáginas— en la carpeta .hypothesis/. La próxima corrida, antes de hojear al azar (generar casos nuevos), abre primero el libro en las páginas marcadas: reprueba los fallos guardados. Si el bug sigue ahí, lo encuentra al instante, sin depender de la suerte de la generación. Cuando arreglas el bug, el caso guardado deja de fallar y Hypothesis lo retira solo. Todo esto pasa sin que escribas una línea: la base de datos es el marcapáginas que la biblioteca pone y quita por ti.
La analogía aclara la pregunta del inicio. El fallo reaparece "idéntico" no porque la aleatoriedad se apague, sino porque Hypothesis reprueba primero lo que ya sabía que fallaba. La lotería sigue siendo lotería; simplemente, antes de jugarla, la biblioteca abre el libro en la página que ya marcó.
La carpeta .hypothesis/ y su .gitignore
Vamos a verla nacer. Parte de un proyecto sin base de datos —borra cualquier .hypothesis/ previo— y corre una propiedad que falla (el bono sin tope de siempre):
rm -rf .hypothesis
python3 -m pytest test_db_lesson.py
La propiedad falla con un caso mínimo (en mi corrida, hours_before=52, paid=25; en la tuya puede ser otro par pequeño —ya volveremos sobre por qué—). Ahora mira tu directorio: apareció una carpeta .hypothesis/. Explórala:
$ ls .hypothesis
constants examples
$ cat .hypothesis/.gitignore
# This .gitignore file was automatically created by Hypothesis. Hypothesis gitignores
# .hypothesis by default, because we generally recommend that .hypothesis not be checked
# into version control.
#
# If you *would* like to check .hypothesis into version control, you should delete this
# file. Hypothesis will not re-create this .gitignore unless .hypothesis is deleted (and
# if it does, that's a bug - please report it!)
*
Dos cosas para notar. Primero, dentro de examples/ hay archivos con nombres de códigos hexadecimales: son los casos fallidos guardados, codificados (no están pensados para que los leas a mano; son la representación interna que Hypothesis reprueba). Segundo, y más importante para tu día a día, Hypothesis creó un .gitignore que ignora la carpeta entera (esa última línea, *, ignora todo). El comentario lo explica: la recomendación oficial es no meter .hypothesis/ en el control de versiones. ¿Por qué? Porque es una caché local de tu máquina —los fallos que tú encontraste—, no parte del código compartido. Cada quien tiene la suya; no tiene sentido versionarla. (Si tuvieras una razón para compartirla —por ejemplo, una base de datos de fallos común en integración continua—, borrarías ese .gitignore; Hypothesis lo respeta. Pero el caso normal es dejarlo ignorar.)
Esta es ya una primera diferencia con @example: la base de datos es local y automática (vive en tu disco, se ignora en git), mientras que un @example es compartido y explícito (vive en tu código, viaja con git clone). Volveremos sobre esta pareja al final.
La fase de reuso: por qué el bug reaparece idéntico
Ahora la parte que responde la pregunta del inicio. Con la base de datos ya poblada (acabas de correr y fallar), corre otra vez el mismo test, pidiendo estadísticas para ver qué hace Hypothesis por dentro:
python3 -m pytest test_db_lesson.py --hypothesis-show-statistics
Qué esperar. El fallo reaparece igual, y las estadísticas muestran algo nuevo: una fase de reuso que hace todo el trabajo, sin llegar a generar:
- during reuse phase (0.03 seconds):
- 0 passing, 1 failing, and 0 invalid test cases
- Found 1 distinct error in this phase
- Stopped because nothing left to do
Léela. La reuse phase es Hypothesis abriendo el libro en el marcapáginas: tomó el caso guardado en la corrida anterior y lo reprobó primero, antes de generar nada. 0 passing, 1 failing: reprobó un solo caso —el guardado— y falló. Found 1 distinct error in this phase: el bug seguía ahí. Y Stopped because nothing left to do: en cuanto reconfirmó el fallo con el caso guardado, se detuvo —no hacía falta generar cien casos nuevos para redescubrir un bug que ya tenía anotado—. Por eso la segunda corrida reproduce el fallo idéntico y encima más rápido: no busca, recuerda.
Compara con la primera corrida (la que pobló la base de datos), que sí tuvo generate phase (buscar el fallo al azar) y shrink phase (encogerlo). La segunda corrida se saltó las dos: la fase de reuso le entregó el caso ya encontrado y ya encogido. Ese es el ciclo completo del marcapáginas: la primera vez buscas y marcas; las siguientes, abres directo en la marca. Y seguirá así —fallo idéntico, corrida tras corrida— hasta que arregles el bug; entonces el caso guardado dejará de fallar, Hypothesis lo retirará, y la fase de reuso ya no tendrá nada que reprobar.
Profundización: tres niveles de determinismo (y un matiz honesto)
Aquí conviene ser preciso, porque "determinista" se usa para tres cosas distintas y mezclarlas confunde. Vamos de menor a mayor garantía.
Nivel 1: la base de datos reproduce un fallo ya encontrado, en tu máquina. Es lo que acabas de ver. Una vez que un fallo se guardó, reaparece idéntico en las siguientes corridas de esa máquina, porque la fase de reuso lo reprueba. Garantía: "un bug que encontré no se me escapa hasta que lo arregle". Alcance: local (la base de datos es tuya) y solo para fallos ya descubiertos.
Nivel 2: derandomize fija la semilla, para los mismos valores en cualquier máquina. La generación de Hypothesis usa una semilla aleatoria distinta en cada corrida —por eso, sin base de datos, cada corrida explora casos diferentes—. El ajuste @settings(derandomize=True) fija esa semilla a un valor constante: la generación deja de ser aleatoria entre corridas y produce exactamente la misma secuencia de casos siempre, en cualquier máquina. Es lo que usamos en las lecciones 2 a 5 para que vieras los mismos números que yo (hours_before=50, paid=50). Garantía: "la búsqueda entera es reproducible, no solo un fallo ya hallado". Úsalo cuando necesites que una corrida sea idéntica en todas partes (documentación, ejemplos, depuración compartida).
Nivel 3: @reproduce_failure y @example clavan un caso exacto en el código. Ya conoces @example (lección 5): fija un caso que se prueba siempre. Su primo @reproduce_failure('6.161.2', b'...') es una línea que Hypothesis a veces sugiere en el reporte (si activas print_blob) para reproducir ese caso exacto pegándola como decorador:
You can reproduce this test case by temporarily adding
@reproduce_failure('6.161.2', b'AEEyQTI=') as a decorator on your test function
El blob b'AEEyQTI=' codifica el caso preciso. Garantía: "este caso puntual se reproduce donde pegues el decorador". Es la reproducción más quirúrgica, pensada para compartir un fallo con un compañero ("pégate esto y verás el mismo bug") o para depurar uno concreto. A diferencia de @example, el blob es opaco (no lees los valores en él) y está atado a la versión de Hypothesis; por eso es temporal, para reproducir, no para dejar como regresión permanente —para eso, @example—.
El matiz honesto: sin ninguno de estos, el mínimo puede variar entre corridas frescas. Esto casi nunca se dice, y conviene decirlo. Si borras la base de datos y corres la propiedad del bono sin derandomize, dos corridas frescas pueden aterrizar en mínimos distintos: una vez hours_before=50, paid=50, otra vez hours_before=52, paid=25, otra hours_before=51, paid=34. Los tres son mínimos válidos —casos chiquitos donde el reembolso supera lo pagado por un centavo— pero no son el mismo caso. ¿Por qué? Porque, como viste en la lección 2, este bug depende de dos dimensiones a la vez (horas y precio), y tiene varias esquinas mínimas; cuál alcanza el encogido depende del camino aleatorio que tomó la generación esa vez. No es un defecto: cada uno de esos casos reproduce el mismo bug y sirve igual para depurar. Pero explica por qué "el caso mínimo" no siempre es un caso fijo entre corridas frescas —y por qué, si necesitas reproducibilidad exacta, usas derandomize (nivel 2) o clavas el caso (nivel 3)—.
Junta los tres niveles y tienes el mapa completo: la base de datos te salva del "lo encontré y se me escapó" sin que hagas nada; derandomize te da corridas idénticas cuando las necesitas; @example/@reproduce_failure clavan casos puntuales en el código. Y por debajo, la generación sigue siendo aleatoria a propósito —para descubrir bugs nuevos—, con estas herramientas encima para que nada de lo ya hallado se pierda.
Errores comunes
Meter .hypothesis/ en el control de versiones. Qué pasa: alguien ve la carpeta nueva, no sabe qué es, y la incluye en su commit. Por qué pasa: no reparó en el .gitignore que Hypothesis puso justamente para evitarlo. Cómo detectarlo: si tu git status muestra archivos dentro de .hypothesis/, algo anuló el ignore (o lo borraste). Cómo corregirlo: deja que Hypothesis ignore la carpeta —es una caché local, no código—. Versionarla no aporta nada y ensucia el historial con archivos binarios que cambian en cada corrida. Si quieres una regresión que sí viaje con el repo, esa es la función de @example, no de la base de datos.
Creer que la base de datos hace la generación "no aleatoria". Qué pasa: alguien ve el fallo reaparecer idéntico y concluye que Hypothesis ya no genera al azar. Por qué pasa: confunde "reprueba primero lo guardado" con "dejó de ser aleatorio". Cómo detectarlo: si crees que la generación es determinista por la base de datos, borra .hypothesis/ y corre dos veces: verás que explora casos distintos. Cómo corregirlo: entiende que la base de datos solo reproduce lo ya encontrado (fase de reuso); después de reprobar lo guardado, la generación sigue siendo tan aleatoria como siempre, buscando bugs nuevos. Determinismo de "lo viejo", aleatoriedad para "lo nuevo". Si quieres que la generación misma sea determinista, eso es derandomize.
Esperar el mismo caso mínimo exacto en máquinas distintas sin fijar la semilla. Qué pasa: dos personas corren la misma propiedad fallida y obtienen mínimos distintos (50,50 vs 52,25), y una concluye que "algo está mal". Por qué pasa: no sabe que, sin derandomize, el camino aleatorio —y por tanto la esquina mínima que alcanza el encogido— difiere entre máquinas. Cómo detectarlo: si te preocupa que tu mínimo no coincida con el de un compañero, revisa si alguno fijó la semilla. Cómo corregirlo: acepta que varios mínimos válidos son normales para un bug multidimensional, y que todos sirven igual para depurar. Si de verdad necesitas el mismo caso en todas partes —para un ejemplo de documentación o una discusión—, usa @settings(derandomize=True) (nivel 2) o clava el caso con @example/@reproduce_failure (nivel 3).
Ejercicios
Ejercicio 1
Reproduce el ciclo del marcapáginas: borra .hypothesis/, corre la propiedad del bono sin tope (falla, se guarda), y córrela de nuevo con --hypothesis-show-statistics. Confirma que la segunda corrida muestra una reuse phase y Stopped because nothing left to do. Luego responde: ¿por qué la segunda corrida es más rápida que la primera?
Ver solución
La primera corrida (sin base de datos) muestra una generate phase (busca el fallo al azar) y una shrink phase (lo encoge), y crea .hypothesis/. La segunda corrida muestra solo una reuse phase con 0 passing, 1 failing y Found 1 distinct error, seguida de Stopped because nothing left to do.
La segunda es más rápida porque no busca: recuerda. En vez de generar cien casos aleatorios con la esperanza de tropezar el bug, y luego encogerlo, la fase de reuso toma el caso ya guardado (ya encontrado y ya encogido en la primera corrida) y lo reprueba directamente. Un solo caso, sin generación ni encogido: por eso Stopped because nothing left to do en cuanto reconfirma el fallo. El trabajo caro (buscar y encoger) se hizo una vez y quedó anotado; las corridas siguientes cobran ese ahorro. Es el marcapáginas: la primera vez hojeas todo el libro para encontrar la errata; después, el libro se abre solo en la página marcada.
Ejercicio 2
Empareja cada herramienta —base de datos, derandomize, @example— con la afirmación que la describe. No hay dos para la misma.
- "Vive en el código, viaja con
git clone, y prueba un caso concreto siempre." - "Vive en el disco local, se ignora en git, y reproduce en mi máquina un fallo que ya encontré."
- "Fija la semilla para que la generación produzca los mismos valores en cualquier máquina."
Ver solución
@example. Es la regresión explícita: la escribes en el código, viaja con el repositorio, y clava un caso que se prueba en cada corrida. (Lección 5.)- Base de datos (
.hypothesis/). Es la memoria implícita y automática: una caché local que Hypothesis mantiene sola, ignorada en git, que reprueba (fase de reuso) los fallos que esa máquina ya descubrió. derandomize. Fija la semilla aleatoria, volviendo reproducible la generación entera —los mismos casos, en el mismo orden, en todas partes—, no solo un fallo ya hallado.
La distinción clave entre 1 y 2 es explícito vs implícito y compartido vs local: @example es tu decisión, escrita y versionada; la base de datos es automática y personal. Se complementan: la base de datos te salva sin esfuerzo mientras depuras; cuando quieres que una regresión sea permanente y compartida, la promueves a @example.
Ejercicio 3
Borra .hypothesis/ y corre la propiedad del bono sin tope (sin derandomize) tres veces seguidas, borrando .hypothesis/ entre cada una. Anota el caso mínimo de cada corrida. ¿Son los tres idénticos? Explica el resultado usando lo que sabes del bug y del encogido, y di qué harías si necesitaras que las tres corridas dieran el mismo caso.
Ver solución
Borrando .hypothesis/ entre corridas (para que cada una sea "fresca", sin memoria), los mínimos pueden variar: podrías ver hours_before=50, paid=50 en una, hours_before=52, paid=25 en otra, hours_before=51, paid=34 en otra. No son idénticos —aunque los tres son casos minúsculos donde el reembolso supera lo pagado por un centavo—.
La explicación: el bug del bono depende de dos dimensiones a la vez (las horas por encima de 48 y el precio), así que tiene varias "esquinas mínimas" —combinaciones pequeñas que lo reproducen—. Sin semilla fija, cada corrida toma un camino aleatorio distinto, y el encogido aterriza en la esquina más cercana a ese camino. Todas son mínimos legítimos (quitarles un poco más hace desaparecer el fallo) y todas sirven igual para depurar; simplemente no son la misma. Borrar .hypothesis/ entre corridas quita la única cosa que las haría coincidir: la base de datos, que reproduciría el mismo caso guardado.
Si necesitara que las tres dieran el mismo caso, tengo dos opciones: @settings(derandomize=True), que fija la semilla y hace la generación —y por tanto el mínimo— idéntica en cada corrida; o no borrar .hypothesis/, dejando que la fase de reuso reproduzca el primer mínimo encontrado. La primera es para reproducibilidad total (misma búsqueda en todas partes); la segunda, para que un fallo ya hallado no cambie mientras lo depuro.
Resumen y siguiente paso
En esta lección abriste la base de datos de ejemplos: la carpeta .hypothesis/ que aparece sola, con su propio .gitignore, y que actúa como el marcapáginas que la biblioteca pone por ti. La primera vez que una propiedad falla, Hypothesis guarda el caso; en las corridas siguientes, la fase de reuso lo reprueba antes de generar, así que el bug reaparece idéntico —y más rápido— hasta que lo arregles. Por eso "no busca: recuerda".
Y despejaste el significado de "determinista" separándolo en tres niveles: la base de datos reproduce un fallo ya encontrado en tu máquina (local, automática, ignorada en git); derandomize fija la semilla para los mismos valores en cualquier máquina; y @example/@reproduce_failure clavan casos exactos en el código. Con el matiz honesto de que, sin ninguno de ellos, un bug multidimensional puede encoger a esquinas mínimas distintas entre corridas frescas —(50, 50), (52, 25), (51, 34)—, todas válidas. La base de datos es la pareja implícita de los @example explícitos de la lección anterior: juntas, nada de lo hallado se pierde.
Antes de avanzar deberías poder: explicar qué guarda .hypothesis/ y por qué se ignora en git; leer la reuse phase en las estadísticas; y distinguir los tres niveles de determinismo.
En la siguiente lección cerramos el módulo con el panel de control completo: settings. Ya usaste piezas sueltas —derandomize, phases— para propósitos concretos; ahora las ponemos todas sobre la mesa: max_examples (cuántos casos), deadline (cuánto puede tardar cada uno), phases (qué etapas) y derandomize, con la guía de cuándo tocar cada una y cuándo dejarla en paz. Ya conoces la memoria de Hypothesis; toca aprender a gobernar su esfuerzo.
Recursos
- The example database (documentación oficial de Hypothesis) — la referencia de
.hypothesis/: qué guarda, cómo configurardatabase, y por qué se recomienda no versionarla. La fuente de lo que esta lección explora. - Reproducing failures —
@reproduce_failurey@seed(documentación oficial) — cómo reproducir un caso exacto con el blob que sugiereprint_blob, y la diferencia con@example. El nivel 3 de determinismo de esta lección. - Settings —
derandomizeydatabase(documentación oficial de Hypothesis) — los ajustes que fijan la semilla y controlan la base de datos; los veremos en el panel completo de la lección 7.