Els tests redueixen els bugs; no els extingeixen. Un dia la suite està en verd i tot i així la Júlia paga un preu estrany, o tancament_de_caixa retorna un total absurd: el bug viu en un cas que cap test no havia imaginat. Depurar no és mirar el codi fixament esperant una revelació: és mètode científic aplicat al programari — el bug és una hipòtesi que es formula, es posa a prova i es verifica. En aquesta lliçó construiràs aquest mètode amb les eines que ja tens (tracebacks de M7, f-strings de M4, el papyrus.log de M7) i l'aplicaràs complet a un bug real de Papyrus: un descompte aplicat dues vegades.
Contingut
- El mètode: reproduir → aïllar → corregir → protegir
- Rellegir el traceback (ara amb la pila profunda del paquet)
- Print-debugging honest: la f-string amb
= logginga DEBUG: prints amb nivells i memòria- El cas mínim reproduïble
- Eines mentals: ànec de goma i
git bisect - Taula de símptomes i sospitosos habituals
- Cas pràctic: el descompte fantasma de Papyrus
El mètode: reproduir → aïllar → corregir → protegir
Un bug és una afirmació empírica: "amb aquesta entrada, el programa fa una cosa diferent de l'esperada". S'ataca en quatre fases, sempre en aquest ordre:
graph LR
R["1. REPRODUIR<br/>fer-lo fallar a voluntat"] --> A["2. AÏLLAR<br/>reduir fins a la causa"]
A --> C["3. CORREGIR<br/>el canvi mínim que ho arregla"]
C --> P["4. PROTEGIR<br/>prova de regressió: que no torni"]
- Reproduir. Si no pots fer-lo fallar a voluntat, no pots saber si l'has arreglat. "A vegades falla" no és un punt de partida: és la primera hipòtesi a refinar (amb quines dades?, en quin ordre?, després de quina operació prèvia?).
- Aïllar. Formula hipòtesis ("l'estoc es corromp a
reposar") i dissenya'ls experiments barats que les descartin: cada hipòtesi descartada estreny el setge. Aquí viuen els prints, els logs i el depurador (propera lliçó). - Corregir. El canvi mínim que explica tot el símptoma. Si arregles sense entendre per què falla, no has corregit: has mogut el bug.
- Protegir. Una prova de regressió: el test que hauria caçat aquest bug, escrit ara, en vermell sobre el codi trencat i en verd després de l'arranjament. És TDD (09-04) aplicat al passat: cada bug corregit deixa la suite més forta, i aquest bug concret no pot tornar sense fer saltar una alarma.
Rellegir el traceback (ara amb la pila profunda del paquet)
A 07-01 vas aprendre a llegir tracebacks; aleshores eren de dos nivells. Amb Papyrus fet paquet, la pila és profunda, i la tècnica escala igual: es llegeix de baix a dalt. A baix, el què (tipus i missatge); pujant, l'on (l'última línia teva sol ser la pista calenta):
Traceback (most recent call last):
File "jornada.py", line 12, in <module>
total = cobrar_a_soci(cataleg, "Faust", 1)
File ".../papyrus/caixa.py", line 31, in cobrar_a_soci
quantia = vendre(cataleg, titol, unitats)
File ".../papyrus/magatzem.py", line 58, in vendre
preu = llibre.preu_final(soci)
File ".../papyrus/models.py", line 27, in preu_final
return round(base * (1 + IVA_LLIBRES), 2)
TypeError: unsupported operand type(s) for *: 'NoneType' and 'float'Lectura de baix a dalt: (1) un None ha arribat a una multiplicació a preu_final; (2) però preu_final no crea aquest None — base ve de més amunt; (3) la cadena caixa → magatzem → models et diu per on ha viatjat la dada. La pila no assenyala on va néixer el bug, sinó on ha explotat; l'origen sol ser un o diversos marcs més amunt (aquí: algú va assignar base = ... amb una funció que retornava None — et sona la fila 1 de la taula de sospitosos de més avall?). Aquest desfasament entre explosió i origen és la raó de ser de tot el que segueix.
Print-debugging honest: la f-string amb =
Posar print per veure què passa no és cosa d'aficionats: és l'eina més ràpida per a una hipòtesi concreta. Fes-ho bé, amb l'especificador = de les f-strings que vas veure a 04-05 — imprimeix l'expressió i el seu valor:
def vendre(cataleg, titol, unitats):
llibre = obtenir_llibre(cataleg, titol)
print(f"DEBUG: {titol=} {unitats=} {llibre.estoc=}") # ← xivato temporal
...Sortida: DEBUG: titol='Faust' unitats=1 llibre.estoc=10 — amb les cometes del repr, que distingeixen '4' de 4 (quants bugs són un string on esperaves un int!). Els seus límits, sense romanticisme:
| Límit del print | Conseqüència |
|---|---|
| Cal editar el codi per veure alguna cosa | I recordar-se de treure'l després (els print oblidats acaben a producció) |
| Només veus el que has preguntat | Si la hipòtesi era dolenta, a reeditar i reexecutar |
| Es barreja amb la sortida normal | A tancament_de_caixa amb mil files, el xivato s'hi ofega |
| No en queda registre | Demà no sabràs què valia ahir |
logging a DEBUG: prints amb nivells i memòria
Els dos últims límits els resol una cosa que ja tens muntada: el logging del mòdul 7, que escriu a dades/papyrus.log. Un logger.debug(...) és un print amb superpoders: té nivell (s'activa i es desactiva sense tocar el codi), context (data, mòdul) i memòria (queda al fitxer):
import logging
logger = logging.getLogger(__name__)
def vendre(cataleg, titol, unitats):
llibre = obtenir_llibre(cataleg, titol)
logger.debug("vendre: titol=%r unitats=%d estoc=%d", titol, unitats, llibre.estoc)
...En el dia a dia, el nivell del logger està a INFO i aquestes línies no costen gairebé res. Quan caces un bug, abaixes el llindar a DEBUG a la configuració (una línia, a caixa.py) i el fitxer papyrus.log es converteix en la caixa negra de l'avió: pots reconstruir la seqüència exacta que va precedir la fallada — fins i tot d'una fallada que va passar ahir a l'ordinador d'en Lluís. Regla pràctica: el print per a hipòtesis de dos minuts que esborres de seguida; el logger.debug permanent als punts calents del negoci (vendes, tancaments, càrregues de fitxer).
El cas mínim reproduïble
Quan la fallada apareix "en tancar la caixa del dimarts amb el catàleg gros", l'enemic és la mida: massa variables. La tècnica és reduir: retalla dades i passos a la meitat, comprova si continua fallant, repeteix. L'objectiu és un guió mínim que falla sempre:
# minim.py — el bug, reduit a l'essencia
from papyrus.models import Llibre
llibre = Llibre("L'Odissea", 12.50, 4)
print(llibre.preu_final(soci=True)) # imprimeix 11.73, esperava 12.35Tres línies que fallen valen més que una jornada sencera que falla: caben al cap, s'executen en mil·lisegons i — bonus — són a un pas de convertir-se en la prova de regressió de la fase 4. Si en reduir la fallada desapareix, també has après alguna cosa: la causa era en allò que acabes de treure.
Eines mentals: ànec de goma i git bisect
L'ànec de goma. Explica el problema en veu alta, línia a línia, a algú que no en sap res — clàssicament, un ànec de goma sobre el monitor. Funciona de debò: verbalitzar t'obliga a convertir suposicions ("això segur que retorna el llibre") en afirmacions explícites, i el bug sol viure en una suposició falsa. La meitat de les vegades, la frase "i llavors això multiplica per... espera" arriba abans d'acabar l'explicació.
git bisect (només el concepte). Si Papyrus funcionava la setmana passada i avui no, la pregunta canvia d'on és el bug a quan va entrar. Git pot fer cerca binària entre un commit bo conegut i el dolent actual: et va proposant commits intermedis, tu respons "bo/dolent" (o deixes que ho respongui la suite de tests — una altra raó per tenir-la), i en log₂(n) passos assenyala el commit exacte que va trencar les coses. No cal que el dominis avui; cal que sàpigues que existeix per al dia que el "quan" sigui la pista més barata.
Taula de símptomes i sospitosos habituals
Amb els anys, els depuradors desenvolupen olfacte. Aquí en tens un avançament, amb els sospitosos que aquest curs ja t'ha presentat:
| Símptoma | Sospitós habitual | On el vas veure |
|---|---|---|
TypeError ... 'NoneType' |
Una funció sense return (retorna None implícit), o cercar_llibre sense comprovar el None de la seva signatura -> Llibre | None |
M3, M8 |
| Dades que "canvien soles" | Mutació compartida: dos noms per al mateix dict/llista, còpia superficial, argument per defecte mutable | M4 |
0.30000000000000004, cèntims que ballen |
Aritmètica float binària; arrodoniments al lloc equivocat | M1 |
Accents trencats (Júlia) o UnicodeDecodeError |
Fitxer llegit/escrit sense encoding="utf-8" |
M6 |
KeyError amb una clau que "hi és" |
Espais o majúscules invisibles: "Hamlet " no és "Hamlet" (el repr amb %r/{x=} els delata) |
M4 |
| Funciona a mà, falla al test (o al revés) | Estat compartit entre tests, o dades reals vs. dades de prova | 09-02/09-03 |
La taula no substitueix el mètode: et dóna la primera hipòtesi, que encara cal verificar.
Cas pràctic: el descompte fantasma de Papyrus
Apliquem el mètode complet. Dilluns al matí: la Júlia, sòcia, compra L'Odissea i la caixa li cobra 11.73 €. L'Anna arrufa el nas: el preu de soci canònic és 12.35 €.
Fase 1 — Reproduir. L'Anna escriu el cas mínim (el minim.py d'abans): Llibre("L'Odissea", 12.50, 4).preu_final(soci=True) → 11.73, sempre. Reproduït a voluntat, i de passada queda clar que el bug és a models.py, no a caixa.py ni a vendre: el setge ja és petit.
Fase 2 — Aïllar. Hipòtesis per ordre de cost:
- H1: algú ha canviat les constants. Experiment:
print(f"{DESCOMPTE_SOCI=} {IVA_LLIBRES=}")→DESCOMPTE_SOCI=0.05 IVA_LLIBRES=0.04. Descartada. - H2: l'arrodoniment està malament. Comptes a mà:
12.50 × 0.95 × 1.04 = 12.35; cap arrodoniment produeix 11.73 des d'aquí. Descartada. Però la calculadora deixa anar una pista:11.73 ≈ 12.35 × 0.95. La quantia porta dos descomptes de soci. - H3: el descompte s'aplica dues vegades. L'Anna obre
preu_final— retocat divendres per a la campanya de cupons — i hi afegeix un xivato:
def preu_final(self, soci: bool = False) -> float:
base = self.preu * (1 - DESCOMPTE_SOCI) if soci else self.preu
print(f"{base=}") # xivato: base=11.875 ✔ correcte
if soci:
base = base * (1 - DESCOMPTE_SOCI) # ← una altra vegada! (linia de divendres)
return round(base * (1 + IVA_LLIBRES), 2)Divendres, l'Anna va afegir l'if soci: sense veure que la primera línia ja descomptava en la seva expressió condicional. 11.875 × 0.95 × 1.04 = 11.7325 → 11.73. Hipòtesi confirmada: el símptoma queda explicat al 100 %, cèntim a cèntim — l'estàndard que t'has d'exigir abans de tocar res.
Fase 3 — Corregir. Canvi mínim: eliminar el bloc if soci: duplicat (i el xivato). El cas mínim imprimeix 12.35.
Fase 4 — Protegir. Per què no va saltar la suite? L'Anna l'executa ara: el parametritzat de 09-03 amb els quatre preus de soci falla en vermell sobre el codi trencat... però ella no l'havia executat divendres abans de marxar. Dues proteccions, doncs: el test parametritzat ja existent (que hauria bastat — moralitat: la suite només protegeix si s'executa sempre, després de cada canvi) i una prova de regressió explícita que documenta l'incident:
def test_regressio_descompte_soci_no_saplica_dos_cops():
"""Bug 2026-07: preu_final descomptava el 5% dos cops (11.73 en lloc de 12.35)."""
llibre = Llibre("L'Odissea", 12.50, 4)
assert llibre.preu_final(soci=True) == pytest.approx(12.35)
assert llibre.preu_final(soci=True) != pytest.approx(11.73)Reproduir, aïllar amb hipòtesis barates, corregir el mínim, protegir amb un test. Quatre fases, zero endevinació.
Errors Comuns i Consells
- Canviar codi abans de reproduir. "Prova de canviar això a veure si..." és programació per superstició: si el símptoma desapareix, no saps si l'has arreglat o l'has amagat.
- Canviar dues coses alhora. Si toques dues línies i el bug mor, no saps quina l'ha matat — i potser una de les dues ha trencat una altra cosa. Un experiment, una variable.
- Confiar en la memòria en lloc d'apuntar. Amb tres hipòtesis a l'aire, apunta quines has descartat i amb quin experiment; rellegir-ho és l'ànec de goma en paper.
- Deixar els
printde depuració al codi. Embruten la sortida i confonen el següent. Els xivatos temporals s'esborren amb l'arranjament; el que mereixi quedar-se, que es quedi com alogger.debug. - Culpar primer Python o la biblioteca. El 99,9 % de les vegades el bug és teu. Esgota les teves hipòtesis abans de sospitar de l'intèrpret.
- Consell: quan l'arranjament funcioni, explica el cèntim. Si no pots justificar aritmèticament per què sortia 11.73 i ara surt 12.35, encara no has acabat d'entendre el bug.
Exercicis
Exercici 1
tancament_de_caixa retorna 41.40 per a un vendes.csv les files del qual sumen clarament 62.10 (tres vendes: 20.70 + 20.70 + 20.70). Escriu les tres primeres hipòtesis que investigaries, ordenades de més barata a més cara de comprovar, i l'experiment concret (una línia de codi o de shell) per a cadascuna. Pista: 41.40 = 62.10 − 20.70.
Exercici 2
Aquest traceback apareix en executar una jornada de Papyrus. Llegeix-lo de baix a dalt i respon: (a) què ha explotat i on?, (b) on buscaries l'origen?, (c) quina fila de la taula de sospitosos aplica?
Traceback (most recent call last):
File "jornada.py", line 8, in <module>
print(llibre.preu_final(soci=True))
File ".../papyrus/models.py", line 27, in preu_final
base = self.preu * (1 - DESCOMPTE_SOCI)
TypeError: can't multiply sequence by non-int of type 'float'Exercici 3
Converteix el cas mínim de l'exercici 1 (la fila que es perd) en una prova de regressió amb tmp_path (09-03), suposant que la causa era: l'última fila del CSV s'ignora si el fitxer no acaba en salt de línia.
Solucions
Exercici 1 — Falta exactament una fila (62.10 − 41.40 = 20.70): la hipòtesi mare és "una fila no se suma".
- H1 (segons): una fila es descarta com a corrupta. Experiment: abaixar el logger a DEBUG i reexecutar — l'avís de "fila ignorada" de M7 hauria d'aparèixer a
papyrus.log. - H2 (un minut): l'última fila no es llegeix (fitxer sense
\nfinal?). Experiment: cas mínim amb 3 files atmp_path, amb i sense salt de línia final. - H3 (minuts): el filtre
nomes_del_dia(08-03) descarta una venda per data. Experiment:print(f"{fila=}")dins del pipeline, o les tres files amb la mateixa data al cas mínim.
Exercici 2 — (a) Ha explotat una multiplicació a preu_final (models.py, línia 27): self.preu és una seqüència (un string com "12.50"), no un float — multiplicar string per int replica ("ab" * 3), però per float rebenta. (b) L'origen no és a models.py: és on es va construir aquest Llibre amb el preu sense convertir — gairebé segur una càrrega des de CSV que va oblidar float(fila["preu"]) (M6: tot el que surt d'un CSV és text). (c) La fila del KeyError/repr n'és cosina, però l'aplicable és la primera de les "de tipus": un string on esperaves un número — el {x=} amb el seu repr (self.preu='12.50', amb cometes) ho hauria delatat a l'instant. Nota: aquí __post_init__ validava el preu com a número positiu, però un string s'hi va colar — bon candidat a endurir la validació amb un test.
Exercici 3
def test_regressio_ultima_fila_sense_salt_final_tambe_suma(tmp_path):
"""Bug: l'ultima fila es perdia si el CSV no acabava en \\n."""
ruta = tmp_path / "vendes.csv"
ruta.write_text(
"data,titol,quantia\n"
"2026-07-10,Hamlet,20.70\n"
"2026-07-10,Hamlet,20.70\n"
"2026-07-10,Hamlet,20.70", # ← sense salt de linia final, a proposit
encoding="utf-8",
)
assert tancament_de_caixa(ruta) == pytest.approx(62.10)El comentari del cas degenerat (a propòsit) és part del test: d'aquí a un any, ningú no ha d'"arreglar" aquest detall sense entendre que és l'essència de la regressió.
Conclusió
Ja no depures per intuïció: reprodueixes a voluntat, aïlles amb hipòtesis barates (traceback llegit de baix a dalt, {x=} amb el seu repr delator, logger.debug a papyrus.log com a caixa negra, cas mínim que cap en tres línies), corregeixes el mínim explicant cada cèntim i protegeixes amb una prova de regressió que solda el bug per sempre — com el descompte fantasma de la Júlia, caçat amb el mètode complet. Però hauràs notat el vaivé: afegir un xivato, executar, llegir-lo, treure'l, afegir-ne un altre... Quan la hipòtesi exigeix veure el programa per dins i en moviment — aturar l'execució en una línia, inspeccionar qualsevol variable, avançar pas a pas — els prints es queden curts. Per a això existeix el depurador interactiu: pdb al terminal i el seu germà visual a VS Code. És l'última peça de la teva xarxa de seguretat, i la propera lliçó.
Curs de Programació en Python
Mòdul 1: Introducció a Python
- Introducció a Python
- Configuració de l'Entorn de Desenvolupament
- Sintaxi de Python i Tipus de Dades Bàsics
- Variables i Constants
- Entrada i Sortida Bàsica
- Entorns Virtuals i Gestió de Paquets
Mòdul 2: Estructures de Control
Mòdul 3: Funcions i Mòduls
- Definició de Funcions
- Arguments de Funció
- Funcions Lambda
- Mòduls i Paquets
- Visió General de la Biblioteca Estàndard
Mòdul 4: Estructures de Dades
Mòdul 5: Programació Orientada a Objectes
Mòdul 6: Gestió de Fitxers
- Lectura i Escriptura de Fitxers
- Treball amb Fitxers CSV
- Gestió de Dades JSON
- Operacions amb Fitxers i Directoris
Mòdul 7: Gestió d'Errors i Excepcions
- Introducció a les Excepcions
- Gestió d'Excepcions
- Llançament d'Excepcions
- Excepcions Personalitzades
- Bones Pràctiques i Registre d'Errors amb logging
Mòdul 8: Temes Avançats
- Anotacions de Tipus (type hints)
- Decoradors
- Generadors
- Gestors de Context
- Concurrència: Fils i Processos
- Asyncio per a la Programació Asíncrona
Mòdul 9: Proves i Depuració
- Introducció a les Proves
- Proves Unitàries amb unittest
- Proves amb pytest
- Desenvolupament Guiat per Proves
- Tècniques de Depuració
- Ús de pdb per a la Depuració
Mòdul 10: Desenvolupament Web amb Python
- Introducció al Desenvolupament Web
- Fonaments del Framework Flask
- Construcció d'APIs REST amb Flask
- Introducció a Django
- Construcció d'Aplicacions Web amb Django
Mòdul 11: Ciència de Dades amb Python
- Introducció a la Ciència de Dades
- NumPy per a la Computació Numèrica
- Pandas per a la Manipulació de Dades
- Matplotlib per a la Visualització de Dades
- Introducció a l'Aprenentatge Automàtic amb scikit-learn
