Papyrus Online funciona — o això sembla quan el proves a mà. Però "ho vaig provar a mà dimarts" no és verificació: és una anècdota. Aquesta lliçó converteix els criteris d'acceptació de 12-01 en una suite de tests executable, la que et permet tocar qualsevol peça del sistema i saber en vint segons si has trencat res. Aplicarem la piràmide de M9 al projecte complet — què es prova a cada nivell i amb quina eina —, escriurem el test d'integració que recorre tot el flux (vendre → persistir → rellegir → quadrar caixa), provarem la interfície amb el seu client de proves, i aprendrem a depurar a través de les capes quan un test digui que no. Al final: la checklist de qualitat que decideix si el projecte està llest per lliurar-se.

Contingut

  1. La piràmide aplicada a Papyrus Online
  2. Dades de prova vs dades reals: les fixtures canòniques
  3. La suite mínima exigible (criteris → tests)
  4. El test d'integració del flux complet
  5. Provar la interfície: test client de Flask / Client de Django
  6. Regressions: cada bug deixa el seu test
  7. Depurar el sistema integrat
  8. Checklist final de qualitat

La piràmide aplicada a Papyrus Online

A 09-01 la piràmide era teoria; ara té noms i cognoms. Cada nivell prova una cosa diferent, amb una eina diferent, i en la proporció correcta (molts a baix, pocs a dalt):

Nivell Què es prova AQUÍ Eina Quants Velocitat
Unitari (domini) preu_final, aplicar_cupo, hi_ha_estoc, construcció de Llibre/Soci pytest pur, sense disc ni xarxa 10-15 mil·lisegons
Servei ServeiVendes complet: les tres fases, cada error, cada combinació soci/cupó pytest + repositoris sobre tmp_path 8-12 ràpids
Integració El flux sencer: vendre → persistir → rellegir → el tancament de caixa quadra pytest + tmp_path 2-3 moderats
Interfície Cada ruta retorna el seu codi i el seu JSON/HTML test client (Flask) / Client (Django) 5-8 moderats

La regla de repartiment: si un comportament es pot provar en un nivell inferior, es prova allà. L'arrodoniment de 12.35 es verifica al nivell unitari, no llançant peticions HTTP; l'endpoint només verifica que tradueix bé (JSON correcte, codi correcte), perquè el fons ja està provat més avall. Provar l'arrodoniment per HTTP funciona, però és lent, fràgil i, quan falla, no et diu on és el bug.

Dades de prova vs dades reals

Regla absoluta: els tests no toquen mai dades/. Un test que escriu al teu catàleg real és una bomba: et corromp les dades o, pitjor, passa o falla segons el que vas vendre ahir. Tots els tests treballen sobre tmp_path (M9) amb les fixtures canòniques — els quatre llibres de sempre, precisament perquè fa onze mòduls que en coneixes els números de memòria:

# tests/conftest.py
import json
import pytest
from papyrus.repositoris import (RegistreVendes, RepositoriCataleg,
                                 RepositoriSocis)
from papyrus.serveis import ServeiVendes

CATALEG = [
    {"titol": "L'Odissea", "autor": "Homer", "preu": 12.50, "estoc": 4},
    {"titol": "Hamlet", "autor": "Shakespeare", "preu": 9.95, "estoc": 6},
    {"titol": "El Quixot", "autor": "Cervantes", "preu": 15.90, "estoc": 8},
    {"titol": "Faust", "autor": "Goethe", "preu": 21.00, "estoc": 10},
]
SOCIS = [{"codi": "LLUIS-001", "nom": "Lluis", "alta": "2025-03-12"},
         {"codi": "MARTA-002", "nom": "Marta", "alta": "2025-06-30"},
         {"codi": "PAU-003", "nom": "Pau", "alta": "2026-01-15"}]

@pytest.fixture
def rutes(tmp_path):
    """Un dades/ de mentida, acabat de crear per a CADA test (aillament)."""
    (tmp_path / "cataleg.json").write_text(
        json.dumps(CATALEG, ensure_ascii=False), encoding="utf-8")
    (tmp_path / "socis.json").write_text(
        json.dumps(SOCIS, ensure_ascii=False), encoding="utf-8")
    return tmp_path

@pytest.fixture
def servei(rutes):
    """Un ServeiVendes muntat sobre el dades/ de mentida."""
    return ServeiVendes(RepositoriCataleg(rutes / "cataleg.json"),
                        RepositoriSocis(rutes / "socis.json"),
                        RegistreVendes(rutes / "vendes.csv"))

