Python testing breadcrumbs

Scopri insieme ai nostri sviluppatori, la guida dedicata ai Python testing breadcrumbs.

Python testing breadcrumbs

Python: che cos'è

Python è un linguaggio molto semplice e potente che permette anche ai neofiti di raggiungere velocemente risultati interessanti senza incontrare particolari difficoltà.
Quello che però non sempre viene detto, soprattutto a chi è nuovo del settore, è che il successo di un progetto a lungo termine, a prescindere dal linguaggio utilizzato, passa non solo per la sua buona progettazione ma anche per l’architettura dei test!
I test appaiono, per chi è alle prime armi, una strada tortuosa e da evitare, ma vi assicuro che se considerati con il giusto approccio, si riescono a domare facilmente.

Scopriamo insieme cosa sono gli unit / integration e system tests.

  • unit test: sono quelli per la cui esecuzione non sono necessarie dipendenze da componenti esterne;
  •  integration test: sono quei test che mirano a verificare l’integrità delle interfacce tra le componenti dell’applicazione;
  •  system tests: mirano a testare il prodotto nella sua interezza e per verificare che rispetti i requisiti richiesti.


Parola d'ordine per i test? Metodo!

Il mio consiglio è di creare dei test già nelle primissime fasi dello sviluppo, soprattutto quelli unitari, partendo dalle classi più semplici.
Man mano che il progetto prende forma e le classi evolvono, aggiungere nuovi test oppure modificare quelli esistenti per riflettere le modifiche da effettuare, secondo il paradigma TTD.
L’approccio Test Driven Development (TDD) mira a implementare un caso di test prima della componente da testare. In parole più chiare, testare una singola permutazione per poi scrivere il minimo codice è indispensabile per far si che il test passi.
In seguito, estendere le permutazioni testate e di conseguenza estendere il codice della componente da testare per far si che questi continuino a passare.
Un altro consiglio molto importante è il seguente: se l’implementazione di un caso di test dovesse rivelarsi troppo macchinosa oppure troppo difficile, allora con molta probabilità l’oggetto del test è a sua volta troppo complesso e quindi sarebbe utile concentrarsi nel semplificarlo (occhio alla bassa coesione). Una buona opzione potrebbe essere quella di suddividerlo in componenti più semplici (divide et impera docet).

Test Case: iniziamo dalle basi

Per preparare gli esempi che trovi in questo articolo è stata utilizzata la libreria standard di Python per i test, chiamata unittest. Ogni caso di test viene scritto attraverso l’implementazione di una classe che, di norma, è chiamata test case. Un test case è un insieme di singoli test accomunati dalle medesime finalità o dall’oggetto del test, implementati come metodi della classe. I test all’interno dello stesso test case a volte hanno in comune delle istruzioni o configurazioni che vengono chiamate test fixture.
Il successo di un singolo test dipende dalla comparsa o meno di errori durante l’esecuzione del metodo del test case e, di conseguenza, il successo di un test case dipende dal successo di ogni singolo test che contiene.

Ma partiamo subito con l’esempio che aiuterà a capire meglio i concetti espressi finora e che, nonostante la sua semplicità, forniscono una buona base anche per progetti più complessi.
Ho preparato un piccolo progetto, un’interfaccia a riga di comando per la gestione di una rubrica di indirizzi. Troppo semplice? 
Lo screenshot seguente mostra l’alberatura dei file del progetto.

.
|____tests
| |____integration
| | |______init__.py
| | |____test_contact.py
| |____system
| | |______init__.py
| | |____test_app.py
| |____unit
| | |______init__.py
| | |____test_address.py
| |______init__.py
|____address.py
|____app.py
|____contact.py
|____main.py

Nel file app.py viene gestita l’interazione con l’utente, nel file contact.py è presente la classe demandata alla gestione del contatto, mentre nel file address.py vi è quella per la gestione dell’indirizzo. Il file main.py è il semplice entrypoint dell’applicativo e il package test, invece, ospiterà la test suite per questo esempio: al suo interno, infatti, vi sono i tre sub-package che conterranno le tre tipologie di test per questo esempio. Tale suddivisione non è obbligatoria ma raccomandata. I files che conterranno le classi dei test case, invece, devono corrispondere al pattern test_*.py per essere riconosciuti dal test runner quando viene lanciato con le opzioni di default. C’è una seconda regola, questa però riguarda i nomi dei metodi della test case: il test runner eseguirà come test individuali i metodi della classe che iniziano con “test”. Non ci sono altre indicazioni, tranne che le classi contenenti i test case siano, ovviamente, estensione della classe TestCase dal package unittest, di cui invito caldamente a consultare la documentazione ufficiale.

Unit Tests: cosa sono e come funzionano

