L'script artesanal de la lliçó anterior provava Papyrus, però s'aturava a la primera fallada, contaminava el catàleg entre comprovacions i calia llançar-lo a mà. Python porta de sèrie la solució: unittest, el framework de proves de la biblioteca estàndard (inspirat en el clàssic JUnit de Java). Sense instal·lar res — ni tan sols cal pip — convertiràs aquelles comprovacions soltes en una suite organitzada: classes de test, asercions especialitzades, un escenari fresc per a cada prova i un executor que ho descobreix i ho corre tot amb una sola ordre.

Contingut

  1. Organitzar el projecte: la carpeta tests/
  2. Anatomia d'un TestCase
  3. Les asercions: assertEqual, assertAlmostEqual i companyia
  4. Provar excepcions: assertRaises com a context manager
  5. setUp i tearDown: un catàleg fresc per test
  6. Executar la suite: python -m unittest
  7. La suite real de Papyrus: test_models.py i test_magatzem.py

Organitzar el projecte: la carpeta tests/

La convenció universal és una carpeta tests/ al costat del paquet, no a dins: el codi de producció no ha de carregar amb les seves proves. El nostre projecte queda així:

papyrus_projecte/
├── papyrus/               ← el paquet (moduls 3-8)
│   ├── __init__.py
│   ├── errors.py
│   ├── models.py
│   ├── magatzem.py
│   └── caixa.py
├── dades/
│   ├── cataleg.csv
│   └── vendes.csv
└── tests/                 ← NOU: la suite de proves
    ├── __init__.py        ← converteix tests/ en paquet (ajuda el descobriment)
    ├── test_models.py     ← prova papyrus/models.py
    └── test_magatzem.py   ← prova papyrus/magatzem.py

Dues convencions importen perquè l'executor les fa servir per descobrir les proves automàticament: els fitxers comencen per test_, i a dins, els mètodes de prova també. Un fitxer de test per mòdul provat (test_models.pymodels.py) manté la simetria fàcil de navegar.

Anatomia d'un TestCase

A unittest, els tests s'agrupen en classes que hereten d'unittest.TestCase (herència, tal com la vas veure a M5). Cada mètode el nom del qual comenci per test_ és una prova independent:

# tests/test_models.py
import unittest

from papyrus.models import Llibre


class TestPreuFinal(unittest.TestCase):
    """Proves del calcul de preu amb IVA i descompte de soci."""

    def test_preu_sense_soci_aplica_nomes_iva(self):
        # Preparar
        llibre = Llibre("L'Odissea", 12.50, 4)
        # Actuar
        preu = llibre.preu_final()
        # Comprovar: 12.50 × 1.04 = 13.00
        self.assertEqual(preu, 13.00)

    def test_preu_soci_aplica_descompte_i_iva(self):
        llibre = Llibre("L'Odissea", 12.50, 4)
        preu = llibre.preu_final(soci=True)
        # 12.50 × 0.95 × 1.04 = 12.35 — el bug de l'Anna moriria aqui
        self.assertAlmostEqual(preu, 12.35, places=2)


if __name__ == "__main__":
    unittest.main()  # permet executar aquest fitxer directament

Desglossem-ho:

  • La classe agrupa tests relacionats; en pots tenir diverses per fitxer (TestPreuFinal, TestValidacioLlibre...).
  • Cada mètode test_ és un test aïllat: l'executor els corre per separat i informa de cadascun. Si test_preu_sense_soci... falla, test_preu_soci... s'executa igualment — adéu al "s'atura a la primera fallada" de l'assert a pèl.
  • self.assertEqual(a, b) substitueix assert a == b, i no és un caprici: quan falla, l'informe mostra tots dos valors (13.0 != 12.99), cosa que un assert nu no fa.
  • El if __name__ == "__main__": unittest.main() final és el nostre vell conegut de M3, ascendit: ja no llança una demo que cal mirar, sinó la suite que es jutja sola.
  • Fixa't en els noms descriptius: test_preu_soci_aplica_descompte_i_iva es llegeix com una frase. Quan falli d'aquí a sis mesos, el nom sol ja et dirà quin contracte s'ha trencat. Fuig de test_1, test_preu2.