Aquí cobra sentit la decisió de disseny de 12-02: com que el servei rep els repositoris ja construïts, els tests li injecten repositoris de tmp_path sense tocar ni una línia del codi de producció. Si el teu servei hagués obert "dades/cataleg.json" a pèl, aquesta lliçó seria impossible. El codi fàcil de provar i el codi ben dissenyat són el mateix codi.

La suite mínima exigible

Els criteris d'acceptació de 12-01, un a un, convertits en tests amb nom. Aquesta taula és el teu RNF1: la suite està completa quan cada fila existeix i passa:

Test Requisit que verifica
test_preu_final_soci_i_no_soci (parametritzat: les 8 quanties canòniques) RF2
test_alta_llibre_duplicat_falla RF1
test_esborrar_llibre_i_consultar_lo_llanca_no_trobat RF1
test_vendre_a_soci_valid_aplica_descompte (12.35 amb LLUIS-001) RF2, RF3
test_vendre_amb_soci_desconegut_llanca_soci_invalid RF2
test_vendre_sense_estoc_llanca_error_i_no_modifica_res RF3
test_vendre_amb_cupo_valid_i_invalid (18.67 / CupoInvalidError) RF3
test_venda_persisteix_i_es_rellegeix (el d'integració, a sota) RF4
test_guardar_es_atomic_no_deixa_json_corrupte RF4
test_api_rutes_retornen_codis_correctes (o vistes Django) RF5
test_informe_reprodueix_numeros_canonics (520 u, dissabte 130) RF6
test_venda_rebutjada_deixa_warning_al_log (amb caplog) RF7

Amb les parametritzacions, això suma uns 25 tests. No és un número màgic: és la traducció literal del contracte de 12-01. Si demà afegeixes un requisit, la seva fila apareix aquí abans que el seu codi — això era TDD (09-03) i aquest projecte és el millor lloc per practicar-lo.

El test d'integració del flux complet

El test més valuós del projecte: recorre les quatre capes de dades (servei → disc → rellegir → agregar) i comprova que la història completa quadra. Complet i comentat:

# tests/test_integracio.py
from datetime import date
from papyrus.repositoris import (RegistreVendes, RepositoriCataleg,
                                 RepositoriSocis)
from papyrus.serveis import ServeiVendes

def test_flux_complet_vendre_persistir_rellegir_quadrar(rutes):
    # --- 1. VENDRE: soci + cupo, dues vendes ---
    servei = ServeiVendes(RepositoriCataleg(rutes / "cataleg.json"),
                          RepositoriSocis(rutes / "socis.json"),
                          RegistreVendes(rutes / "vendes.csv"))
    v1 = servei.vendre("Faust", 1, codi_soci="LLUIS-001", cupo="PAPYRUS10")
    v2 = servei.vendre("Hamlet", 1)                      # la Julia, sense soci
    assert v1.quantia == 18.67                           # 21.00 ×1.04 ×0.95 ×0.90
    assert v2.quantia == 10.35                           # 9.95 ×1.04

    # --- 2. PERSISTIR i RELLEGIR: una instancia NOVA, que nomes veu el disc ---
    # Si l'estoc es correcte aqui, es perque guardar() i carregar() funcionen
    # de veritat, no perque l'objecte vell ho recordes en memoria.
    cataleg_rellegit = RepositoriCataleg(rutes / "cataleg.json").carregar()
    assert cataleg_rellegit["Faust"].estoc == 9          # era 10
    assert cataleg_rellegit["Hamlet"].estoc == 5         # era 6

    # --- 3. QUADRAR: el tancament de caixa surt del CSV, no de la memoria ---
    servei_nou = ServeiVendes(RepositoriCataleg(rutes / "cataleg.json"),
                              RepositoriSocis(rutes / "socis.json"),
                              RegistreVendes(rutes / "vendes.csv"))
    avui = date.today().isoformat()
    assert servei_nou.tancament_de_caixa(avui) == 29.02  # 18.67 + 10.35

El detall que el fa un test d'integració de veritat és al pas 2: la instància nova. Si reutilitzes el servei original, el test pot passar encara que guardar() estigui trencada, perquè l'objecte recorda l'estoc en memòria. Rellegir des del disc és el que demostra RF4.

Provar la interfície

Itinerari A — test client de Flask (10-03). Per injectar el tmp_path necessitaràs el patró app factory: una funció crear_app(servei) en lloc de muntar el servei global en importar. És un refactor de deu minuts que torna a demostrar la regla: provar millora el disseny.

# tests/test_app.py
import pytest
from app import crear_app

@pytest.fixture
def client(servei):                        # reutilitza la fixture de conftest
    app = crear_app(servei)
    app.config["TESTING"] = True
    return app.test_client()

def test_venda_sense_estoc_retorna_409(client):
    resposta = client.post("/api/vendes",
                           json={"titol": "L'Odissea", "unitats": 99})
    assert resposta.status_code == 409
    assert "error" in resposta.get_json()  # el cos explica el rebuig

def test_venda_valida_retorna_201_amb_quantia(client):
    resposta = client.post("/api/vendes",
                           json={"titol": "Faust", "unitats": 1,
                                 "soci": "LLUIS-001", "cupo": "PAPYRUS10"})
    assert resposta.status_code == 201
    assert resposta.get_json()["quantia"] == 18.67

Itinerari B — Client de Django (10-05), on TestCase et dona una base de dades neta per test (el seu equivalent del tmp_path):

# cataleg/tests.py
from django.test import TestCase
from cataleg.models import Llibre

class TestVenda(TestCase):
    def setUp(self):
        Llibre.objects.create(titol="L'Odissea", autor="Homer",
                              preu=12.50, estoc=4)

    def test_venda_sense_estoc_mostra_error_al_form(self):
        resposta = self.client.post("/vendre/",
                                    {"titol": "L'Odissea", "unitats": 99})
        self.assertEqual(resposta.status_code, 200)      # el form es torna a mostrar
        self.assertContains(resposta, "estoc")           # ...amb l'error visible

Fixa't en què verifiquen aquests tests: la traducció, no el negoci. Que el 409 sigui 409, que el JSON porti la quantia, que el form torni a mostrar l'error. El càlcul del 18.67 ja va quedar demostrat dos nivells més avall.

Regressions: cada bug deixa el seu test

Durant 12-03 vas trobar bugs (tots en trobem). La regla de 09-05, ara com a disciplina de projecte: cap bug no es tanca sense el seu test. El flux: bug detectat → escrius el test que el reprodueix (falla, vermell) → arregles → el test passa (verd) → el test es queda a la suite per sempre. Aquest test val més que deu tests inventats: protegeix un punt on el teu sistema va demostrar que sabia trencar-se. Anota a DECISIONS.md cada regressió amb una línia ("tancament_de_caixa sumava strings — test_tancament_converteix_quanties"): a 12-05, aquesta llista serà or per a la teva retrospectiva.

Depurar el sistema integrat

Quan un test (o l'ús real) falla al sistema complet, el bug pot ser a qualsevol de les quatre capes. Tres tècniques, per ordre:

  • Llegir el traceback A TRAVÉS de les capes. Un traceback del sistema integrat és llarg: línies de Flask/Django, línies teves d'app.py, de serveis.py, de repositoris.py. Recorre'l de baix a dalt i localitza l'última línia teva: aquesta és la capa on ha esclatat. Però compte: on esclata no sempre és on es va trencar — un KeyError al servei pot ser un JSON mal guardat pel repositori fa tres vendes. El traceback et dona la capa de l'explosió; la causa pot viure una capa més avall.
  • El log com a testimoni entre capes. papyrus.log registra què va fer cada capa i en quin ordre (RF7 ja no és un requisit burocràtic: és la teva caixa negra). L'endpoint ha retornat 500? Mira el log: si l'última línia és l'INFO d'una venda, la fallada va ser després de vendre — al guardat o a la serialització de la resposta. Acabes de descartar dues capes sense obrir el depurador.
  • pdb al punt d'integració. breakpoint() just on una capa entrega a la següent (la crida de l'endpoint al servei, la del servei al repositori) i inspecciona el paquet que creua la frontera: p dades, p type(fila["unitats"]). Com deia 12-03: la majoria dels bugs d'integració són "creia que em passaves X i era Y" — i la frontera és on es veu.

Checklist final de qualitat

Abans de declarar el projecte "lliurable", passa-li aquesta llista. No és burocràcia: és la diferència entre creure que està bé i saber-ho.

  • [ ] RF1: alta duplicada rebutjada; baixa + consulta → no trobat. Test en verd.
  • [ ] RF2: les 8 quanties canòniques exactes (parametritzat); soci fals rebutjat.
  • [ ] RF3: venda sense estoc no modifica res (verifica l'estoc després de l'error!); 18.67 amb soci+cupó; cupó fals rebutjat.
  • [ ] RF4: el test d'integració passa; matar el procés durant un guardat no corromp el JSON.
  • [ ] RF5: totes les rutes/vistes amb el seu test de codi d'estat.
  • [ ] RF6: informe sobre vendes_2026.csv → 520 unitats, dissabte 130, PNG generat.
  • [ ] RF7: caplog (o el fitxer) confirma el WARNING a cada rebuig.
  • [ ] RNF1: pytest → tot verd, ~25 tests, cap no toca el dades/ real.
  • [ ] RNF2: zero funcions públiques del paquet sense type hints.
  • [ ] RNF3: zero except Exception genèrics sense justificació escrita.
  • [ ] RNF4: el README existeix i una altra persona podria arrencar el projecte amb ell (es verifica a 12-05).

Errors Comuns i Consells

  • Provar el negoci per HTTP. Si test_api_... verifica arrodoniments, tens la piràmide invertida: lenta, fràgil i amb diagnòstics pobres. L'endpoint prova la traducció; el domini, el fons.
  • Tests que comparteixen estat. El símptoma clàssic: passen en solitari, fallen en suite (o segons l'ordre). Causa: escriuen al mateix lloc. La fixture rutes crea un món nou per test; usa-la sempre.
  • Comparar floats a pèl. assert quantia == 18.67 funciona aquí perquè arrodonim a 2 decimals en un únic punt (12-03). Si un assert de quanties et falla per 0.0000001, no "arreglis el test amb pytest.approx": tens un arrodoniment de més o de menys al codi. approx és per a matemàtiques contínues, no per a diners arrodonits.
  • Esborrar el test d'un bug arreglat ("ja no falla, sobra"). És exactament a l'inrevés: és l'únic test amb una fallada real demostrada al seu historial.
  • Depurar afegint print pertot arreu. Ja tens tres eines millors i ordenades: traceback (quina capa?), log (què va passar abans?), pdb (què creua la frontera?). Els print solts acaben oblidats al codi — el log és un print amb contracte.

Exercicis

  1. Fita de projecte — la suite completa. Implementa les 12 files de la suite mínima (amb les seves parametritzacions). Verificació: pytest -v en verd, i el nom de cada test diu quin RF cobreix sense obrir el fitxer.
  2. Fita de projecte — el test del guardat atòmic. Escriu test_guardar_es_atomic: provoca una fallada a mitges de guardar (pista: passa-li un catàleg amb un objecte no serialitzable, que farà esclatar json.dump) i comprova que el fitxer original continua intacte i carregable. Aquest test justifica tot el disseny de l'os.replace de 12-03.
  3. Fita de projecte — la teva regressió. Tria el bug més dolorós que vas trobar a 12-03, escriu el seu test de regressió amb un nom que expliqui la història, i afegeix la línia corresponent a DECISIONS.md.

Solucions

  1. Autocomprovació en lloc de solució: executa pytest --collect-only i compara la llista amb la taula de la suite mínima; cada fila ha de tenir almenys un test recollit. Si test_informe_... triga uns quants segons, està bé: és el preu de pandas + PNG, i per això n'hi ha un, no vint.
  2. Orientativa: cataleg_trencat = dict(cataleg); cataleg_trencat["X"] = object() i with pytest.raises(TypeError): repo.guardar(cataleg_trencat); després, repo.carregar() ha de retornar el catàleg original sense error. Si el teu guardar escrivia directament sobre self._ruta, aquest test t'ho dirà amb un JSONDecodeError — i acabes d'entendre, amb una fallada controlada, per a què existia el fitxer temporal.
  3. Sense solució única. Criteri de qualitat del nom: algú que no va viure el bug ha d'entendre què protegeix — test_tancament_de_caixa_converteix_quanties_a_float sí; test_bug_arreglat_2 no.

Conclusió

Papyrus Online ja no és codi que sembla funcionar: és un sistema verificat. La piràmide ha deixat de ser un dibuix — domini amb tests unitaris a mil·lisegons, servei sobre tmp_path, un test d'integració que recorre vendre → persistir → rellegir → quadrar, i una interfície la traducció HTTP de la qual està demostrada amb el seu client de proves. Els criteris de 12-01 són ara 25 tests amb nom, cada bug del desenvolupament ha deixat la seva regressió, i saps depurar a través de les capes amb traceback, log i pdb a les fronteres. La checklist final està marcada: el projecte està acabat. Però acabat no és el mateix que lliurat: falta que una altra persona pugui instal·lar-lo, entendre'l i veure'l funcionar — i falta que tu puguis explicar-lo. Documentació, demo, informe final i la mirada enrere sobre tot el camí: això és l'última lliçó del curs.

Curs de Programació en Python

Mòdul 1: Introducció a Python

Mòdul 2: Estructures de Control

Mòdul 3: Funcions i Mòduls

Mòdul 4: Estructures de Dades

Mòdul 5: Programació Orientada a Objectes

Mòdul 6: Gestió de Fitxers

Mòdul 7: Gestió d'Errors i Excepcions

Mòdul 8: Temes Avançats

Mòdul 9: Proves i Depuració

Mòdul 10: Desenvolupament Web amb Python

Mòdul 11: Ciència de Dades amb Python

Mòdul 12: Projecte Final

© Copyright 2026. Tots els drets reservats