Testing Unitario in Python: Guida Completa con Esempi

Test Python con pytest, TDD, mocking e coverage

Indice

L’unità di test garantisce che il tuo codice Python funzioni correttamente e continui a funzionare mentre il progetto evolve. Questa guida completa copre tutto ciò che devi sapere sulle unità di test in Python, dagli concetti di base alle tecniche avanzate.

Python Unit Testing

Perché le unità di test contano

Le unità di test offrono numerosi vantaggi per gli sviluppatori Python:

  • Rilevamento precoce degli errori: Cattura gli errori prima che arrivino in produzione
  • Qualità del codice: Ti obbliga a scrivere codice modulare e testabile
  • Confidenza nel refactoring: Modifica in modo sicuro sapendo che i test rileveranno le regressioni
  • Documentazione: I test servono come documentazione eseguibile su come dovrebbe funzionare il codice
  • Sviluppo più rapido: I test automatici sono più veloci dei test manuali
  • Progettazione migliore: Scrivere codice testabile porta a una migliore architettura

Comprendere i fondamenti delle unità di test

Cosa è un test unitario?

Un test unitario verifica la parte più piccola e testabile di un’applicazione (di solito una funzione o un metodo) in isolamento. Dovrebbe essere:

  • Veloce: Eseguito in millisecondi
  • Isolato: Indipendente da altri test e sistemi esterni
  • Ripetibile: Produca gli stessi risultati ogni volta
  • Autoverificante: Passi o fallisca chiaramente senza ispezione manuale
  • Tempestivo: Scritto prima o insieme al codice

La piramide dei test

Un insieme di test sano segue la piramide dei test:

           /\
          /  \     Test E2E (Pochi)
         /____\
        /      \   Test di integrazione (Alcuni)
       /_____ ___\
      /          \ Test unitari (Molti)
     /_____ __ ___\

I test unitari formano la base - sono numerosi, veloci e forniscono un feedback rapido.

Confronto tra framework di test per Python

unittest: Il framework integrato

La libreria standard di Python include unittest, ispirato a JUnit:

import unittest

class TestCalculator(unittest.TestCase):
    def setUp(self):
        """Eseguito prima di ogni test"""
        self.calc = Calculator()
    
    def tearDown(self):
        """Eseguito dopo ogni test"""
        self.calc = None
    
    def test_addition(self):
        result = self.calc.add(2, 3)
        self.assertEqual(result, 5)
    
    def test_division_by_zero(self):
        with self.assertRaises(ZeroDivisionError):
            self.calc.divide(10, 0)

if __name__ == '__main__':
    unittest.main()

Punti di forza:

  • Integrato, non necessita di installazione
  • Buono per gli sviluppatori familiari con i framework xUnit
  • Amichevole per le aziende, ben stabilito

Punti deboli:

  • Sintassi verbosa con codice boilerplate
  • Richiede classi per l’organizzazione dei test
  • Gestione dei fixture meno flessibile

pytest: La scelta moderna

pytest è il framework di test terze parti più popolare:

import pytest

def test_addition():
    calc = Calculator()
    assert calc.add(2, 3) == 5

def test_division_by_zero():
    calc = Calculator()
    with pytest.raises(ZeroDivisionError):
        calc.divide(10, 0)

@pytest.mark.parametrize("a,b,expected", [
    (2, 3, 5),
    (-1, 1, 0),
    (0, 0, 0),
])
def test_addition_parametrized(a, b, expected):
    calc = Calculator()
    assert calc.add(a, b) == expected

Punti di forza:

  • Sintassi semplice e Pythonica
  • Sistema di fixture potente
  • Ecosistema di plugin eccellente
  • Segnalazione degli errori migliore
  • Test parametrizzati integrati

Punti deboli:

  • Richiede l’installazione
  • Meno familiare agli sviluppatori provenienti da altri linguaggi

Installazione:

pip install pytest pytest-cov pytest-mock

Scrivere i primi test unitari

Costruiamo un esempio semplice da zero utilizzando lo sviluppo guidato dai test (TDD). Se sei nuovo a Python o hai bisogno di un riferimento rapido per la sintassi e le caratteristiche del linguaggio, consulta il nostro Python Cheatsheet per un’overview completa dei fondamenti di Python.

Esempio: Funzioni utili per le stringhe

Passo 1: Scrivi il test prima (Rosso)

Crea test_string_utils.py:

import pytest
from string_utils import reverse_string, is_palindrome, count_vowels

def test_reverse_string():
    assert reverse_string("hello") == "olleh"
    assert reverse_string("") == ""
    assert reverse_string("a") == "a"

def test_is_palindrome():
    assert is_palindrome("racecar") == True
    assert is_palindrome("hello") == False
    assert is_palindrome("") == True
    assert is_palindrome("A man a plan a canal Panama") == True

