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

  1. Instal·lació al venv i primer test
  2. assert nu amb superpoders: la introspecció
  3. La mateixa suite, reescrita: unittest vs pytest
  4. Excepcions amb pytest.raises i floats amb pytest.approx
  5. Fixtures: el setUp fet funció (i amb yield)
  6. tmp_path: provar fitxers sense tocar dades/
  7. Parametrització amb @pytest.mark.parametrize
  8. Executar: pytest -v, -k i companyia
  9. 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 rapida

Un 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                        # Comprovar

Executa 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_final

Obtens 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 None

La 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 == 4

match="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 centim

Per 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 amb pip list dins del venv (M1).
  • Oblidar demanar la fixture com a paràmetre: si escrius def test_x(): i fas servir cataleg a dins, obtens NameError. 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 a scope="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 amb except.
  • 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 pytest abans 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 == 6

Exercici 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

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