Les asercions: assertEqual, assertAlmostEqual i companyia

TestCase ofereix desenes de mètodes assert*. Aquests són els que faràs servir el 95 % del temps:

Mètode Comprova Exemple a Papyrus
assertEqual(a, b) a == b assertEqual(quantia, 20.70)
assertNotEqual(a, b) a != b Preu soci ≠ preu normal
assertAlmostEqual(a, b, places=2) Igualtat de floats amb tolerància Qualsevol preu calculat
assertTrue(x) / assertFalse(x) Veracitat assertTrue(llibre.hi_ha_estoc())
assertIn(x, col) x in col assertIn("Hamlet", cataleg)
assertIsNone(x) / assertIsNotNone(x) x is None cercar_llibre amb títol inexistent
assertIs(a, b) Identitat (a is b) Comprovar que retorna el mateix objecte
assertRaises(Exc) Que es llança l'excepció EstocInsuficientError
assertGreater(a, b) / assertLess(a, b) Comparacions Estoc després de reposar

Dues mereixen comentari:

assertAlmostEqual i els floats. Des de M1 saps que 0.1 + 0.2 == 0.3 és False: els floats binaris no representen exactament els decimals. Els nostres preus s'arrodoneixen a 2 decimals dins de preu_final, així que assertEqual sol funcionar... fins que un refactor canviï on s'arrodoneix i el test falli per 12.350000000000001. assertAlmostEqual(preu, 12.35, places=2) compara arrodonint a 2 decimals la diferència, i és l'elecció robusta per a tot import monetari als tests.

assertEqual entre objectes. Recorda de M5 que @dataclass genera __eq__ comparant camp a camp. Això fa trivial comprovar objectes sencers:

self.assertEqual(
    cercar_llibre(cataleg, "Hamlet"),
    Llibre("Hamlet", 9.95, 6),   # __eq__ generat: compara titol, preu i estoc
)

Sense la dataclass hauries de comparar atribut a atribut. És un d'aquells dividends que M5 ens paga ara.

Provar excepcions: assertRaises com a context manager

Al mòdul 7 vam blindar vendre amb excepcions; són part del contracte i mereixen tests. La forma moderna d'assertRaises és com a context manager — el mateix protocol __enter__/__exit__ que vas construir a mà a 08-04:

from papyrus.errors import EstocInsuficientError

def test_vendre_sense_estoc_llanca_error(self):
    cataleg = {"L'Odissea": Llibre("L'Odissea", 12.50, 4)}

    with self.assertRaises(EstocInsuficientError) as ctx:
        vendre(cataleg, "L'Odissea", 5)   # nomes n'hi ha 4

    # L'excepcio capturada queda a ctx.exception: la podem inspeccionar
    self.assertEqual(ctx.exception.demanat, 5)
    self.assertEqual(ctx.exception.disponible, 4)

Veus l'elegància? El __exit__ del context manager rep l'excepció (com vas aprendre a 08-04) i decideix: si és l'esperada, se l'empassa i el test continua; si no en salta cap, el test falla amb "EstocInsuficientError not raised". S'ha acabat el ball try/except/assert False de la lliçó anterior. I com que EstocInsuficientError guarda titol, demanat i disponible (M7), podem verificar que l'excepció porta la informació correcta, no només que salta.

setUp i tearDown: un catàleg fresc per test

L'script artesanal tenia un vici: els tests compartien el catàleg, i l'ordre importava (M4 et va ensenyar com són de traïdores les mutacions compartides). unittest ho resol amb dos mètodes especials:

  • setUp(self): s'executa abans de cada mètode test_. Ideal per muntar l'escenari.
  • tearDown(self): s'executa després de cada test, encara que hagi fallat. Ideal per netejar (tancar fitxers, esborrar temporals).
