Los tests reducen los bugs; no los extinguen. Un día la suite está en verde y aun así Júlia paga un precio raro, o cierre_de_caja devuelve un total absurdo: el bug vive en un caso que ningún test imaginó. Depurar no es mirar el código fijamente esperando una revelación: es método científico aplicado a software — el bug es una hipótesis que se formula, se pone a prueba y se verifica. En esta lección construirás ese método con las herramientas que ya tienes (tracebacks de M7, f-strings de M4, el papyrus.log de M7) y lo aplicarás completo a un bug real de Papyrus: un descuento aplicado dos veces.

Contenido

  1. El método: reproducir → aislar → corregir → proteger
  2. Releer el traceback (ahora con la pila profunda del paquete)
  3. Print-debugging honesto: la f-string con =
  4. logging a DEBUG: prints con niveles y memoria
  5. El caso mínimo reproducible
  6. Herramientas mentales: pato de goma y git bisect
  7. Tabla de síntomas y sospechosos habituales
  8. Caso práctico: el descuento fantasma de Papyrus

El método: reproducir → aislar → corregir → proteger

Un bug es una afirmación empírica: "con esta entrada, el programa hace algo distinto de lo esperado". Se ataca en cuatro fases, siempre en este orden:

graph LR
    R["1. REPRODUCIR<br/>hacerlo fallar a voluntad"] --> A["2. AISLAR<br/>reducir hasta la causa"]
    A --> C["3. CORREGIR<br/>el cambio mínimo que lo arregla"]
    C --> P["4. PROTEGER<br/>test de regresión: que no vuelva"]
  • Reproducir. Si no puedes hacerlo fallar a voluntad, no puedes saber si lo arreglaste. "A veces falla" no es un punto de partida: es la primera hipótesis a refinar (¿con qué datos?, ¿en qué orden?, ¿tras qué operación previa?).
  • Aislar. Formula hipótesis ("el stock se corrompe en reponer") y diséñales experimentos baratos que las descarten: cada hipótesis descartada estrecha el cerco. Aquí viven los prints, los logs y el depurador (próxima lección).
  • Corregir. El cambio mínimo que explica todo el síntoma. Si arreglas sin entender por qué falla, no has corregido: has movido el bug.
  • Proteger. Un test de regresión: el test que habría cazado este bug, escrito ahora, en rojo sobre el código roto y en verde tras el arreglo. Es TDD (09-04) aplicado al pasado: cada bug corregido deja la suite más fuerte, y ese bug concreto no puede volver sin hacer saltar una alarma.

Releer el traceback (ahora con la pila profunda del paquete)

En 07-01 aprendiste a leer tracebacks; entonces eran de dos niveles. Con Papyrus hecho paquete, la pila es profunda, y la técnica escala igual: se lee de abajo arriba. Abajo, el qué (tipo y mensaje); subiendo, el dónde (la última línea tuya suele ser la pista caliente):

Traceback (most recent call last):
  File "jornada.py", line 12, in <module>
    total = cobrar_a_socio(catalogo, "Fausto", 1)
  File ".../papyrus/caja.py", line 31, in cobrar_a_socio
    importe = vender(catalogo, titulo, unidades)
  File ".../papyrus/almacen.py", line 58, in vender
    precio = libro.precio_final(socio)
  File ".../papyrus/modelos.py", line 27, in precio_final
    return round(base * (1 + IVA_LIBROS), 2)
TypeError: unsupported operand type(s) for *: 'NoneType' and 'float'

Lectura de abajo arriba: (1) un None llegó a una multiplicación en precio_final; (2) pero precio_final no crea ese Nonebase viene de más arriba; (3) la cadena caja → almacen → modelos te dice por dónde viajó el dato. La pila no señala dónde nació el bug, sino dónde explotó; el origen suele estar uno o varios marcos más arriba (aquí: alguien asignó base = ... con una función que devolvía None — ¿te suena la fila 1 de la tabla de sospechosos de más abajo?). Ese desfase entre explosión y origen es la razón de ser de todo lo que sigue.

Print-debugging honesto: la f-string con =

Poner print para ver qué pasa no es de aficionados: es la herramienta más rápida para una hipótesis concreta. Hazlo bien, con el especificador = de las f-strings que viste en 04-05 — imprime la expresión y su valor:

def vender(catalogo, titulo, unidades):
    libro = obtener_libro(catalogo, titulo)
    print(f"DEBUG: {titulo=} {unidades=} {libro.stock=}")   # ← chivato temporal
    ...

