unittest compleix, però arrossega cerimònia: heretar de TestCase, el self. omnipresent, vint mètodes assert* per memoritzar. La comunitat Python va convergir fa anys en una alternativa que redueix un test a la seva essència — una funció i un assert — sense perdre cap potència: pytest. En aquesta lliçó reescriuràs la suite de Papyrus en estil pytest, descobriràs les fixtures (parentes directes dels teus generadors de 08-03), provaràs fitxers sense tocar les dades reals gràcies a tmp_path i parametritzaràs els quatre preus de soci canònics en tres línies.
Contingut
- Instal·lació al venv i primer test
assertnu amb superpoders: la introspecció- La mateixa suite, reescrita: unittest vs pytest
- Excepcions amb
pytest.raisesi floats ambpytest.approx - Fixtures: el
setUpfet funció (i ambyield) tmp_path: provar fitxers sense tocardades/- Parametrització amb
@pytest.mark.parametrize - Executar:
pytest -v,-ki companyia - El següent esglaó de maduresa:
unittest.mock(només el mapa)
Instal·lació al venv i primer test
pytest no és a la biblioteca estàndard: s'instal·la amb pip dins del venv del projecte, exactament com vas aprendre al mòdul 1 (i convé congelar-lo a les teves dependències):
# Amb el venv activat, a l'arrel del projecte
pip install pytest
pytest --version # comprovacio rapidaUn test pytest és una funció normal el nom de la qual comença per test_, en un fitxer test_*.py. Sense classes, sense herència, sense self:
# tests/test_models.py — versio pytest
from papyrus.models import Llibre
def test_preu_sense_soci_aplica_nomes_iva():
llibre = Llibre("L'Odissea", 12.50, 4) # Preparar
preu = llibre.preu_final() # Actuar
assert preu == 13.00 # ComprovarExecuta pytest a l'arrel del projecte i ell sol descobreix tests/, els fitxers test_*.py i les funcions test_* (també entén classes TestCase, així que la teva suite de 09-02 corre sense canvis sota pytest — la migració pot ser gradual).
assert nu amb superpoders: la introspecció
A 09-01 vam dir que l'assert a pèl donava missatges pobres, i a 09-02 que assertEqual existia per mostrar-te tots dos valors. pytest dissol el dilema: reescriu els assert dels teus tests (introspecció d'asercions) perquè, en fallar, mostrin cada subexpressió avaluada. Si el bug del doble arrodoniment de l'Anna tornés:
def test_preu_soci_aplica_descompte_i_iva():
llibre = Llibre("L'Odissea", 12.50, 4)
> assert llibre.preu_final(soci=True) == 12.35
E assert 12.36 == 12.35
E + where 12.36 = preu_final(soci=True)
E + where preu_final = Llibre(titol="L'Odissea", preu=12.5, estoc=4).preu_finalObtens el valor real (12.36), l'esperat (12.35) i fins i tot el __repr__ del Llibre implicat (cortesia de la dataclass de M5) — sense haver escrit cap missatge. Per això a pytest l'assert nu no és una limitació: és la interfície.
La mateixa suite, reescrita: unittest vs pytest
La classe TestVendre de la lliçó anterior, traduïda:
# tests/test_magatzem.py — versio pytest
import pytest
from papyrus.magatzem import cercar_llibre, vendre
from papyrus.errors import EstocInsuficientError
from papyrus.models import Llibre
@pytest.fixture
def cataleg():
"""Cataleg canonic fresc per a cada test que el demani."""
return {
"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_venda_felic_retorna_quantia_i_descompta(cataleg):
quantia = vendre(cataleg, "Hamlet", 2)
assert quantia == pytest.approx(20.70)
assert cataleg["Hamlet"].estoc == 4
def test_sense_estoc_llanca_error_i_no_muta(cataleg):
with pytest.raises(EstocInsuficientError):
vendre(cataleg, "L'Odissea", 5)
assert cataleg["L'Odissea"].estoc == 4
def test_cercar_inexistent_retorna_none(cataleg):
assert cercar_llibre(cataleg, "Rayuela") is NoneLa comparació, cara a cara:
| Aspecte | unittest |
pytest |
|---|---|---|
| Origen | Biblioteca estàndard (sense instal·lar) | pip install pytest al venv |
| Estructura del test | Classe que hereta de TestCase + mètode |
Funció simple |
| Aserció d'igualtat | self.assertEqual(a, b) |
assert a == b (amb introspecció) |
| Floats | self.assertAlmostEqual(a, b, places=2) |
assert a == pytest.approx(b) |
| Excepcions | with self.assertRaises(Exc): |
with pytest.raises(Exc, match="..."): |
| Escenari per test | setUp/tearDown (mètodes) |
Fixtures (funcions, injectades per nom) |
| Parametritzar | Bucle + subTest |
@pytest.mark.parametrize |
| Fitxers temporals | Munta-t'ho tu (tempfile) |
Fixture tmp_path inclosa |
| Executar | python -m unittest discover tests |
pytest |
| Ecosistema | Estable, mínim | Enorme (plugins: cobertura, paral·lelisme...) |
Per què la comunitat prefereix pytest? Menys soroll per test (l'important ocupa tot l'espai), missatges de fallada molt superiors, fixtures componibles i un ecosistema de plugins gegant. unittest continua sent valuós — sense dependències, ubic en codi corporatiu antic — i tot el que has après a 09-02 (AAA, aïllament, noms descriptius) es trasllada intacte: només canvia la sintaxi.
Excepcions amb pytest.raises i floats amb pytest.approx
pytest.raises és, de nou, un context manager (08-04 continua rendint). El seu extra respecte a assertRaises és el paràmetre match=, una expressió regular que s'ha de trobar dins el missatge de l'excepció:
def test_sense_estoc_explica_el_problema(cataleg):
with pytest.raises(EstocInsuficientError, match="L'Odissea") as exc_info:
vendre(cataleg, "L'Odissea", 5)
# exc_info.value es l'excepcio: verifiquem la seva carrega util (M7)
assert exc_info.value.demanat == 5
assert exc_info.value.disponible == 4match="L'Odissea" garanteix que el missatge esmenta el títol — provem no només que falla, sinó que falla explicant-se bé, que era la raó de ser de la nostra jerarquia ErrorPapyrus.
pytest.approx resol els floats amb una sintaxi que es llegeix com matemàtica:
assert 0.1 + 0.2 == pytest.approx(0.3) # True, tolerancia relativa per defecte
assert quantia == pytest.approx(20.70, abs=0.01) # tolerancia absoluta d'1 centimPer a imports monetaris, abs=0.01 (a mig camí entre cèntims) és una elecció explícita i clara.
Fixtures: el setUp fet funció (i amb yield)
La fixture cataleg de dalt mereix una segona mirada. Es declara amb @pytest.fixture (un decorador, 08-02!) i els tests la reben demanant-la com a paràmetre: pytest veu que el test declara cataleg, busca la fixture d'aquest nom, l'executa i li injecta el resultat. Cada test rep un catàleg acabat de construir — l'aïllament de setUp, sense classe.
I el tearDown? Aquí pytest connecta amb els teus generadors de 08-03: una fixture amb yield fa el muntatge abans del yield i la neteja després:
@pytest.fixture
def registre_en_log():
# --- muntatge (com setUp) ---
logger = configurar_logger_de_prova()
yield logger # ← aqui s'executa el test
# --- neteja (com tearDown), corre encara que el test falli ---
logger.handlers.clear()És literalment el patró del generador: el codi es pausa al yield, el test consumeix el valor, i en acabar el test pytest reprèn la funció per executar la neteja. Setup i teardown en una sola funció, amb les variables compartides de manera natural. Si diverses suites necessiten la mateixa fixture, es mou a un fitxer especial tests/conftest.py i pytest la fa visible a tots els tests sense importar-la.
tmp_path: provar fitxers sense tocar dades/
carregar_cataleg(ruta: Path) -> dict[str, Llibre] i tancament_de_caixa(ruta_vendes: Path) -> float treballen amb fitxers (M6). Provar-les contra el dades/cataleg.csv real violaria la repetibilitat: el test fallaria el dia que l'Anna afegís un llibre. pytest regala la fixture tmp_path: un Path (el pathlib de M6!) a un directori temporal únic per test, que pytest crea i destrueix per tu.
from papyrus.magatzem import carregar_cataleg, tancament_de_caixa
def test_carregar_cataleg_inexistent_retorna_buit(tmp_path):
# Un Path a un fitxer que segur que NO existeix: contracte de M7 → {} + avis
resultat = carregar_cataleg(tmp_path / "no_existeix.csv")
assert resultat == {}
def test_tancament_de_caixa_suma_nomes_files_valides(tmp_path):
# Preparar: un vendes.csv de mentida, amb una fila corrupta
ruta = tmp_path / "vendes.csv"
ruta.write_text(
"data,titol,quantia\n"
"2026-07-10,Hamlet,20.70\n"
"2026-07-10,L'Odissea,BROSSA\n" # fila corrupta: s'ha d'ignorar
"2026-07-11,Faust,21.84\n",
encoding="utf-8",
)
# Actuar + Comprovar: tolerant a corruptes (contracte de M7/M8)
assert tancament_de_caixa(ruta) == pytest.approx(42.54)Fixa't en el que acabem d'aconseguir: provem el comportament tolerant a files corruptes de tancament_de_caixa — impossible de verificar contra el vendes.csv real sense embrutar-lo — en un directori d'un sol ús, amb el mateix write_text/encoding="utf-8" que domines des de M6. El test és ràpid, aïllat i repetible al portàtil de qualsevol dels socis.
Parametrització amb @pytest.mark.parametrize
L'exercici 2 de 09-02 (els quatre preus de soci, amb bucle i subTest) té a pytest una forma canònica:
@pytest.mark.parametrize(
"titol, base, esperat",
[
("L'Odissea", 12.50, 12.35),
("Hamlet", 9.95, 9.83),
("El Quixot", 15.90, 15.71),
("Faust", 21.00, 20.75),
],
)
def test_preu_soci_canonic(titol, base, esperat):
llibre = Llibre(titol, base, 1)
assert llibre.preu_final(soci=True) == pytest.approx(esperat, abs=0.01)El decorador genera quatre tests independents (un per tupla): pytest -v els llista com test_preu_soci_canonic[L'Odissea-12.5-12.35], etc., i si en fallen dos, veus tots dos. Afegir un cinquè llibre al catàleg canònic serà afegir una línia a la llista. Dades i lògica, separades.
Executar: pytest -v, -k i companyia
| Ordre | Què fa |
|---|---|
pytest |
Descobreix i executa tota la suite |
pytest -v |
Verbós: una línia per test, amb paràmetres |
pytest -k "soci" |
Només els tests el nom dels quals conté "soci" |
pytest tests/test_magatzem.py |
Només un fitxer |
pytest -x |
S'atura a la primera fallada (per depurar d'una en una) |
pytest --lf |
last failed: reexecuta només els que van fallar l'última vegada |
El cicle de treball natural: pytest complet després de cada canvi; -k o --lf mentre persegueixes una fallada concreta; -v quan vols llegir la suite com la llista de contractes que és.
El següent esglaó de maduresa: unittest.mock (només el mapa)
Siguem honestos: hi ha coses que aquesta lliçó no resol. Com proves una funció que crida una API per la xarxa, consulta l'hora actual o depèn d'alguna cosa lenta o no determinista? La resposta professional són els dobles de prova (mocks): objectes falsos que substitueixen la dependència real durant el test. Python els porta a unittest.mock (compatible amb pytest). És el següent esglaó en la maduresa d'un tester, i apareix de manera natural quan Papyrus parli amb serveis externs — cosa que començarà a passar al mòdul 10, amb la web. De moment, queda't amb el nom i amb aquesta regla: si la teva funció és difícil de provar perquè depèn de mig món, sol ser el disseny el que demana desacoblar-se, no el test el que demana màgia.
Errors Comuns i Consells
- Instal·lar pytest fora del venv (o amb el venv desactivat): després
pytest"no existeix" o executa contra un altre Python. Comprova-ho ambpip listdins del venv (M1). - Oblidar demanar la fixture com a paràmetre: si escrius
def test_x():i fas servircatalega dins, obtensNameError. La injecció només passa si el paràmetre es diu exactament com la fixture. - Mutar una fixture creient que és compartida (o al revés): per defecte cada test rep una fixture nova (
scope="function"). Si canvies ascope="module"per velocitat, els tests tornen a compartir estat mutable — el fantasma de M4. Amb dades mutables, queda't a l'scope per defecte. pytest.raises(Exception): capturar l'excepció més genèrica fa passar el test encara que salti un error diferent de l'esperat. Captura la classe concreta (EstocInsuficientError), com et va ensenyar el mòdul 7 ambexcept.- Construir rutes a mà en lloc de fer servir
tmp_path:Path("test_tmp.csv")deixa brossa al projecte i col·lisiona entre tests.tmp_pathés únic per test i es neteja sol. - Consell: executa
pytestabans de començar a canviar codi, no només després. Saber que parties de verd converteix qualsevol vermell posterior en informació pura.
Exercicis
Exercici 1
Reescriu en estil pytest els tres tests de reposar de l'exercici 1 de 09-02, fent servir la fixture cataleg (només necessites Hamlet) i pytest.raises amb match= en el cas del títol inexistent (el missatge de LlibreNoTrobatError conté el títol).
Exercici 2
Escriu un test parametritzat test_preu_sense_soci amb els quatre llibres canònics i els seus preus finals sense descompte (calcula: base × 1.04, arrodonit a 2 decimals) fent servir pytest.approx.
Exercici 3
Amb tmp_path, escriu test_tancament_de_caixa_fitxer_buit_retorna_zero: crea un vendes.csv que contingui només la capçalera data,titol,quantia i comprova que tancament_de_caixa retorna 0.0.
Solucions
Exercici 1
def test_reposar_suma_estoc_i_retorna_none(cataleg):
assert reposar(cataleg, "Hamlet", 5) is None
assert cataleg["Hamlet"].estoc == 11
def test_reposar_inexistent_llanca_error(cataleg):
with pytest.raises(LlibreNoTrobatError, match="Rayuela"):
reposar(cataleg, "Rayuela", 5)
def test_reposar_zero_unitats_no_muta(cataleg):
with pytest.raises(ValueError):
reposar(cataleg, "Hamlet", 0)
assert cataleg["Hamlet"].estoc == 6Exercici 2 — els esperats: 12.50×1.04=13.00; 9.95×1.04=10.348→10.35; 15.90×1.04=16.536→16.54; 21.00×1.04=21.84.
@pytest.mark.parametrize(
"titol, base, esperat",
[
("L'Odissea", 12.50, 13.00),
("Hamlet", 9.95, 10.35),
("El Quixot", 15.90, 16.54),
("Faust", 21.00, 21.84),
],
)
def test_preu_sense_soci(titol, base, esperat):
assert Llibre(titol, base, 1).preu_final() == pytest.approx(esperat, abs=0.01)Exercici 3
def test_tancament_de_caixa_fitxer_buit_retorna_zero(tmp_path):
ruta = tmp_path / "vendes.csv"
ruta.write_text("data,titol,quantia\n", encoding="utf-8")
assert tancament_de_caixa(ruta) == pytest.approx(0.0)Un fitxer amb només la capçalera és un límit clàssic (la família "límits" de 09-01): zero files per sumar no ha de ser un error, sinó 0.0.
Conclusió
La suite de Papyrus s'ha aprimat i ha guanyat múscul alhora: tests que són funcions, assert nu amb introspecció que mostra els valors en fallar, pytest.raises(..., match=) per a les excepcions de M7, pytest.approx per als preus, fixtures que injecten un catàleg fresc (i fan setup+teardown amb un yield, com els teus generadors), tmp_path per provar carregar_cataleg i tancament_de_caixa sense fregar dades/, i parametrize fixant els quatre preus de soci canònics en una taula. Fins ara, però, sempre hem seguit el mateix ordre: primer el codi, després els seus tests. A la propera lliçó invertirem la fletxa: escriure el test abans que el codi — vermell, verd, refactor — i descobrir que els tests no només verifiquen el disseny: el produeixen. Ho farem amb una peça nova de Papyrus que l'Anna fa setmanes que demana: els cupons de descompte.
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