class TestVendre(unittest.TestCase):

    def setUp(self):
        """Cataleg canonic ACABAT DE CREAR abans de cada test."""
        self.cataleg = {
            "L'Odissea": Llibre("L'Odissea", 12.50, 4),
            "Hamlet": Llibre("Hamlet", 9.95, 6),
            "El Quixot": Llibre("El Quixot", 15.90, 8),
            "Faust": Llibre("Faust", 21.00, 10),
        }

    def test_vendre_descompta_estoc(self):
        vendre(self.cataleg, "Hamlet", 2)
        self.assertEqual(self.cataleg["Hamlet"].estoc, 4)

    def test_vendre_retorna_quantia_amb_iva(self):
        quantia = vendre(self.cataleg, "Hamlet", 2)
        # Aquest test rep UN ALTRE cataleg fresc: l'estoc torna a ser 6
        self.assertAlmostEqual(quantia, 20.70, places=2)

Encara que test_vendre_descompta_estoc deixi l'estoc a 4, el test següent parteix de 6: setUp corre de nou. Cada test és una illa — la qualitat "aïllat" de 09-01, gratis. (També existeixen setUpClass/tearDownClass, un sol cop per classe, per a recursos cars; amb el nostre catàleg en memòria no calen.)

Executar la suite: python -m unittest

Des de l'arrel del projecte (amb el venv de M1 activat):

# Descobreix i executa TOT el que comenci per test_ sota tests/
python -m unittest discover tests

# Mode verbos: llista cada test amb el seu resultat
python -m unittest discover tests -v

# Nomes un fitxer, una classe o un test concret
python -m unittest tests.test_magatzem
python -m unittest tests.test_magatzem.TestVendre
python -m unittest tests.test_magatzem.TestVendre.test_vendre_descompta_estoc