Salida: DEBUG: titulo='Fausto' unidades=1 libro.stock=10 — con las comillas del repr, que distinguen '4' de 4 (¡cuántos bugs son un string donde esperabas un int!). Sus límites, sin romanticismo:

Límite del print Consecuencia
Hay que editar el código para ver algo Y acordarse de quitarlo después (los print olvidados acaban en producción)
Solo ves lo que preguntaste Si la hipótesis era mala, a reeditar y reejecutar
Se mezcla con la salida normal En cierre_de_caja con mil filas, el chivato se ahoga
No queda registro Mañana no sabrás qué valía ayer

logging a DEBUG: prints con niveles y memoria

Los dos últimos límites los resuelve algo que ya tienes montado: el logging del módulo 7, que escribe en datos/papyrus.log. Un logger.debug(...) es un print con superpoderes: tiene nivel (se activa y desactiva sin tocar el código), contexto (fecha, módulo) y memoria (queda en el fichero):

import logging

logger = logging.getLogger(__name__)

def vender(catalogo, titulo, unidades):
    libro = obtener_libro(catalogo, titulo)
    logger.debug("vender: titulo=%r unidades=%d stock=%d", titulo, unidades, libro.stock)
    ...

En el día a día, el nivel del logger está en INFO y estas líneas no cuestan casi nada. Cuando cazas un bug, bajas el umbral a DEBUG en la configuración (una línea, en caja.py) y el fichero papyrus.log se convierte en la caja negra del avión: puedes reconstruir la secuencia exacta que precedió al fallo — incluso de un fallo que ocurrió ayer en el ordenador de Luis. Regla práctica: el print para hipótesis de dos minutos que borras enseguida; el logger.debug permanente en los puntos calientes del negocio (ventas, cierres, cargas de fichero).

El caso mínimo reproducible

Cuando el fallo aparece "al cerrar la caja del martes con el catálogo grande", el enemigo es el tamaño: demasiadas variables. La técnica es reducir: recorta datos y pasos a la mitad, comprueba si sigue fallando, repite. El objetivo es un guion mínimo que falla siempre:

# minimo.py — el bug, destilado
from papyrus.modelos import Libro

libro = Libro("La Odisea", 12.50, 4)
print(libro.precio_final(socio=True))   # imprime 11.73, esperaba 12.35

Tres líneas que fallan valen más que una jornada entera que falla: caben en la cabeza, se ejecutan en milisegundos y — bonus — están a un paso de convertirse en el test de regresión de la fase 4. Si al reducir el fallo desaparece, también has aprendido algo: la causa estaba en lo que acabas de quitar.

Herramientas mentales: pato de goma y git bisect

El pato de goma. Explica el problema en voz alta, línea a línea, a alguien que no sabe nada — clásicamente, un pato de goma sobre el monitor. Funciona de verdad: verbalizar te obliga a convertir suposiciones ("esto seguro que devuelve el libro") en afirmaciones explícitas, y el bug suele vivir en una suposición falsa. La mitad de las veces, la frase "y entonces esto multiplica por... espera" llega antes de acabar la explicación.

git bisect (solo el concepto). Si Papyrus funcionaba la semana pasada y hoy no, la pregunta cambia de dónde está el bug a cuándo entró. Git puede hacer búsqueda binaria entre un commit bueno conocido y el malo actual: te va proponiendo commits intermedios, tú respondes "bueno/malo" (o dejas que lo responda la suite de tests — otra razón para tenerla), y en log₂(n) pasos señala el commit exacto que rompió las cosas. No necesitas dominarlo hoy; necesitas saber que existe para el día en que el "cuándo" sea la pista más barata.

Tabla de síntomas y sospechosos habituales

Con los años, los depuradores desarrollan olfato. Aquí va un anticipo, con los sospechosos que este curso ya te presentó:

Síntoma Sospechoso habitual Dónde lo viste
TypeError ... 'NoneType' Una función sin return (devuelve None implícito), o buscar_libro sin comprobar el None de su firma -> Libro | None M3, M8
Datos que "cambian solos" Mutación compartida: dos nombres para el mismo dict/lista, copia superficial, argumento por defecto mutable M4
0.30000000000000004, céntimos que bailan Aritmética float binaria; redondeos en el sitio equivocado M1
Tildes rotas (Júlia) o UnicodeDecodeError Fichero leído/escrito sin encoding="utf-8" M6
KeyError con una clave que "está" Espacios o mayúsculas invisibles: "Hamlet " no es "Hamlet" (el repr con %r/{x=} los delata) M4
Funciona a mano, falla en el test (o viceversa) Estado compartido entre tests, o datos reales vs. datos de prueba 09-02/09-03

