Salta el contingut

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

pip install pytest
pip install pytest-cov   # per a cobertura de codi

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:

  -1, 0, 1  |  49, 50, 51  |  69, 70, 71  |  89, 90, 91  |  99, 100, 101

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_zero millor que test_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

Recursos