La sortida explica la història sencera: un punt per test passat, F per fallada (una aserció no s'ha complert), E per error (el test ha rebentat amb una excepció no esperada), i al final el resum:

...F..
======================================================================
FAIL: test_preu_soci_aplica_descompte_i_iva (tests.test_models.TestPreuFinal)
----------------------------------------------------------------------
AssertionError: 12.36 != 12.35 within 2 places (0.01 difference)
----------------------------------------------------------------------
Ran 6 tests in 0.004s

FAILED (failures=1, errors=0)

Aquí tens el bug de l'Anna, caçat en 4 mil·lisegons, amb els dos valors a la vista. Això és el que l'assert a pèl no et podia donar.

La suite real de Papyrus: test_models.py i test_magatzem.py

Ajuntem-ho tot. Primer els models — el cas feliç, la validació del __post_init__ i hi_ha_estoc:

# tests/test_models.py
import unittest

from papyrus.models import Llibre


class TestValidacioLlibre(unittest.TestCase):

    def test_llibre_valid_es_crea_amb_les_seves_dades(self):
        llibre = Llibre("Faust", 21.00, 10)
        self.assertEqual(llibre, Llibre("Faust", 21.00, 10))  # __eq__ de dataclass

    def test_estoc_per_defecte_es_zero(self):
        self.assertEqual(Llibre("Faust", 21.00).estoc, 0)

    def test_preu_negatiu_llanca_valueerror(self):
        with self.assertRaises(ValueError):
            Llibre("Faust", -21.00, 10)   # __post_init__ ho veta (M5)

    def test_hi_ha_estoc_es_fals_a_zero(self):
        self.assertFalse(Llibre("Faust", 21.00, 0).hi_ha_estoc())
        self.assertTrue(Llibre("Faust", 21.00, 1).hi_ha_estoc())

I el magatzem — feliç, límits i els tres errors del contracte de vendre:

# tests/test_magatzem.py
import unittest

from papyrus.magatzem import cercar_llibre, obtenir_llibre, vendre
from papyrus.errors import LlibreNoTrobatError, EstocInsuficientError
from papyrus.models import Llibre


class TestVendre(unittest.TestCase):

    def setUp(self):
        self.cataleg = {
            "L'Odissea": Llibre("L'Odissea", 12.50, 4),
            "Hamlet": Llibre("Hamlet", 9.95, 6),
        }

    def test_venda_felic_retorna_quantia_i_descompta(self):
        quantia = vendre(self.cataleg, "Hamlet", 2)
        self.assertAlmostEqual(quantia, 20.70, places=2)
        self.assertEqual(self.cataleg["Hamlet"].estoc, 4)

    def test_vendre_tot_lestoc_deixa_zero(self):
        vendre(self.cataleg, "L'Odissea", 4)           # limit exacte
        self.assertEqual(self.cataleg["L'Odissea"].estoc, 0)

    def test_sense_estoc_llanca_error_i_no_muta(self):
        with self.assertRaises(EstocInsuficientError):
            vendre(self.cataleg, "L'Odissea", 5)
        self.assertEqual(self.cataleg["L'Odissea"].estoc, 4)  # intacte!

    def test_titol_inexistent_llanca_llibre_no_trobat(self):
        with self.assertRaises(LlibreNoTrobatError):
            vendre(self.cataleg, "Rayuela", 1)

    def test_unitats_invalides(self):
        with self.assertRaises(ValueError):
            vendre(self.cataleg, "Hamlet", 0)
        with self.assertRaises(TypeError):
            vendre(self.cataleg, "Hamlet", "dos")


class TestCerca(unittest.TestCase):

    def setUp(self):
        self.cataleg = {"Hamlet": Llibre("Hamlet", 9.95, 6)}

    def test_cercar_inexistent_retorna_none(self):
        self.assertIsNone(cercar_llibre(self.cataleg, "Rayuela"))

    def test_obtenir_inexistent_llanca_error(self):
        with self.assertRaises(LlibreNoTrobatError):
            obtenir_llibre(self.cataleg, "Rayuela")

Nou tests que fixen per escrit el contracte complet que vam construir entre els mòduls 5 i 8: les signatures que mypy verifica en tipus, ara verificades en comportament. Si demà l'Anna toca vendre, python -m unittest discover tests li dirà en mil·lisegons si ha trencat res.

Errors Comuns i Consells

  • Oblidar el prefix test_ en un mètode. L'executor l'ignora en silenci: la suite passa... perquè aquell test no existeix. Si un test "no falla mai", comprova primer que s'està executant (-v llista els noms).
  • self.assertRaises(ValueError, vendre(cataleg, "Hamlet", 0)). Això crida vendre abans que assertRaises pugui vigilar: l'excepció rebenta el test com a error. Fes servir la forma context manager (with self.assertRaises(...):) i crida dins del bloc.
  • Compartir estat mutable a nivell de classe (cataleg = {...} com a atribut de classe en lloc de crear-lo a setUp): tots els tests mutarien el mateix diccionari, ressuscitant el problema d'aliasing de M4.
  • assertTrue(a == b) en lloc d'assertEqual(a, b). Funciona, però en fallar només diu "False is not true" en comptes de mostrar-te tots dos valors. Fes servir sempre l'aserció més específica disponible.
  • ModuleNotFoundError: No module named 'papyrus' en executar els tests: llança python -m unittest des de l'arrel del projecte (on hi ha la carpeta papyrus/), no des de dins de tests/.
  • Consell: posa a cada test el nom d'una afirmació del contracte (test_sense_estoc_llanca_error_i_no_muta). L'informe de fallades es converteix en una llista de promeses incomplertes, llegible sense obrir codi.

Exercicis

Exercici 1

Escriu TestReposar a tests/test_magatzem.py, amb setUp propi, cobrint la taula que vas dissenyar a l'exercici 2 de 09-01: (a) reposar 5 Hamlet apuja l'estoc de 6 a 11 i la funció retorna None; (b) reposar un títol inexistent llança LlibreNoTrobatError; (c) unitats=0 llança ValueError i l'estoc no canvia.

Exercici 2

Afegeix a TestPreuFinal un test parametritzat a mà, test_preus_soci_canonics, que comprovi amb assertAlmostEqual els quatre preus de soci del catàleg canònic (12.35, 9.83, 15.71, 20.75) fent servir un bucle sobre una llista de tuples (titol, preu_base, esperat). Pista: fes servir with self.subTest(titol=titol): dins del bucle perquè, si fallen dos preus, l'informe mostri tots dos.

Exercici 3

Escriu un test que demostri que EstocInsuficientError transporta la informació correcta: en vendre 9 Hamlet (estoc 6), l'excepció capturada ha de tenir titol == "Hamlet", demanat == 9 i disponible == 6.

Solucions

Exercici 1

class TestReposar(unittest.TestCase):

    def setUp(self):
        self.cataleg = {"Hamlet": Llibre("Hamlet", 9.95, 6)}

    def test_reposar_suma_estoc_i_retorna_none(self):
        resultat = reposar(self.cataleg, "Hamlet", 5)
        self.assertIsNone(resultat)                       # -> None, com anota la signatura
        self.assertEqual(self.cataleg["Hamlet"].estoc, 11)

    def test_reposar_inexistent_llanca_error(self):
        with self.assertRaises(LlibreNoTrobatError):
            reposar(self.cataleg, "Rayuela", 5)

    def test_reposar_zero_unitats_llanca_valueerror_i_no_muta(self):
        with self.assertRaises(ValueError):
            reposar(self.cataleg, "Hamlet", 0)
        self.assertEqual(self.cataleg["Hamlet"].estoc, 6)

Exercici 2

def test_preus_soci_canonics(self):
    casos = [
        ("L'Odissea", 12.50, 12.35),
        ("Hamlet", 9.95, 9.83),
        ("El Quixot", 15.90, 15.71),
        ("Faust", 21.00, 20.75),
    ]
    for titol, base, esperat in casos:
        with self.subTest(titol=titol):
            llibre = Llibre(titol, base, 1)
            self.assertAlmostEqual(llibre.preu_final(soci=True), esperat, places=2)

subTest fa que cada volta del bucle s'informi per separat: sense ell, el primer preu que fallés amagaria els altres. (Avançament: a la propera lliçó, pytest.mark.parametrize fa exactament això amb més elegància.)

Exercici 3

def test_estoc_insuficient_transporta_les_dades(self):
    cataleg = {"Hamlet": Llibre("Hamlet", 9.95, 6)}
    with self.assertRaises(EstocInsuficientError) as ctx:
        vendre(cataleg, "Hamlet", 9)
    self.assertEqual(ctx.exception.titol, "Hamlet")
    self.assertEqual(ctx.exception.demanat, 9)
    self.assertEqual(ctx.exception.disponible, 6)

Conclusió

Papyrus ja té la seva primera suite de debò: tests/test_models.py i tests/test_magatzem.py fixen el contracte de Llibre, vendre, reposar i les cerques — casos feliços, límits i les excepcions de M7 incloses — amb un catàleg fresc per test gràcies a setUp, floats comparats amb assertAlmostEqual i excepcions vigilades pel context manager assertRaises. Tot amb la biblioteca estàndard i una sola ordre: python -m unittest discover tests. Però potser has notat una certa cerimònia: heretar de TestCase, memoritzar vint mètodes assert*, el self. omnipresent. La comunitat Python va pensar el mateix, i per això l'eina dominant al món real és una altra: pytest, on un test és una funció normal i l'aserció és l'assert de tota la vida — però amb superpoders. S'instal·la amb pip al venv, exactament com vas aprendre al mòdul 1. És la propera lliçó.

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