La tabla no sustituye al método: te da la primera hipótesis, que aún hay que verificar.

Caso práctico: el descuento fantasma de Papyrus

Apliquemos el método completo. Lunes por la mañana: Júlia, socia, compra La Odisea y la caja le cobra 11.73 €. Ana frunce el ceño: el precio de socio canónico es 12.35 €.

Fase 1 — Reproducir. Ana escribe el caso mínimo (el minimo.py de antes): Libro("La Odisea", 12.50, 4).precio_final(socio=True)11.73, siempre. Reproducido a voluntad, y de paso queda claro que el bug está en modelos.py, no en caja.py ni en vender: el cerco ya es pequeño.

Fase 2 — Aislar. Hipótesis en orden de coste:

  • H1: alguien cambió las constantes. Experimento: print(f"{DESCUENTO_SOCIO=} {IVA_LIBROS=}")DESCUENTO_SOCIO=0.05 IVA_LIBROS=0.04. Descartada.
  • H2: el redondeo está mal. Cuentas a mano: 12.50 × 0.95 × 1.04 = 12.35; ningún redondeo produce 11.73 desde ahí. Descartada. Pero la calculadora suelta una pista: 11.73 ≈ 12.35 × 0.95. El importe lleva dos descuentos de socio.
  • H3: el descuento se aplica dos veces. Ana abre precio_final — retocado el viernes para la campaña de cupones — y añade un chivato:
def precio_final(self, socio: bool = False) -> float:
    base = self.precio * (1 - DESCUENTO_SOCIO) if socio else self.precio
    print(f"{base=}")                                   # chivato: base=11.875 ✔ correcto
    if socio:
        base = base * (1 - DESCUENTO_SOCIO)             # ← ¡otra vez! (línea del viernes)
    return round(base * (1 + IVA_LIBROS), 2)

El viernes, Ana añadió el if socio: sin ver que la primera línea ya descontaba en su expresión condicional. 11.875 × 0.95 × 1.04 = 11.7325 → 11.73. Hipótesis confirmada: el síntoma queda explicado al 100 %, céntimo a céntimo — el estándar que debes exigirte antes de tocar nada.

Fase 3 — Corregir. Cambio mínimo: eliminar el bloque if socio: duplicado (y el chivato). El caso mínimo imprime 12.35.

Fase 4 — Proteger. ¿Por qué no saltó la suite? Ana la ejecuta ahora: el parametrizado de 09-03 con los cuatro precios de socio falla en rojo sobre el código roto... pero ella no lo había ejecutado el viernes antes de irse. Dos protecciones, entonces: el test parametrizado ya existente (que habría bastado — moraleja: la suite solo protege si se ejecuta siempre, tras cada cambio) y un test de regresión explícito que documenta el incidente:

def test_regresion_descuento_socio_no_se_aplica_dos_veces():
    """Bug 2026-07: precio_final descontaba el 5% dos veces (11.73 en vez de 12.35)."""
    libro = Libro("La Odisea", 12.50, 4)
    assert libro.precio_final(socio=True) == pytest.approx(12.35)
    assert libro.precio_final(socio=True) != pytest.approx(11.73)

Reproducir, aislar con hipótesis baratas, corregir lo mínimo, proteger con un test. Cuatro fases, cero adivinación.

Errores Comunes y Consejos

  • Cambiar código antes de reproducir. "Prueba a cambiar esto a ver si..." es programación por superstición: si el síntoma desaparece, no sabes si lo arreglaste o lo escondiste.
  • Cambiar dos cosas a la vez. Si tocas dos líneas y el bug muere, no sabes cuál lo mató — y quizá una de las dos rompió otra cosa. Un experimento, una variable.
  • Confiar en la memoria en vez de anotar. Con tres hipótesis en el aire, apunta cuáles descartaste y con qué experimento; releerlo es el pato de goma en papel.
  • Dejar los print de depuración en el código. Ensucian la salida y confunden al siguiente. Los chivatos temporales se borran con el arreglo; lo que merezca quedarse, que se quede como logger.debug.
  • Culpar primero a Python o a la biblioteca. El 99,9 % de las veces el bug es tuyo. Agota tus hipótesis antes de sospechar del intérprete.
  • Consejo: cuando el arreglo funcione, explica el céntimo. Si no puedes justificar aritméticamente por qué salía 11.73 y ahora sale 12.35, aún no has terminado de entender el bug.

