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
- Organitzar el projecte: la carpeta
tests/ - Anatomia d'un
TestCase - Les asercions:
assertEqual,assertAlmostEquali companyia - Provar excepcions:
assertRaisescom a context manager setUpitearDown: un catàleg fresc per test- Executar la suite:
python -m unittest - La suite real de Papyrus:
test_models.pyitest_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.pyDues 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.py ↔ models.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 directamentDesglossem-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. Sitest_preu_sense_soci...falla,test_preu_soci...s'executa igualment — adéu al "s'atura a la primera fallada" de l'asserta pèl. self.assertEqual(a, b)substitueixassert a == b, i no és un caprici: quan falla, l'informe mostra tots dos valors (13.0 != 12.99), cosa que unassertnu 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_ivaes llegeix com una frase. Quan falli d'aquí a sis mesos, el nom sol ja et dirà quin contracte s'ha trencat. Fuig detest_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ètodetest_. 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_estocLa 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 (-vllista els noms). self.assertRaises(ValueError, vendre(cataleg, "Hamlet", 0)). Això cridavendreabans queassertRaisespugui 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 asetUp): 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çapython -m unittestdes de l'arrel del projecte (on hi ha la carpetapapyrus/), no des de dins detests/.- 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
- 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