def test_count_vowels():
    assert count_vowels("hello") == 2
    assert count_vowels("HELLO") == 2
    assert count_vowels("xyz") == 0
    assert count_vowels("") == 0

Passo 2: Scrivi il codice minimo per passare (Verde)

Crea string_utils.py:

def reverse_string(s: str) -> str:
    """Inverti una stringa"""
    return s[::-1]

def is_palindrome(s: str) -> bool:
    """Verifica se una stringa è un palindromo (ignorando maiuscole e spazi)"""
    cleaned = ''.join(s.lower().split())
    return cleaned == cleaned[::-1]

def count_vowels(s: str) -> int:
    """Conta le vocali in una stringa"""
    return sum(1 for char in s.lower() if char in 'aeiou')

Passo 3: Esegui i test

pytest test_string_utils.py -v

Output:

test_string_utils.py::test_reverse_string PASSED
test_string_utils.py::test_is_palindrome PASSED
test_string_utils.py::test_count_vowels PASSED

Tecniche avanzate di test

Utilizzo di fixture per la configurazione dei test

Le fixture forniscono una configurazione e un ripulimento dei test riutilizzabili:

import pytest
from database import Database

@pytest.fixture
def db():
    """Crea un database di test"""
    database = Database(":memory:")
    database.create_tables()
    yield database  # Fornisci al test
    database.close()  # Pulizia dopo il test

@pytest.fixture
def sample_users(db):
    """Aggiungi utenti di esempio al database"""
    db.add_user("Alice", "alice@example.com")
    db.add_user("Bob", "bob@example.com")
    return db

def test_get_user(sample_users):
    user = sample_users.get_user_by_email("alice@example.com")
    assert user.name == "Alice"

def test_user_count(sample_users):
    assert sample_users.count_users() == 2

Scopi delle fixture

Controlla la durata delle fixture con gli scopi:

@pytest.fixture(scope="function")  # Default: eseguito per ogni test
def func_fixture():
    return create_resource()

@pytest.fixture(scope="class")  # Una volta per classe di test
def class_fixture():
    return create_resource()

@pytest.fixture(scope="module")  # Una volta per modulo
def module_fixture():
    return create_resource()

@pytest.fixture(scope="session")  # Una volta per sessione di test
def session_fixture():
    return create_expensive_resource()

Mocking delle dipendenze esterne

Utilizza il mocking per isolare il codice dalle dipendenze esterne:

from unittest.mock import Mock, patch, MagicMock
import requests

class WeatherService:
    def get_temperature(self, city):
        response = requests.get(f"https://api.weather.com/{city}")
        return response.json()["temp"]

# Test con mock
def test_get_temperature():
    service = WeatherService()
    
    # Mock della funzione requests.get
    with patch('requests.get') as mock_get:
        # Configura la risposta del mock
        mock_response = Mock()
        mock_response.json.return_value = {"temp": 72}
        mock_get.return_value = mock_response
        
        # Test
        temp = service.get_temperature("Boston")
        assert temp == 72
        
        # Verifica la chiamata
        mock_get.assert_called_once_with("https://api.weather.com/Boston")

Utilizzo del plugin pytest-mock

pytest-mock fornisce una sintassi più pulita:

def test_get_temperature(mocker):
    service = WeatherService()
    
    # Mock con pytest-mock
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"temp": 72}
    mocker.patch('requests.get', return_value=mock_response)
    
    temp = service.get_temperature("Boston")
    assert temp == 72

Test parametrizzati

Testa efficacemente diversi scenari:

import pytest

@pytest.mark.parametrize("input,expected", [
    ("", True),
    ("a", True),
    ("ab", False),
    ("aba", True),
    ("racecar", True),
    ("hello", False),
])
def test_is_palindrome_parametrized(input, expected):
    assert is_palindrome(input) == expected

@pytest.mark.parametrize("number,is_even", [
    (0, True),
    (1, False),
    (2, True),
    (-1, False),
    (-2, True),
])
def test_is_even(number, is_even):
    assert (number % 2 == 0) == is_even

Test delle eccezioni e gestione degli errori

import pytest

def divide(a, b):
    if b == 0:
        raise ValueError("Non si può dividere per zero")
    return a / b

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Non si può dividere per zero"):
        divide(10, 0)

def test_divide_by_zero_with_message():
    with pytest.raises(ValueError) as exc_info:
        divide(10, 0)
    assert "zero" in str(exc_info.value).lower()

# Test che non solleva alcuna eccezione
def test_divide_success():
    result = divide(10, 2)
    assert result == 5.0

Test del codice asincrono

Il test del codice asincrono è essenziale per le applicazioni Python moderne, soprattutto quando si lavora con API, database o servizi AI. Ecco come testare le funzioni asincrone:

import pytest
import asyncio

async def fetch_data(url):
    """Funzione asincrona per recuperare dati"""
    await asyncio.sleep(0.1)  # Simula una chiamata API
    return {"status": "success"}

@pytest.mark.asyncio
async def test_fetch_data():
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "success"

@pytest.mark.asyncio
async def test_fetch_data_with_mock(mocker):
    # Mock della funzione asincrona
    mock_fetch = mocker.AsyncMock(return_value={"status": "mocked"})
    mocker.patch('module.fetch_data', mock_fetch)
    
    result = await fetch_data("https://api.example.com")
    assert result["status"] == "mocked"

Per esempi pratici di test del codice asincrono con servizi AI, vedi la nostra guida su integrare Ollama con Python, che include strategie di test per le interazioni con i modelli LLM.

Copertura del codice

Misura quanto del tuo codice è testato:

Utilizzo di pytest-cov

# Esegui i test con la copertura
pytest --cov=myproject tests/

# Genera un rapporto HTML
pytest --cov=myproject --cov-report=html tests/

# Mostra le righe mancanti
pytest --cov=myproject --cov-report=term-missing tests/

Configurazione della copertura

Crea .coveragerc:

[run]
source = myproject
omit =
    */tests/*
    */venv/*
    */__pycache__/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:
    @abstractmethod

Buone pratiche per la copertura

  1. Mirare a una copertura del 80%+ per i percorsi critici del codice
  2. Non fissarsi sul 100% - concentrati sui test significativi
  3. Testa i casi limite non solo i percorsi felici
  4. Escludi il boilerplate dai rapporti di copertura
  5. Utilizza la copertura come guida non come obiettivo

Organizzazione dei test e struttura del progetto

Struttura consigliata

myproject/
├── myproject/
│   ├── __init__.py
│   ├── module1.py
│   ├── module2.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # Fixtures condivisi
│   ├── test_module1.py
│   ├── test_module2.py
│   ├── test_utils.py
│   └── integration/
│       ├── __init__.py
│       └── test_integration.py
├── pytest.ini
├── requirements.txt
└── requirements-dev.txt

conftest.py per i fixtures condivisi

# tests/conftest.py
import pytest
from myproject.database import Database

@pytest.fixture(scope="session")
def test_db():
    """Database di test a livello di sessione"""
    db = Database(":memory:")
    db.create_schema()
    yield db
    db.close()

@pytest.fixture
def clean_db(test_db):
    """Database pulito per ogni test"""
    test_db.clear_all_tables()
    return test_db

Configurazione di pytest.ini

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
    -v
    --strict-markers
    --cov=myproject
    --cov-report=term-missing
    --cov-report=html
markers =
    slow: segna i test come lenti
    integration: segna i test come test di integrazione
    unit: segna i test come test unitari

Migliori pratiche per i test unitari

1. Segui il modello AAA

Arrange-Act-Assert rende i test chiari:

def test_user_creation():
    # Arrange
    username = "john_doe"
    email = "john@example.com"
    
    # Act
    user = User(username, email)
    
    # Assert
    assert user.username == username
    assert user.email == email
    assert user.is_active == True

2. Un’asserzione per test (guida, non regola)

# Buono: Test focalizzato
def test_user_username():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"

def test_user_email():
    user = User("john_doe", "john@example.com")
    assert user.email == "john@example.com"

# Accettabile: Asserzioni correlate
def test_user_creation():
    user = User("john_doe", "john@example.com")
    assert user.username == "john_doe"
    assert user.email == "john@example.com"
    assert isinstance(user.created_at, datetime)

3. Utilizza nomi descrittivi per i test

# Cattivo
def test_user():
    pass

# Buono
def test_user_creation_with_valid_data():
    pass

def test_user_creation_fails_with_invalid_email():
    pass

def test_user_password_is_hashed_after_setting():
    pass

4. Testa i casi limite e i bordi

def test_age_validation():
    # Casi validi
    assert validate_age(0) == True
    assert validate_age(18) == True
    assert validate_age(120) == True
    
    # Casi limite
    assert validate_age(-1) == False
    assert validate_age(121) == False
    
    # Casi limite
    with pytest.raises(TypeError):
        validate_age("18")
    with pytest.raises(TypeError):
        validate_age(None)

5. Mantieni i test indipendenti

# Cattivo: I test dipendono dall'ordine
counter = 0

def test_increment():
    global counter
    counter += 1
    assert counter == 1

def test_increment_again():  # Fallisce se eseguito da solo
    global counter
    counter += 1
    assert counter == 2

# Buono: I test sono indipendenti
def test_increment():
    counter = Counter()
    counter.increment()
    assert counter.value == 1

def test_increment_multiple_times():
    counter = Counter()
    counter.increment()
    counter.increment()
    assert counter.value == 2

6. Non testare i dettagli dell’implementazione

# Cattivo: Testa l'implementazione
def test_sort_uses_quicksort():
    sorter = Sorter()
    assert sorter.algorithm == "quicksort"

# Buono: Testa il comportamento
def test_sort_returns_sorted_list():
    sorter = Sorter()
    result = sorter.sort([3, 1, 2])
    assert result == [1, 2, 3]

7. Testa i casi d’uso reali

Quando si testano librerie che elaborano o trasformano dati, concentrati sui casi d’uso reali. Ad esempio, se stai lavorando con lo scraping web o la conversione del contenuto, consulta la nostra guida su convertire HTML in Markdown con Python, che include strategie di test e confronti tra le librerie di conversione.

Integrazione con Continuous Integration

Esempio di GitHub Actions

# .github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.9, 3.10, 3.11, 3.12]
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install -r requirements-dev.txt        
    
    - name: Run tests
      run: |
        pytest --cov=myproject --cov-report=xml        
    
    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

Test di funzioni serverless

Quando si testano funzioni AWS Lambda o applicazioni serverless, considera strategie di test di integrazione insieme ai test unitari. La nostra guida su costruire una funzione AWS Lambda a doppio modo con Python e Terraform copre le strategie di test per le applicazioni Python serverless, incluso come testare i gestori Lambda, i consumer SQS e le integrazioni API Gateway.

Checklist delle migliori pratiche per i test

  • Scrivi i test prima o insieme al codice (TDD)
  • Mantieni i test veloci (< 1 secondo per test)
  • Rendi i test indipendenti e isolati
  • Utilizza nomi descrittivi per i test
  • Segui il modello AAA (Arrange-Act-Assert)
  • Testa i casi limite e le condizioni di errore
  • Mocka le dipendenze esterne
  • Mirare a una copertura del codice del 80%+
  • Esegui i test nel pipeline CI/CD
  • Rivedi e ristruttura regolarmente i test
  • Documenta gli scenari di test complessi
  • Utilizza le fixture per le configurazioni comuni
  • Parametrizza i test simili
  • Mantieni i test semplici e leggibili

Pattern comuni di test

Testare le classi

class TestUser:
    @pytest.fixture
    def user(self):
        return User("john_doe", "john@example.com")
    
    def test_username(self, user):
        assert user.username == "john_doe"
    
    def test_email(self, user):
        assert user.email == "john@example.com"
    
    def test_full_name(self, user):
        user.first_name = "John"
        user.last_name = "Doe"
        assert user.full_name() == "John Doe"

Testare con file temporanei

import pytest
from pathlib import Path

@pytest.fixture
def temp_file(tmp_path):
    """Crea un file temporaneo"""
    file_path = tmp_path / "test_file.txt"
    file_path.write_text("test content")
    return file_path

def test_read_file(temp_file):
    content = temp_file.read_text()
    assert content == "test content"

Testare la generazione di file

Quando si testa il codice che genera file (come PDF, immagini o documenti), utilizza directory temporanee e verifica le proprietà dei file:

@pytest.fixture
def temp_output_dir(tmp_path):
    """Fornisce una directory temporanea di output"""
    output_dir = tmp_path / "output"
    output_dir.mkdir()
    return output_dir

def test_pdf_generation(temp_output_dir):
    pdf_path = temp_output_dir / "output.pdf"
    generate_pdf(pdf_path, content="Test")
    
    assert pdf_path.exists()
    assert pdf_path.stat().st_size > 0

Per esempi completi di test per la generazione di PDF, vedi la nostra guida su generare PDF in Python, che copre le strategie di test per varie librerie di generazione di PDF.

Testare con Monkeypatch

def test_environment_variable(monkeypatch):
    monkeypatch.setenv("API_KEY", "test_key_123")
    assert os.getenv("API_KEY") == "test_key_123"

def test_module_attribute(monkeypatch):
    monkeypatch.setattr("module.CONSTANT", 42)
    assert module.CONSTANT == 42

Risorse correlate

Fondamenti di Python e migliori pratiche

  • Python Cheatsheet - Riferimento completo per la sintassi Python, le strutture dati e i pattern comuni

Test di casi specifici di Python

Test per serverless e cloud


L’unità di test è una competenza essenziale per gli sviluppatori Python. Che tu scelga unittest o pytest, l’importante è scrivere i test in modo coerente, mantenerli manutenibili e integrarli nel tuo flusso di lavoro di sviluppo. Inizia con test semplici, adotta gradualmente tecniche avanzate come il mocking e le fixture, e utilizza gli strumenti di copertura per identificare il codice non testato.