Corso
I test sono un argomento enorme nello sviluppo software. Prima che un prodotto software arrivi nelle mani di un utente finale, è probabile che sia passato attraverso vari test come test di integrazione, test di sistema e test di accettazione. L'idea alla base di test così rigorosi è garantire che il comportamento dell'applicazione funzioni come previsto dal punto di vista dell'utente finale. Questo approccio ai test è noto come behavior-driven development (BDD).
Più recentemente, tra gli sviluppatori è cresciuto notevolmente l'interesse per il test-driven development (TDD). Approfondirlo in dettaglio sarebbe complesso per questo articolo, ma l'idea generale è che il processo tradizionale di sviluppo e testing viene capovolto: prima scrivi i test unitari e poi implementi le modifiche al codice finché i test non passano.
In questo articolo ci concentreremo sui test unitari e, in particolare, su come realizzarli usando un popolare framework di testing per Python chiamato Pytest.
Cosa sono i test unitari?
I test unitari sono una forma di test automatizzati: significa semplicemente che il piano di test viene eseguito da uno script anziché manualmente da una persona. Rappresentano il primo livello di testing del software e sono tipicamente scritti sotto forma di funzioni che validano il comportamento di varie funzionalità all'interno di un programma software.

I livelli del testing del software
L'idea alla base di questi test è consentire agli sviluppatori di isolare la più piccola unità di codice che abbia senso logico e verificare che si comporti come previsto. In altre parole, i test unitari convalidano che il singolo componente di un programma software funzioni come inteso dagli sviluppatori.
Idealmente, questi test dovrebbero essere piuttosto piccoli: più sono piccoli, meglio è. Un motivo per costruire test più piccoli è l'efficienza: testare unità più piccole permette al codice di test di eseguire molto più velocemente. Un altro motivo è che testare componenti più minuti ti dà maggiore visibilità su come il codice granulare si comporta quando viene integrato.
Perché abbiamo bisogno dei test unitari?
La motivazione generale per cui è essenziale eseguire test unitari è che gli sviluppatori devono assicurarsi che il codice che scrivono rispetti gli standard di qualità prima di consentirne l'ingresso in produzione. Tuttavia, diversi altri fattori contribuiscono alla necessità dei test unitari. Vediamo più da vicino alcune di queste ragioni.
Preserva le risorse
Eseguire test unitari aiuta gli sviluppatori a intercettare bug del codice durante la fase di costruzione del software, impedendo che si insinuino più a fondo nel ciclo di sviluppo. Questo preserva risorse, perché gli sviluppatori non dovranno sostenere il costo di correggere i bug nelle fasi successive dello sviluppo — significa anche che gli utenti finali avranno meno probabilità di imbattersi in codice difettoso.
Documentazione extra
Un'altra ottima ragione per condurre test unitari è che fungono da ulteriore livello di documentazione viva per il tuo prodotto software. Gli sviluppatori possono semplicemente fare riferimento ai test unitari per una comprensione olistica del sistema nel suo complesso, poiché dettagliano come dovrebbero comportarsi i componenti più piccoli.
Una dose di fiducia
È estremamente semplice commettere errori sottili nel codice mentre si implementa qualche funzionalità. Tuttavia, la maggior parte degli sviluppatori concorderebbe che è molto meglio individuare i punti di rottura all'interno di una codebase prima che venga messa in produzione: i test unitari offrono questa opportunità.
Si può affermare con ragione che "il codice coperto da test unitari può essere considerato più affidabile del codice che non lo è". Futuri malfunzionamenti del codice possono essere scoperti molto più velocemente rispetto al codice senza copertura di test, risparmiando tempo e denaro. Gli sviluppatori beneficiano anche di documentazione aggiuntiva per comprendere più rapidamente la codebase, e c'è l'ulteriore sicurezza di sapere che, se commettono un errore nel loro codice, verrà intercettato dai test unitari piuttosto che da un utente finale.
Framework di testing per Python
Python è cresciuto enormemente in popolarità negli anni. Come parte di questa crescita, è aumentato anche il numero di framework di testing, dando come risultato una ricca dotazione di strumenti per aiutarti a testare il tuo codice Python. Entrare nei dettagli di ciascuno strumento va oltre lo scopo di questo articolo, ma toccheremo alcuni dei framework di testing Python più comuni disponibili.
unittest
Unittest è un framework integrato in Python per i test unitari. È stato ispirato da un framework di test unitari chiamato JUnit del linguaggio Java. Poiché è fornito di serie con il linguaggio Python, non ci sono moduli extra da installare e la maggior parte degli sviluppatori lo usa per iniziare a imparare il testing.
Pytest
Pytest è probabilmente il framework di testing Python più utilizzato in circolazione — significa che ha una vasta community pronta a supportarti quando resti bloccato. È un framework open source che consente agli sviluppatori di scrivere suite di test semplici e compatte, supportando test unitari, funzionali e API.
doctest
Il framework doctest unisce due componenti fondamentali dell'ingegneria del software: documentazione e testing. Questa funzionalità garantisce che tutti i programmi software siano ben documentati e testati per assicurare che funzionino come dovrebbero. doctest fa parte della libreria standard di Python ed è piuttosto semplice da imparare.
nose2
Nose2, il successore dell'ecosistema nose, è essenzialmente unittest con plugin. Spesso ci si riferisce a nose2 come a "unit test estesi" o "unit test con plugin" per via del suo stretto legame con il framework di test unitari integrato in Python. Poiché è praticamente un'estensione di unittest, nose2 è estremamente facile da adottare per chi è già familiare con unittest.
Testify
Testify, un framework Python per test unitari, di integrazione e di sistema, è conosciuto come il framework progettato per sostituire unittest e nose. È ricco di plugin e ha una curva di apprendimento piuttosto dolce se conosci già unittest.
Hypothesis
Hypothesis consente agli sviluppatori di creare test unitari semplici da scrivere ma potenti in esecuzione. Poiché il framework è costruito per supportare progetti di data science, aiuta a trovare edge case non così evidenti mentre crei i test generando esempi di input che rispettano specifiche proprietà da te definite.
Per il nostro tutorial useremo pytest. Dai un'occhiata alla prossima sezione per capire perché potresti scegliere Pytest rispetto agli altri che abbiamo elencato.
Perché usare Pytest?
Oltre alla sua vasta community, pytest ha diverse caratteristiche che lo rendono uno dei migliori strumenti per eseguire la tua suite di test automatizzati in Python. La filosofia e le funzionalità di Pytest sono pensate per rendere il testing del software un'esperienza migliore per gli sviluppatori. Uno dei modi in cui i creatori di Pytest hanno raggiunto questo obiettivo è riducendo in modo significativo la quantità di codice necessaria per svolgere attività comuni e rendendo possibile eseguire attività avanzate con comandi estesi e plugin.
Altri motivi per usare Pytest includono i seguenti:
Facile da imparare
Pytest è estremamente facile da imparare: se capisci come funziona la keyword assert di Python, sei già a buon punto per padroneggiare il framework. I test con pytest sono funzioni Python con "test_" come prefisso o "_test" come suffisso nel nome della funzione — anche se puoi usare una classe per raggruppare più test. In generale, la curva di apprendimento per pytest è molto più dolce rispetto a unittest, perché non devi imparare nuove astrazioni.
Filtraggio dei test
Potresti non voler eseguire tutti i test a ogni esecuzione — può succedere man mano che la tua suite cresce. A volte potresti voler isolare alcuni test su una nuova funzionalità per ottenere un feedback rapido durante lo sviluppo, per poi eseguire l'intera suite quando sei sicuro che tutto funzioni come previsto. Pytest offre tre modi per isolare i test: 1) filtraggio basato sul nome, che dice a pytest di eseguire solo i test i cui nomi corrispondono al pattern fornito; 2) ambito per directory, impostazione predefinita che indica a pytest di eseguire solo i test nella directory corrente o nelle sue sottodirectory; e 3) categorizzazione dei test, che ti consente di definire categorie di test che pytest dovrebbe includere o escludere.
Parametrizzazione
Pytest ha un decorator integrato chiamato parametrize che abilita la parametrizzazione degli argomenti per una funzione di test. Quindi, se le funzioni che stai testando elaborano dati o eseguono una trasformazione generica, non devi scrivere molti test simili. Approfondiremo la parametrizzazione più avanti nell'articolo.
Ci fermiamo qui, ma l'elenco dei motivi per cui pytest è un'ottima opzione per la tua suite di test automatizzati è lungo.
Pytest vs unittest
Nonostante tutti i motivi che abbiamo elencato sopra, si potrebbe ancora discutere sull'uso di pytest per il semplice fatto che è un framework di terze parti — "che senso ha installare un framework se ce n'è già uno integrato?" È un'osservazione valida, ma per tutelarci in questa disputa, ti forniremo alcuni spunti da considerare.
Nota: se sei già convinto da pytest, passa alla sezione successiva, dove vediamo come usare il framework.
Meno boilerplate
Unittest richiede agli sviluppatori di creare classi derivate dal modulo TestCase e poi definire i casi di test come metodi nella classe.
"""
An example test case with unittest.
See: https://docs.python.org/3/library/unittest.html
"""
import unittest
class TestStringMethods(unittest.TestCase):
def test_upper(self):
self.assertEqual('foo'.upper(), 'FOO')
def test_isupper(self):
self.assertTrue('FOO'.isupper())
self.assertFalse('Foo'.isupper())
def test_split(self):
s = 'hello world'
self.assertEqual(s.split(), ['hello', 'world'])
# check that s.split fails when the separator is not a string
with self.assertRaises(TypeError):
s.split(2)
Pytest, invece, richiede soltanto di definire una funzione con il prefisso "test_" e usare le condizioni assert al suo interno.
"""
An example test case with pytest.
See: https://docs.pytest.org/en/6.2.x/index.html
"""
# content of test_sample.py
def inc(x):
return x + 1
def test_answer():
assert inc(3) == 5
Nota la differenza nella quantità di codice richiesto; unittest ha una notevole quantità di codice boilerplate, che funge da requisito minimo per qualsiasi test tu voglia eseguire. Ciò significa che è molto probabile che finirai per scrivere lo stesso codice più e più volte. Pytest, invece, ha ricche funzionalità integrate che semplificano questo flusso di lavoro riducendo il codice necessario per scrivere i casi di test.
Output
Gli output forniti da ciascun framework sono estremamente diversi. Ecco un esempio di esecuzione con pytest:
"""
See: https://docs.pytest.org/en/6.2.x/index.html
"""
$ pytest
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-6.x.y, py-1.x.y, pluggy-1.x.y
cachedir: $PYTHON_PREFIX/.pytest_cache
rootdir: $REGENDOC_TMPDIR
collected 1 item
test_sample.py F [100%]
================================= FAILURES =================================
_______________________________ test_answer ________________________________
def test_answer():
> assert inc(3) == 5
E assert 4 == 5
E + where 4 = inc(3)
test_sample.py:6: AssertionError
========================= short test summary info ==========================
FAILED test_sample.py::test_answer - assert 4 == 5
============================ 1 failed in 0.12s =============================
Il test sopra è fallito, ma nota quanto è dettagliata la spiegazione del fallimento. Questo rende più semplice per gli sviluppatori individuare dove si trovano i bug nel loro codice, il che è utilissimo in fase di debug. Come bonus, c'è anche un report di stato complessivo per la suite di test, che indica il numero di test falliti e la durata.
Vediamo ora un esempio di test fallito con unittest.
import unittest
def square(n):
return n*n
def cube(n):
return n*n*n
class TestCase(unittest.TestCase):
def test_square(self):
self.asserEquals(square(4), 16)
def test_cube(self):
self.assertEquals(cube(4), 16)
Quando eseguiamo lo script, otteniamo il seguente output:
---------------------------------------------------------------
Ran 2 tests in 0.001s
FAILED (errors=1)
L'output sopra ci dice che sono stati eseguiti due test in 0,001 s e uno è fallito, ma non molto altro. In definitiva, pytest fornisce un feedback molto più informativo, utile quando è il momento di fare debug.
Detto questo, sia pytest che unittest sono ottimi strumenti per il testing automatizzato in Python. Molti sviluppatori Python potrebbero propendere per pytest rispetto ai suoi omologhi per la sua compattezza ed efficienza. È anche estremamente facile da adottare e offre diverse funzionalità per costruire una suite di test efficace.
Ora passiamo alla parte principale dell'articolo. Abbiamo discusso cos'è il testing unitario e perché pytest è un ottimo strumento per il testing automatizzato in Python. Vediamo ora come usare lo strumento.
Tutorial su Pytest
Vediamo come funziona questo framework di testing per Python di cui abbiamo parlato.
Il primo passo è installare il pacchetto, operazione che può essere fatta con un semplice comando pip.
Nota: i creatori di pytest raccomandano di usare venv per lo sviluppo e pip per installare la tua applicazione, le dipendenze e pytest stesso.
pip install -U pytest
Successivamente, verifica che il framework sia stato installato con il seguente comando:
>>>> pytest --version
pytest 7.1.2
È tutto installato. Ora sei pronto per iniziare a eseguire alcuni test.
Creare un test semplice
Creare un test è semplice con Pytest. Per dimostrare questa funzionalità, abbiamo creato uno script chiamato calcualte_age.py. Questo script ha una sola funzione, get_age, responsabile del calcolo dell'età di un utente data la sua data di nascita.
import datetime
def get_age(yyyy:int, mm:int, dd:int) -> int:
dob = datetime.date(yyyy, mm, dd)
today = datetime.date.today()
age = round((today - dob).days / 365.25)
return age
Pytest eseguirà tutti i file Python che hanno il prefisso test_ nel nome o il suffisso _test. Più nello specifico, pytest segue le seguenti convenzioni per la scoperta dei test [fonte: documentazione]:
- Se non vengono specificati argomenti, la raccolta di pytest inizierà in testpaths se configurati: i testpaths sono un elenco di directory che pytest cercherà quando non vengono fornite directory, file o ID di test specifici.
- Pytest ricorsivamente esplorerà le directory a meno che tu non abbia indicato di non farlo impostando norecursedirs; sta cercando file che iniziano con test_*.py o terminano con *_test.py
- In tali file, pytest raccoglierà gli elementi di test nel seguente ordine:
- Funzioni o metodi di test con prefisso, fuori da una classe
- Funzioni o metodi di test con prefisso all'interno di classi di test con prefisso Test che non hanno un metodo __init__.
Non abbiamo specificato argomenti, ma abbiamo creato un altro script nella stessa directory chiamato test_calculate_age.py: quindi, quando le directory vengono esplorate ricorsivamente, il test verrà individuato. In questo script, abbiamo un singolo test, test_get_age, per validare che la nostra funzione stia funzionando correttamente.
Nota: potresti decidere di mettere i tuoi test in una directory separata al di fuori dell'applicazione, il che è una buona idea se hai diversi test funzionali o vuoi tenere separati il codice dei test e il codice dell'applicazione per altri motivi.
from calculate_age import get_age
def test_get_age():
# Given.
yyyy, mm, dd = map(int, "1996/07/11".split(""))
# When.
age = get_age(yyyy, mm, dd)
# Then.
assert age == 26
Per eseguire il test, lancia il seguente comando dal prompt dei comandi:
py -m pytest

Output di un'esecuzione pytest riuscita.
E questo è quanto.
Ma cosa succede se dobbiamo fornire dei dati affinché i nostri test passino? Dai il benvenuto alle fixture di pytest.
Fixture di Pytest
Le preziose fixture di Pytest permettono agli sviluppatori di alimentare i test con dei dati. Sono essenzialmente funzioni che vengono eseguite prima di ogni funzione di test per gestire lo stato dei nostri test. Ad esempio, se abbiamo diversi test che usano tutti gli stessi dati, possiamo usare una fixture per recuperare i dati ripetuti tramite un'unica funzione.
import pytest
from sklearn.model_selection import train_test_split
from fraud_detection_model.config.core import config
from fraud_detection_model.processing.data_manager import (
load_datasets,
load_interim_data
)
@pytest.fixture(scope="session")
def pipeline_inputs():
# import the training dataset
dataset = load_datasets(transaction=config.app_config.train_transaction, identity=config.app_config.train_identity, )
# divide train and test
X_train, X_test, y_train, y_test = train_test_split(
dataset[config.model_config.all_features], dataset[config.model_config.target],
test_size=config.model_config.test_size,
stratify=dataset[config.model_config.target],
random_state=config.model_config.random_state)
return X_train, X_test, y_train, y_test
Nel codice sopra, abbiamo creato una fixture usando il decorator pytest.fixture. Nota che lo scope è impostato su "session" per informare pytest che vogliamo che la fixture venga distrutta alla fine della sessione di test.
Nota: le nostre fixture sono archiviate in conftest.py.
Il codice usa una funzione importata da un altro modulo per caricare in memoria due file csv e fonderli in un unico dataset. Successivamente, il dataset viene suddiviso in set di training e di test e restituito dalla funzione.
Per usare questa fixture nei nostri test, dobbiamo passarla come parametro alla nostra funzione di test. In questo esempio, usiamo la nostra fixture nello script di test test_pipeline.py come segue:
import pandas as pd
from pandas.api.types import is_categorical_dtype, is_object_dtype
from fraud_detection_model import pipeline
from fraud_detection_model.config.core import configfrom fraud_detection_model.processing.validation import validate_inputs
def test_pipeline_most_frequent_imputer(pipeline_inputs):
# Given
X_train, _, _, _ = pipeline_inputs assert all( x in X_train.loc[:, X_train.isnull().any()].columns for x in config.model_config.impute_most_freq_cols )
# When
X_transformed = pipeline.fraud_detection_pipe[:1].fit_transform(X_train[:50])
# Then
assert all( x not in X_transformed.loc[:, X_transformed.isnull().any()].columns for x in config.model_config.impute_most_freq_cols )
def test_pipeline_aggregate_categorical(pipeline_inputs):
# Given
X_train, _, _, _ = pipeline_inputs
assert X_train["R_emaildomain"].nunique() == 60
# When
X_transformed = pipeline.fraud_detection_pipe[:2].fit_transform(X_train[:50])
# Then
assert X_transformed["R_emaildomain"].nunique() == 2
Non preoccuparti troppo di cosa faccia il codice. La cosa più importante che vogliamo evidenziare è come abbiamo ridotto significativamente la necessità di scrivere codice ridondante grazie a una fixture che passiamo come parametro per portare i dati nei nostri test.
Tuttavia, ci sono situazioni in cui le fixture possono essere eccessive. Ad esempio, se i dati passati ai test devono essere nuovamente elaborati in ogni caso di test — è praticamente l'equivalente di riempire il codice di vari oggetti banali. Detto questo, le fixture probabilmente giocheranno un ruolo fondamentale nella tua suite di test, ma discernere quando usarle o evitarle richiederà pratica e riflessione.
Pytest parametrize
Le fixture sono ottime quando hai diversi test con gli stessi input. E se vuoi testare una singola funzione con piccole variazioni degli input? Una soluzione è scrivere diversi test con casi differenti.
def test_eval_addition():
assert eval("2 + 2") == 4
def test_eval_subtraction():
assert eval("2 - 2") == 0
def test_eval_multiplication():
assert eval("2 * 2") == 4
def test_eval_division():
assert eval("2 / 2") == 1.0
Anche se questa soluzione funziona, non è la più efficiente: per cominciare, c'è molto boilerplate. Una soluzione migliore è usare il decorator pytest.mark.parametrize() per abilitare la parametrizzazione degli argomenti per una funzione di test. Questo ci permetterà di definire un'unica definizione di test, e poi pytest eseguirà per noi le varie combinazioni di parametri specificate.
Ecco come riscriveremmo il codice sopra usando la parametrizzazione:
import pytest
@pytest.mark.parametrize("test_input, expected_output", [("2+2", 4), ("2-2", 0), ("2*2", 4), ("2/2", 1.0)])
def test_eval(test_input, expected_output):
assert eval(test_input) == expected_output
Il decorator @parametrize definisce quattro diversi input di test e i relativi valori attesi per l'esecuzione della funzione test_eval — significa che la funzione verrà eseguita quattro volte, usando ciascuna coppia a turno.

L'output di pytest per il test parametrizzato
In questo articolo, abbiamo trattato i seguenti argomenti:
- cosa sono i test unitari
- perché servono i test unitari
- diversi framework di testing in Python
- perché pytest è così utile
- come usare pytest e due delle sue funzionalità chiave (fixture e parametrizzazione)
Ora sai abbastanza per iniziare a scrivere i tuoi test con il framework pytest. Ti incoraggiamo a farlo, così tutto quello che hai imparato in questo articolo ti rimarrà. Dovresti anche dare un'occhiata al nostro corso su unit testing per la data science in Python per una spiegazione più dettagliata con esempi.