Partiamo dalla base: gli unit test. Come già accennato, sono test rivolti a singole funzionalità e che non hanno dipendenze. Sono generalmente i più semplici, ma non per questo vanno ignorati o sottovalutati.
Iniziamo con il testare la classe Address, al cui interno, oltre al costruttore, vi è un metodo che restituisce le componenti dell’indirizzo sotto forma di un oggetto JSON. Il primo test case mira a verificare l’integrità di una istanza della classe Address e la correttezza dell’oggetto JSON ritornato dall’omonimo metodo.
Il file che ospita il nostro test case è posizionato nella cartella tests/unit: dovendo testare la classe Address, e senza dimenticare le raccomandazioni di cui sopra, il file è stato chiamato test_address.py.

class AddressTest(TestCase):

   def setUp(self) -> None:
       self.a = Address('via Test', '1', '00000', 'Test City', 'Testing State')

   def tearDown(self) -> None:
       self.a = None

   def test_create_post(self):
       self.assertEqual('via Test', self.a.street)
       self.assertEqual('1', self.a.number)
       self.assertEqual('00000', self.a.postcode)
       self.assertEqual('Test City', self.a.locality)
       self.assertEqual('Testing State', self.a.state)
       self.assertIsNone(self.a.recipient)

   def test_json(self):
       self.assertDictEqual({
           'street': 'via Test',
           'number': '1',
           'postcode': '00000',
           'locality': 'Test City',
           'state': 'Testing State',
           'recipient': None,
       }, self.a.json())

I metodi setUp e tearDown permettono di definire le istruzioni che vengono eseguite rispettivamente prima e dopo ogni test presente nella classe. In questo caso vengono utilizzati per istanziare e poi eliminare l’oggetto Address usato nei test. Ovviamente l’oggetto poteva essere istanziato anche nei metodi di test, ma è un vezzo per mostrare un esempio di test fixture.
Le invocazioni assert* utilizzate nei metodi test_* vengono fornite da TestCase e servono per controllare che il risultato dell’invocazione dei metodi dell’oggetto testato sia conforme a quanto atteso. In questo caso è stato utilizzato un piccolo sottoinsieme, ma invito a scoprire tutti quelli disponibili sulla documentazione ufficiale.
Può sembrare un test case banale e pedante, e per certi versi lo è, ma nel caso in cui dovesse cambiare (per refactoring o cambio requisiti) la firma del costruttore della classe, il fallimento dei test sarà sicuramente un bel promemoria per controllare tutto il codice che utilizza la classe! Inoltre, bisogna pur iniziare a testare qualcosa, o no? ;)

A questo punto potrebbe essere un buon esercizio creare un test case unitario anche per la classe Contact.

Integration tests: cosa sono e come funzionano

La classe Contact è composta dal costruttore, un metodo che ritorna le informazioni di contatto sotto forma di un oggetto JSON, inclusi gli indirizzi associati, e di un metodo per aggiungere un indirizzo al contatto. Contrariamente alla classe Address, che non ha dipendenze, Contact dipende appunto da quest’ultima, dato che la utilizza sia nel metodo add_address che nel metodo json. Entrano quindi in gioco i test di integrazione.

class ContactTest(TestCase):

   def setUp(self) -> None:
       self.c = Contact('Name', 'Surname', 'Nickname')
       self.c.add_address('via Test', '1', '00000', 'Test City', 'Testing State')

   def test_add_address(self):
       self.assertEqual(1, len(self.c.addresses))
       self.assertEqual('via Test', self.c.addresses[0].street)
       self.assertEqual('1', self.c.addresses[0].number)
       self.assertEqual('00000', self.c.addresses[0].postcode)
       self.assertEqual('Test City', self.c.addresses[0].locality)
       self.assertEqual('Testing State', self.c.addresses[0].state)

   def test_json(self):
       expected = {
           'name': 'Name',
           'surname': 'Surname',
           'nickname': 'Nickname',
           'addresses': [
               {
                   'street': 'via Test',
                   'number': '1',
                   'postcode': '00000',
                   'locality': 'Test City',
                   'state': 'Testing State',
                   'recipient': None,
               },
           ],
       }
       self.assertDictEqual(expected, self.c.json())


La classe ContactTest, come si vede nell’immagine, testa soltanto i due metodi che utilizzano Address: l’obiettivo è di accertarsi che le due classi siano ben integrate, ovvero che il metodo add_address crei correttamente una istanza di Address e che json la gestisca correttamente. Il corpo dei due metodi test_* è di facile comprensione, non vi sono elementi di novità rispetto a quanto descritto in precedenza. I tests degli altri metodi, invece, sono responsabilità dei test unitari.

System Tests: cosa sono e come funzionano

class AppTest(TestCase):

   def setUp(self) -> None:
       # Mock contacts variable in app.py
       contact = Contact('Test', 'Testing', 'test')
       app.contacts = {
           'test': contact
       }

   def test_menu_prints_prompt(self):
       with patch('builtins.input', return_value='q') as mocked_input:
           app.menu()
           mocked_input.assert_called_with(app.MENU_PROMPT)

   def test_menu_calls_print_contacts(self):
       # patch does the magic!
       with patch('builtins.print') as mocked_print:
           with patch('builtins.input', return_value='q'):
               app.menu()
               mocked_print.assert_called_with('- Test Testing, aka test')

   def test_menu_calls_print_contacts_twice(self):
       with patch('builtins.input') as mocked_input:
           with patch('app.print_contacts') as mocked_print_contacts:
               mocked_input.side_effect = ('l', 'q')
               app.menu()
               self.assertEqual(mocked_print_contacts.call_count, 2)

