Proves unitàries amb Python¶
Les proves unitàries (unit tests) verifiquen que cada unitat de codi (funció, mètode, classe) funciona correctament de manera aïllada. Python ofereix dos frameworks principals: unittest (inclòs a la biblioteca estàndard) i pytest (tercers, més modern i concís).
Instal·lació de pytest¶
Tipus de proves: caixa negra vs. caixa blanca¶
| Caixa negra | Caixa blanca | |
|---|---|---|
| Perspectiva | Usuari extern | Desenvolupador intern |
| Coneixement del codi | No cal conèixer la implementació | Cal conèixer l'estructura interna |
| Què es prova | Entrades i sortides (comportament) | Camins d'execució, branques, condicions |
| Tècnica | Particions equivalents, valors límit | Cobertura de camins, cobertura de branques |
| Quan s'usa | Validar requisits i especificació | Garantir que tot el codi s'executa |
Caixa negra (Black-box testing)¶
Es prova el comportament extern de la funció sense saber com està implementada. Només importa: donada aquesta entrada, quin resultat hauria de retornar?
Tècniques¶
Particions equivalents — dividir les entrades en grups que haurien de comportar-se igual:
Funció: calcular_nota(punts) → 'Suspès' / 'Aprovat' / 'Notable' / 'Excel·lent'
Particions:
[−∞, 0) → entrada invàlida
[0, 49] → Suspès
[50, 69] → Aprovat
[70, 89] → Notable
[90, 100] → Excel·lent
(100, +∞) → entrada invàlida
Valors límit — provar els extrems de cada partició, on solen aparèixer els errors:
Exemple complet¶
# qualificacions.py
def calcular_nota(punts: int) -> str:
"""Retorna la qualificació corresponent a una puntuació de 0 a 100."""
if not isinstance(punts, (int, float)):
raise TypeError("La puntuació ha de ser numèrica")
if punts < 0 or punts > 100:
raise ValueError(f"Puntuació fora de rang: {punts}")
if punts < 50:
return "Suspès"
if punts < 70:
return "Aprovat"
if punts < 90:
return "Notable"
return "Excel·lent"
# test_qualificacions_negra.py — proves de caixa negra
import pytest
from qualificacions import calcular_nota
# --- Particions equivalents ---
class TestParticions:
def test_suspès_interior(self):
assert calcular_nota(25) == "Suspès"
def test_aprovat_interior(self):
assert calcular_nota(60) == "Aprovat"
def test_notable_interior(self):
assert calcular_nota(80) == "Notable"
def test_excellent_interior(self):
assert calcular_nota(95) == "Excel·lent"
# --- Valors límit ---
class TestValorsLimit:
def test_limit_inferior_valid(self):
assert calcular_nota(0) == "Suspès"
def test_limit_superior_valid(self):
assert calcular_nota(100) == "Excel·lent"
def test_frontera_suspès_aprovat_baix(self):
assert calcular_nota(49) == "Suspès"
def test_frontera_suspès_aprovat_alt(self):
assert calcular_nota(50) == "Aprovat"
def test_frontera_aprovat_notable_baix(self):
assert calcular_nota(69) == "Aprovat"
def test_frontera_aprovat_notable_alt(self):
assert calcular_nota(70) == "Notable"
def test_frontera_notable_excellent_baix(self):
assert calcular_nota(89) == "Notable"
def test_frontera_notable_excellent_alt(self):
assert calcular_nota(90) == "Excel·lent"
# --- Entrades invàlides ---
class TestEntredesInvalides:
def test_negatiu_llança_error(self):
with pytest.raises(ValueError):
calcular_nota(-1)
def test_superior_100_llança_error(self):
with pytest.raises(ValueError):
calcular_nota(101)
def test_tipus_incorrecte_llança_error(self):
with pytest.raises(TypeError):
calcular_nota("vuitanta")
Caixa blanca (White-box testing)¶
Es prova la lògica interna del codi. L'objectiu és que cada línia, branca (if/else) i camí d'execució sigui cobert per almenys un test.
Cobertura de codi (code coverage)¶
La cobertura mesura quines línies i branques del codi han estat executades durant els tests:
| Tipus | Descripció |
|---|---|
| Cobertura de línies | % de línies executades |
| Cobertura de branques | % de branques if/else recorregudes |
| Cobertura de camins | % de combinacions possibles de branques |
# Executar tests amb informe de cobertura
pytest --cov=qualificacions --cov-report=term-missing
# Generar informe HTML
pytest --cov=qualificacions --cov-report=html
Exemple complet — funció amb múltiples branques¶
# calculadora.py
def dividir(a: float, b: float) -> float:
"""Divideix a entre b. Llança ZeroDivisionError si b és 0."""
if b == 0:
raise ZeroDivisionError("No es pot dividir per zero")
return a / b
def es_parell(n: int) -> bool:
"""Retorna True si n és parell, False si és senar."""
if not isinstance(n, int):
raise TypeError("Cal un enter")
return n % 2 == 0
def factorial(n: int) -> int:
"""Calcula el factorial de n (n >= 0)."""
if n < 0:
raise ValueError("El factorial no està definit per a negatius")
if n == 0:
return 1
resultat = 1
for i in range(1, n + 1):
resultat *= i
return resultat
# test_calculadora_blanca.py — proves de caixa blanca
import pytest
from calculadora import dividir, es_parell, factorial
# --- dividir: 2 branques (b==0 i b!=0) ---
class TestDividir:
def test_divisio_normal(self):
# Cobreix la branca: b != 0
assert dividir(10, 2) == 5.0
def test_divisio_decimals(self):
assert dividir(7, 2) == 3.5
def test_divisio_per_zero(self):
# Cobreix la branca: b == 0
with pytest.raises(ZeroDivisionError):
dividir(5, 0)
def test_divisio_negatiu(self):
assert dividir(-10, 2) == -5.0
# --- es_parell: 3 branques (no int, parell, senar) ---
class TestEsParell:
def test_nombre_parell(self):
# Cobreix la branca: n % 2 == 0 → True
assert es_parell(4) is True
def test_nombre_senar(self):
# Cobreix la branca: n % 2 == 0 → False
assert es_parell(7) is False
def test_zero_es_parell(self):
assert es_parell(0) is True
def test_negatiu_parell(self):
assert es_parell(-2) is True
def test_tipus_incorrecte(self):
# Cobreix la branca: not isinstance
with pytest.raises(TypeError):
es_parell(3.5)
# --- factorial: 3 branques (negatiu, n==0, bucle) ---
class TestFactorial:
def test_factorial_zero(self):
# Cobreix la branca: n == 0
assert factorial(0) == 1
def test_factorial_u(self):
assert factorial(1) == 1
def test_factorial_positiu(self):
# Cobreix la branca: bucle range(1, n+1)
assert factorial(5) == 120
def test_factorial_gran(self):
assert factorial(10) == 3628800
def test_factorial_negatiu(self):
# Cobreix la branca: n < 0
with pytest.raises(ValueError):
factorial(-3)
Organització dels tests¶
Estructura de fitxers recomanada¶
projecte/
├── src/
│ ├── qualificacions.py
│ └── calculadora.py
└── tests/
├── __init__.py
├── test_qualificacions_negra.py ← caixa negra
└── test_calculadora_blanca.py ← caixa blanca
Nomenclatura¶
- Els fitxers de test han de començar per
test_o acabar per_test.py - Les funcions i mètodes de test han de començar per
test_ - Usar noms descriptius:
test_divisio_per_zeromillor quetest_cas3
Fixtures — preparació de l'entorn¶
Les fixtures de pytest permeten preparar dades o objectes compartits per múltiples tests:
import pytest
class Cistella:
def __init__(self):
self.articles = []
def afegir(self, article, preu):
self.articles.append({"article": article, "preu": preu})
def total(self):
return sum(a["preu"] for a in self.articles)
def buidar(self):
self.articles.clear()
@pytest.fixture
def cistella_plena():
"""Fixture: cistella amb 3 articles pre-carregats."""
c = Cistella()
c.afegir("Poma", 1.20)
c.afegir("Pa", 0.90)
c.afegir("Llet", 1.50)
return c
class TestCistella:
def test_total_correcte(self, cistella_plena):
assert cistella_plena.total() == pytest.approx(3.60)
def test_nombre_articles(self, cistella_plena):
assert len(cistella_plena.articles) == 3
def test_buidar(self, cistella_plena):
cistella_plena.buidar()
assert cistella_plena.total() == 0
def test_cistella_buida(self):
c = Cistella()
assert c.total() == 0
Parametrització — un test, múltiples casos¶
@pytest.mark.parametrize permet executar el mateix test amb conjunts de dades diferents, ideal per a proves de caixa negra:
import pytest
from qualificacions import calcular_nota
@pytest.mark.parametrize("punts, esperada", [
(0, "Suspès"),
(25, "Suspès"),
(49, "Suspès"),
(50, "Aprovat"),
(60, "Aprovat"),
(69, "Aprovat"),
(70, "Notable"),
(80, "Notable"),
(89, "Notable"),
(90, "Excel·lent"),
(95, "Excel·lent"),
(100, "Excel·lent"),
])
def test_calcular_nota(punts, esperada):
assert calcular_nota(punts) == esperada
@pytest.mark.parametrize("punts", [-1, -100, 101, 200])
def test_punts_invalids_llancen_error(punts):
with pytest.raises(ValueError):
calcular_nota(punts)
Mocks — simular dependències externes¶
Els mocks substitueixen dependències externes (APIs, base de dades, fitxers) per objectes simulats, de manera que el test sigui ràpid i determinista:
from unittest.mock import patch, MagicMock
import requests
def obtenir_temperatura(ciutat: str) -> float:
"""Consulta la temperatura actual d'una ciutat via API."""
r = requests.get(f"https://api.temps.com/v1/{ciutat}")
r.raise_for_status()
return r.json()["temperatura"]
class TestObteniTemperatura:
@patch("requests.get")
def test_temperatura_correcta(self, mock_get):
# Simular la resposta de l'API sense fer cap petició real
mock_resposta = MagicMock()
mock_resposta.json.return_value = {"temperatura": 22.5}
mock_get.return_value = mock_resposta
resultat = obtenir_temperatura("Blanes")
assert resultat == 22.5
mock_get.assert_called_once_with("https://api.temps.com/v1/Blanes")
@patch("requests.get")
def test_error_connexio(self, mock_get):
# Simular un error de xarxa
mock_get.side_effect = requests.exceptions.ConnectionError
with pytest.raises(requests.exceptions.ConnectionError):
obtenir_temperatura("Blanes")
Executar els tests¶
# Executar tots els tests del directori actual
pytest
# Executar un fitxer concret
pytest tests/test_calculadora_blanca.py
# Executar una classe o funció concreta
pytest tests/test_calculadora_blanca.py::TestDividir
pytest tests/test_calculadora_blanca.py::TestDividir::test_divisio_per_zero
# Mode verbós (mostra cada test)
pytest -v
# Aturar-se al primer error
pytest -x
# Mostrar la sortida de print()
pytest -s
# Cobertura de codi
pytest --cov=src --cov-report=term-missing
Sortida típica¶
========================= test session starts ==========================
collected 12 items
tests/test_calculadora_blanca.py::TestDividir::test_divisio_normal PASSED
tests/test_calculadora_blanca.py::TestDividir::test_divisio_per_zero PASSED
tests/test_calculadora_blanca.py::TestEsParell::test_nombre_parell PASSED
tests/test_calculadora_blanca.py::TestFactorial::test_factorial_zero PASSED
...
========================== 12 passed in 0.05s ==========================
Comparativa unittest vs pytest¶
# Amb unittest (biblioteca estàndard)
import unittest
from calculadora import dividir
class TestDividirUnittest(unittest.TestCase):
def test_divisio_normal(self):
self.assertEqual(dividir(10, 2), 5.0)
def test_divisio_per_zero(self):
with self.assertRaises(ZeroDivisionError):
dividir(5, 0)
if __name__ == '__main__':
unittest.main()
# Amb pytest (equivalent, més concís)
from calculadora import dividir
def test_divisio_normal():
assert dividir(10, 2) == 5.0
def test_divisio_per_zero():
with pytest.raises(ZeroDivisionError):
dividir(5, 0)
| Característica | unittest |
pytest |
|---|---|---|
| Instal·lació | Inclòs a Python | pip install pytest |
| Sintaxi | self.assertEqual(...) |
assert ... |
| Fixtures | setUp / tearDown |
@pytest.fixture |
| Parametrització | Manual | @pytest.mark.parametrize |
| Plugins | Pocs | Molts (cobertura, mock, etc.) |
Resum: quan usar cada tècnica¶
| Situació | Tècnica |
|---|---|
| Validar requisits i especificació | Caixa negra — particions equivalents |
| Provar valors extrems i fronteres | Caixa negra — valors límit |
| Garantir que tot el codi s'executa | Caixa blanca — cobertura de línies |
Provar totes les branques if/else |
Caixa blanca — cobertura de branques |
| Preparar dades per a múltiples tests | Fixtures |
| Provar molts casos de manera concisa | Parametrització |
| Aïllar dependències externes | Mocks |