Ejercicios

Ejercicio 1

cierre_de_caja devuelve 41.40 para un ventas.csv cuyas filas suman claramente 62.10 (tres ventas: 20.70 + 20.70 + 20.70). Escribe las tres primeras hipótesis que investigarías, ordenadas de más barata a más cara de comprobar, y el experimento concreto (una línea de código o de shell) para cada una. Pista: 41.40 = 62.10 − 20.70.

Ejercicio 2

Este traceback aparece al ejecutar una jornada de Papyrus. Léelo de abajo arriba y responde: (a) ¿qué explotó y dónde?, (b) ¿dónde buscarías el origen?, (c) ¿qué fila de la tabla de sospechosos aplica?

Traceback (most recent call last):
  File "jornada.py", line 8, in <module>
    print(libro.precio_final(socio=True))
  File ".../papyrus/modelos.py", line 27, in precio_final
    base = self.precio * (1 - DESCUENTO_SOCIO)
TypeError: can't multiply sequence by non-int of type 'float'

Ejercicio 3

Convierte el caso mínimo del ejercicio 1 (la fila que se pierde) en un test de regresión con tmp_path (09-03), suponiendo que la causa era: la última fila del CSV se ignora si el fichero no termina en salto de línea.

Soluciones

Ejercicio 1 — Falta exactamente una fila (62.10 − 41.40 = 20.70): la hipótesis madre es "una fila no se suma".

  1. H1 (segundos): una fila se descarta como corrupta. Experimento: bajar el logger a DEBUG y reejecutar — el aviso de "fila ignorada" de M7 debería aparecer en papyrus.log.
  2. H2 (un minuto): la última fila no se lee (¿fichero sin \n final?). Experimento: caso mínimo con 3 filas en tmp_path, con y sin salto de línea final.
  3. H3 (minutos): el filtro solo_del_dia (08-03) descarta una venta por fecha. Experimento: print(f"{fila=}") dentro del pipeline, o las tres filas con la misma fecha en el caso mínimo.

Ejercicio 2 — (a) Explotó una multiplicación en precio_final (modelos.py, línea 27): self.precio es una secuencia (un string como "12.50"), no un float — multiplicar string por int replica ("ab" * 3), pero por float revienta. (b) El origen no está en modelos.py: está donde se construyó ese Libro con el precio sin convertir — casi seguro una carga desde CSV que olvidó float(fila["precio"]) (M6: todo lo que sale de un CSV es texto). (c) La fila del KeyError/repr es prima, pero la aplicable es la primera de las "de tipo": un string donde esperabas un número — el {x=} con su repr (self.precio='12.50', con comillas) lo habría delatado al instante. Nota: aquí __post_init__ validaba el precio como número positivo, pero un string se le coló — buen candidato a endurecer la validación con un test.

Ejercicio 3

def test_regresion_ultima_fila_sin_salto_final_tambien_suma(tmp_path):
    """Bug: la última fila se perdía si el CSV no terminaba en \\n."""
    ruta = tmp_path / "ventas.csv"
    ruta.write_text(
        "fecha,titulo,importe\n"
        "2026-07-10,Hamlet,20.70\n"
        "2026-07-10,Hamlet,20.70\n"
        "2026-07-10,Hamlet,20.70",      # ← sin salto de línea final, a propósito
        encoding="utf-8",
    )
    assert cierre_de_caja(ruta) == pytest.approx(62.10)

El comentario del caso degenerado (a propósito) es parte del test: dentro de un año, nadie debe "arreglar" ese detalle sin entender que es la esencia de la regresión.

Conclusión

Ya no depuras por intuición: reproduces a voluntad, aíslas con hipótesis baratas (traceback leído de abajo arriba, {x=} con su repr delator, logger.debug en papyrus.log como caja negra, caso mínimo que cabe en tres líneas), corriges lo mínimo explicando cada céntimo y proteges con un test de regresión que suelda el bug para siempre — como el descuento fantasma de Júlia, cazado con el método completo. Pero habrás notado el vaivén: añadir un chivato, ejecutar, leerlo, quitarlo, añadir otro... Cuando la hipótesis exige ver el programa por dentro y en movimiento — parar la ejecución en una línea, inspeccionar cualquier variable, avanzar paso a paso — los prints se quedan cortos. Para eso existe el depurador interactivo: pdb en la terminal y su hermano visual en VS Code. Es la última pieza de tu red de seguridad, y la próxima lección.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados