Pay attention to zeros. If there is a zero, someone will divide by it.

Cem Kaner

Una buona copertura di test per il codice che scriviamo è una componente essenziale, sia per l’affidabilità del codice che per la tranquillità dello sviluppatore.

Ci sono strumenti (ad esempio Nose) per misurare il coverage del codice sorgente in base alle righe attraversate durante l’esecuzione dei test. Una ottima copertura delle righe di codice, però, non comporta necessariamente una copertura altrettanto buona delle funzionalità del codice: uno stesso statement può funzionare correttamente con determinati dati e fallire con altri, altrettanto legittimi. Alcuni valori, inoltre, si prestano più di altri a generare errori: sono edge case i valori limite di un intervallo, l’indice dell’ultima iterazione di un ciclo, caratteri in codifiche impreviste, lo zero, eccetera.

Per avere una copertura efficace anche di questo tipo di errori, ci si può trovare facilmente a replicare nei test interi blocchi di codice, variandone soltanto minime parti.

In questo articolo, vedremo alcuni degli strumenti offerti dall’ecosistema Python per gestire in maniera elegante (e “DRY”) questa necessità.

py.test parametrize

Pytest è una valida alternativa a Unittest a distanza di un pip install. Le due novità principali introdotte in Pytest sono le fixture e il decoratore parametrize. Le prime permettono di gestire il setup di un test in maniera più granulare rispetto al classico metodo setUp().
In questo blogpost però a noi interessa soprattutto il decoratore parametrize, che consente di fare un passo di astrazione nella scrittura dei test-case, dividendo la logica del test dai dati che gli vogliamo dare in ingresso. Potremo quindi verificare il corretto funzionamento del codice con diversi edge case, pur evitando la duplicazione della logica.

import pytest

@pytest.mark.parametrize('value_A,value_B', [
    # ogni elemento di questa lista fornirà valori per gli argomenti
    # "value_A" e "value_B" del test e genererà un test-case a sé stante.
     ('first case', 1),
     ('second case', 2),
])

def test_func(value_A, value_B):
    result = func(value_A, value_B)
    # assertions

Nell’esempio, test_func verrà eseguita due volte, la prima con value_A = 'first case', value_B = 1 e la seconda con value_A = 'second case', value_B = 1.

Durante l’esecuzione dei test, i diversi parametri verranno considerati come test-case indipendenti e, in caso di fallimento, un identificativo che riporta i dati forniti permette allo sviluppatore di risalire rapidamente al caso problematico.

test_pytest.py .F

==================================== FAILURES ===================================

____________________________ test_func[second case-2] ___________________________

...

Faker

Faker fornisce metodi per creare al volo dati verosimili per i nostri test.

from faker import Faker
fake = Faker()
fake.name()
# Wendy Lopez'

# Due diverse chiamate a `.name()` daranno diversi risultati,
# generati in maniera random

fake.name()
# 'Nancy Oconnell'

fake.address()
# '88573 Hannah Track\nNew Rebeccatown, FM 61000'

fake.email()
# '[email protected]'

I dati vengono generati da Provider inclusi nella libreria (nella documentazione una lista completa), ma è possibile anche farne di personalizzati.

from faker.generator import random
from faker.providers import BaseProvider

class UserProvider(BaseProvider):
    """Fake data provider per utenti."""

    users = (
        # alcuni dati validi per la nostra applicazione
        {
            'name': 'Foo Bar',
            'email': '[email protected]',
            'description': fake.text(),
        },
        {
            'name': 'Bar Foo',
            'email': '[email protected]',
            'description': fake.text(),
        },
    )

    @classmethod
    def user(cls):
        """Seleziona in modo random un utente."""
        return random.choice(cls.users)

utilizzabili poi aggiungendoli all’oggetto globale alla base della libreria:

from faker import Faker
fake = Faker()
fake.add_provider(UserProvider)
fake.user()
# {
#    'description': 'Cum qui vitae debitis. Molestiae eum totam eos inventore odio.',
#    'email': '[email protected]',
#    'name': 'Bar Foo'
# }

Per capire alcuni casi in cui Faker può tornare utile, supponiamo ad esempio che si voglia realizzare dei test per verificare la corretta creazione di utenti su un database.

In questo caso, una possibilità sarebbe ricreare il database ad ogni esecuzione della test suite. Tuttavia creare un database è solitamente un’operazione che richiede del tempo, per cui sarebbe preferibile crearlo solo la prima volta magari utilizzando un’apposita opzione a linea di comando. Ma con dati espressi esplicitamente nel codice (“hardcodati”) basterebbe avere qualche genere di vincolo di integrità sugli utenti (ad esempio, l’email univoca) perché il nostro test fallisca se eseguito due volte sullo stesso database. Con Faker possiamo evitare facilmente questi conflitti, perché al posto del dato esplicito abbiamo una chiamata a funzione che restituisce un dato ogni volta diverso.

In questo caso rinunciamo però alla riproducibilità del test: essendo i valori di Faker scelti in maniera randomica, un valore che evidenzia un errore nel codice potrebbe venire estratto oppure no e l’esecuzione del test darebbe risultati diversi in maniera imprevedibile.

Hypothesis

Hypothesis è un motore di generazione di dati. Il programmatore in questo caso stabilisce i criteri con cui il dato deve essere generato e la libreria si occupa di generare esempi (la terminologia usata in questa libreria si ispira al mondo scientifico. I dati generati da Hypothesis sono detti “examples”, vedremo anche altre parole chiave come “given”, “assume”…) che rispettino i criteri dati.

Ad esempio, se vogliamo testare una funzione che prende in ingresso interi, sarà sufficiente applicare al test il decoratore given e passare ad esso la strategia integers. In documentazione trovate tutte le strategie incluse nella libreria.

from hypothesis import given
from hypothesis import strategies as st

@given(value_A=st.integers(), value_B=st.integers())
def test_my_function(value_A, value_B):
    ...

Il test test_my_function prende in ingresso due parametri, value_A e value_B. Hypothesis, tramite il decoratore given, riempe questi parametri con dati validi, secondo la strategia specificata.

Il vantaggio principale rispetto a Faker è che il test verrà eseguito numerose volte, con combinazioni di valori value_A e value_B ogni volta diverse. Hypothesis, inoltre, è fatto in modo da cercare edge case che potrebbero nascondere errori. Nel nostro esempio, non abbiamo definito nessun limite minore o maggiore per gli interi da generare, quindi è ragionevole aspettarsi che tra gli esempi generati troveremo, oltre ai casi più semplici, lo zero e valori abbastanza alti (in valore assoluto) da generare integer overflow in alcune rappresentazioni.

Questi sono alcuni esempi generati dalla strategia text:

@given(word=st.text())
def test_my_function(word):
    print(word)
# 㤴
# 󗖝򯄏畧
# 񗋙጑㥷亠󔥙󇄵닄
# 򂳃
# φક񗋙뭪䰴
# Sφ镇ʻ䓎܆Ą
# ʥ?ËD
# ?S®?
# ®?WS
# ýÆ!깱)󧂏񕊮𽌘
# ý
# ;®僉
# ®ý
# ;ፍŵ򆩑
...

(sì, buona parte di questi caratteri non li visualizza neanche il mio browser)

Delegare ad una libreria esterna il compito di immaginare possibili casi limite che potrebbero mettere in difficoltà il nostro codice è un ottimo modo per scovare possibili errori a cui non si era pensato e mantenere, allo stesso tempo, il codice del test snello.

Si noti che il numero di esecuzioni del test non è a discrezione del programmatore. In particolare, tramite il decoratore settings è possibile impostare un limite massimo di esempi da generare

@settings(max_examples=100)
@given(...)
def test_foo():
    ...

ma questo limite potrebbe comunque venire superato nel caso in cui il test fallisca. Questo comportamento è dovuto ad un’altra feature di Hypothesis: in caso di fallimento, un test viene ripetuto con esempi sempre più elementari, allo scopo di ricreare (e fornire nel report) l’esempio più semplice che garantisca un fallimento del codice.

In questo caso, ad esempio, Hypothesis riesce a trovare il limite per il quale effettivamente il codice fallisce:

def check_float(value):
    return value < 9999.99

@given(value=st.floats())
def test_check_float(value):
    assert check_float(value)

test_check_float()
# Falsifying example: test_check_float(value=9999.99)

Un esempio appena più realistico può essere questo:

def convert_string(value):
    return value.encode('utf-8').decode('latin-1')

@given(value=st.text())
def test_check_string(value):
    assert convert_string(value) == value

test_check_string()
# Falsifying example: test_check_string(value='\x80')

Hypothesis memorizza in una sua cache i valori ricavati dal processo di “falsificazione” e li fornisce come primissimi esempi nelle esecuzioni successive del test, per permettere allo sviluppatore di verificare subito se un bug rivelato in precedenza sia stato risolto o meno. Abbiamo quindi la riproducibilità del test per gli esempi che hanno causato fallimenti. Se si vuole ufficializzare questo comportamento e ritrovarlo anche in un ambiente non locale, come un server di continous integration, possiamo specificare con il decoratore examples esempi che verranno eseguiti sempre prima di quelli generati in modo random.

from hypothesis import example

@given(value=st.text())
@example(value='\x80')
def test_check_string(value):
    ...

example è inoltre un ottimo “segnalibro” per chi leggerà il codice in futuro, poiché evidenzia possibili casi ingannevoli che potrebbero sfuggire a prima vista.

Hypothesis: creare strategie personalizzate

Tutto questo è molto utile, ma spesso nei nostri test abbiamo bisogno di strutture più complesse di una semplice stringa. Hypothesis prevede l’uso di alcuni strumenti per generare dati complessi a piacere.

Per iniziare, il dato in uscita da una strategia può essere passato ad una map o da un filter.

# genera una lista di interi e ci applica `sorted`
st.lists(st.integers()).map(sorted).example()
# [-5605, -174, -144, 23, 76, 114, 234, 4258638726192386892599]

# genera un intero, ma filtrando via i casi in cui l'intero proposto
# dalla strategia è maggiore di 11.
st.integers().filter(lambda x: x > 11).example()
# 236

Un’altra possibilità è concatenare di più strategie, utilizzando flatmap.

Nell’esempio la prima chiamata a st.integers determina la lunghezza delle liste generate da st.lists e pone per esse un limite massimo di 10 elementi, escludendo però liste di lunghezza pari a 5 elementi.

n_length_lists = st.integers(min_value=0, max_value=10).filter(
    lambda x: x == 5).flatmap(
    lambda n: st.lists(st.integers(), min_size=n, max_size=n)
)

n_length_lists.example()
# [[11058035345005582727749250403297998096], [219], [-170], [-5]]

Per operazioni più complesse, possiamo utilizzare invece il decoratore strategies.composite, che consente di ottenere dati provenienti da strategie già esistenti, modificarli e assemblarli in una nuova strategia da utilizzare nei test o come mattoncino per un’altra strategia custom.

Ad esempio, per generare un payload valido per una applicazione web, potremmo scrivere qualcosa di simile al seguente codice.

Supponiamo che i payload che vogliamo generare prevedano alcuni campi obbligatori ed altri opzionali. Costruiamo allora una strategia payloads, che per primi estrae i valori per i campi obbligatori, li inserisce in un dizionario e, in una seconda fase, arricchisce questo dizionario con un sottoinsieme dei campi opzionali.

from hypothesis import assume

@st.composite
def payloads(draw):
    """Strategia personalizzata per generare payload validi per una web app."""

    # campi obbligatori
    payload = draw(st.fixed_dictionaries({
        'name': st.text(),
        'age': st.integers(min_value=0, max_value=150),
    }))

    # campi opzionali
    # nota: `subdictionaries` non è una funzione di libreria, 
    #       la scriveremo noi a momenti

    payload.update(draw(subdictionaries({
        'manufacturer': st.text(),
        'address': st.text(),
        'min': st.integers(min_value=0, max_value=10),
        'max': st.integers(min_value=0, max_value=10),
    })))

    if 'min' in payload and 'max' in payload:
        assume(payload['min'] <= payload['max'])

    return payload

Nell’esempio abbiamo voluto includere anche assume, che fornisce una regola aggiuntiva nella creazione dei dati e può risultare molto utile.

Non ci resta che definire subdictionaries: una funzione di utilità, utilizzabile sia come strategia a sé stante che come componente per altre strategie personalizzate.

La nostra subdictionaries è poco più che una chiamata a random.sample(), ma utilizzando la strategia randoms otteniamo che Hypothesis possa gestire il seed di random e trattare così la strategia personalizzata esattamente come quelle di libreria nel processo di “falsificazione” dei test-case falliti.

@st.composite
def subdictionaries(draw, complete):
    """Strategia per creare un dizionario contenente sottoinsieme dei valori del dizionario dato in ingresso."""

    length = draw(st.integers(min_value=0, max_value=len(complete)))
    subset = draw(st.randoms()).sample(complete.keys(), length)
    return draw(st.fixed_dictionaries({k: complete[k] for k in subset}))

In entrambe le funzioni viene preso in ingresso un argomento draw, che viene gestito interamente dal decoratore given. L’uso della strategia payload sarà quindi di questo tipo:

@given(payload=payloads())
def test_successful_call(payload):
    print(payload)
    # {'address': 'd', 'age': 84, 'max': 2, 'name': ''}

La creazione di strategie personalizzate si presta particolarmente bene per testare il comportamento corretto dell’applicazione, mentre per verificare il comportamento del nostro codice nel caso di specifici fallimenti potrebbe diventare troppo oneroso. Possiamo comunque riutilizzare il lavoro svolto per scrivere la strategia custom ed alterare i dati forniti da Hypothesis in modo da provocare i fallimenti che vogliamo verificare.

@pytest.mark.parametrize('missing_field', ['name', 'age']
def test_missing_values(missing_field):
    # ottieni un singolo esempio dalla strategia Hypothesis
    payload = payloads().example()
    del payload['missing_field']
    # chiama la web app
    # asserzioni sugli errori restituiti

È possibile che, con il crescere della complessità e dell’annidamento delle strategie, la generazione dei dati possa diventare più lenta, fino al punto di far fallire un health check interno di Hypothesis:

hypothesis.errors.FailedHealthCheck: Data generation is extremely slow

Se però la complessità raggiunta è necessaria allo scopo, possiamo sopprimere il controllo in questione per i singoli test che rischierebbero fallimenti randomici, rimettendo mano al decoratore settings:

from hypothesis import HealthCheck
@settings(
    max_examples=100,
    suppress_health_check=[HealthCheck.too_slow],
)

@given(payload=payloads())
def test_very_nested_payload(payload=payloads())

Concludendo

Questi sono sono solamente alcuni degli strumenti disponibili per il data-driven testing in Python, essendo –fortunatamente– l’ambiente in costante evoluzione. Di pytest.parametrize possiamo dire che è uno strumento da tenere a mente durante la scrittura dei test, perché ci aiuta ad ottenere, essenzialmente, un codice più elegante.

Faker è una possibilità interessante, può essere usato per vedere scorrere dati verosimili nei nostri test, ma non aggiunge molto, mentre Hypothesis è sicuramente una libreria più potente e matura. Va detto che scrivere strategie per Hypothesis è un’attività che richiede del tempo, specialmente quando il dato da generare è composto da più parti annidate; ci sono però tutti gli strumenti per poterlo fare. Hypothesis non è forse adatta per uno unit-test scritto rapidamente durante la stesura del codice, ma decisamente lo è per una analisi approfondita dei propri sorgenti. Come spesso succede nel Test Driven Development, la concezione dei test aiuta a scrivere codice fin da subito di qualità migliore: Hypothesis incoraggia lo sviluppatore a valutare quei casi limite che a volte si finiscono, invece, per tralasciare.