Una prima occhiata al contenuto della classe AppTest potrebbe far tremare le ginocchia, ma procediamo per gradi. L’obiettivo di questo test case, un test di sistema, è di verificare che l’interfaccia a riga di comando risponda correttamente alle interazioni con l’utente. Il caso più semplice non può essere che la prova del corretto avvio dell’applicativo, ed è proprio quello che viene testato nel metodo test_menu_prints_prompt.
C’è però un importantissimo elemento di novità: la funzione patch della libreria unittest.mock (si, meglio dare un’occhiata anche alla sua documentazione). patch, che in realtà è un decoratore/manager di contesto, usato nel blocco with sostituisce temporaneamente la funzione (o il metodo) in input con un altro oggetto che ne diventa l’imitazione (unittest.mock.Mock).
Che farsene di questa imitazione? Beh, per esempio possiamo configurarla per farle ritornare un determinato output, oppure interrogarla per conoscere con quali parametri è stata invocata durante il test, o anche sapere quante volte è stata invocata.
Analizziamo dunque il metodo test_menu_prints_prompt: viene innanzitutto “patchata” la funzione input e configurata in modo da ritornare, quando invocata, la stringa “q”. Viene dunque chiamata la funzione app.menu e verificato che la versione patchata di input sia stata invocata al suo interno con il prompt della riga di comando.
Il secondo test, il metodo test_menu_calls_print_contacts, assomiglia molto al primo ma questa volta, oltre ad input, viene patchata anche la funzione print. Il suo scopo è di verificare che vengano correttamente stampati tutti i contatti all’avvio dell’applicativo, infatti il metodo setUp ne crea uno per far sì che l’insieme non sia vuoto e che quindi venga effettivamente passato qualcosa in input alla versione patchata di print.
L’ultimo metodo da analizzare è test_menu_calls_print_contacts_twice: in questo test si vuol mostrare che il menu risponda correttamente all’input ricevuto. Infatti viene patchata la funzione input per simulare la scelta dell’utente di visualizzare la lista dei contatti (“l”): quindi, dato che la stampa avviene anche all’avvio della funzione app.menu, si verifica che la funzione print_contacts, opportunamente patchata, venga effettivamente invocata due volte.
Un cenno sulla proprietà side_effect dell’oggetto Mock: può essere una funzione che verrà invocata ogni volta che viene invocata l’istanza dell’oggetto, oppure una lista (o tupla) di valori che saranno ritornati, secondo l’ordine nel quale vengono passati, sempre ad ogni invocazione dell’istanza.

Modalità di esecuzione dei test

Terminati gli esempi, non resta che svelare l’ultimo tassello di questa guida, ovvero mostrare le modalità di esecuzione dei test!
Utilizzando un IDE come PyCharm il lavoro è praticamente finito, dato che quest’ultimo offre la possibilità di eseguire con un semplice click anche il singolo metodo di un test case.
Per chi invece non resiste al fascino del terminale, e rimanendo nel perimetro del nostro esempio, il comando da lanciare (nella cartella principale del progetto) è davvero semplice:

python -m unittest discover -s tests

Il comando eseguirà tutti i test che trova all’interno della cartella tests, fornendo in output il risultato. Anche in questo caso, consiglio la consultazione della documentazione del comando per ulteriori esempi e per scoprire le altre opzioni.

Gran Finale (...?)

In questa guida è stato mostrato soltanto un piccolo sottoinsieme dei possibili test, nonostante si tratti di un facile esempio. Infatti, allo scopo di provare in maniera esaustiva che tutti i requisiti siano soddisfatti, è consigliato predisporre almeno due casi di test per ciascun requisito: un test per accertare un esito atteso (positivo) e un altro per accertare il lancio di un'eccezione o di un messaggio di errore (negativo). Insomma, per fare le cose per bene bisogna davvero rimboccarsi le maniche, ma è solo una questione di tempo/risorse perché non c’è nulla di davvero complicato!

Infine, è fortemente raccomandata l’adozione di un tool dedicato per automatizzare l’esecuzione dei test, come ad esempio Jenkins o Travis CI, dato che questi strumenti offrono anche la possibilità di configurare un ambiente per gestire la build e/o il rilascio dell’applicativo. In alternativa è possibile configurare opportunamente un GIT Hook in ambiente di sviluppo al fine di lanciare l’esecuzione dei test in concomitanza di un evento GIT: i possibili eventi sono tanti e il mio consiglio è di utilizzare il pre-commit, ma è meglio usare il pre-push se i test impiegano molto tempo per l’esecuzione.
Alla prossima!

Realizziamo qualcosa di straordinario insieme!

Siamo consulenti prima che partner, scrivici per sapere quale soluzione si adatta meglio alle tue esigenze. Potremo trovare insieme la soluzione migliore per dare vita ai tuoi progetti.