Agile, Unit Testing e qualità

Il recente disastro del sito INPS, miseramente crollato nel momento del bisogno, ha riacceso i riflettori sul tema della qualità del software, delle architetture, e dei processi organizzativi,

La qualità è centrale nell’Agile, che enfatizza massimizzazione del valore e ownership del prodotto finale insieme alla sostenibilità dei processi di sviluppo, In questo articolo mi focalizzo sulla sola qualità nel ciclo di vita dello sviluppo software (SDLC) e in particolare sulla sola adozione dei test di unità, mostrando criticità ed esempi pratici del loro utilizzo.

Testing Pyramid e testabilità del codice

Peculiare dell’approccio Agile è il focus sulla scrittura e automazione dei test di unità da parte dei developer che scrivono il codice da testare. Altri strumenti Agile che impattano sulla qualità del software sviluppato sono la definizione di criteri di copertura dei test nelle Definition Of Done, e l’inclusione di test nei criteri di accettazione delle storie. Esiste poi uno specifico approccio Agile al Testing, il Test Driven Development, che prevede la scrittura dei test di unità ancora prima della scrittura del codice da testare. L’Agile mette quindi a disposizione strumenti concreti e processi specifici per assicurare un prodotto finale che massimizza qualità e valore per il cliente finale e sostenibilità dei processi per il team di sviluppo.

Il tema non è però scontato. Capita ancora molto spesso di incontrare senior developer che non hanno mai utilizzato test automatici e faticano nel capire prima e utilizzare poi, un approccio Agile allo sviluppo.

MIke Cohn, uno dei padri dell’Agile, ha inventaro il concetto di Testing Pyramid nel suo libro Succeding with Agile. La piramide ci aiuta a comprendere la quantità di test e l’automazione ottimali per ogni livello: automazione totale per gli unit test, eseguiti a costo “zero” ad ogni deploy, mentre i test end-to-end che verificano il comportamento complessivo del sistema e la sua coerenza con le aspettative dell’utente, sono fatti manualmente e con tempi lunghi,

Scrivere e utilizzare Unit Test non è complesso, ma il codice da testare dev’essere scritto in modo da essere testabile. Cosa significa? Lo spiego con un esempio pratico. Immaginiamo di avere una funzione che, in base all’ora del giorno, decide se salutare con buongiorno, buon pomeriggio, buona sera, etc. Ecco il codice in Python:

"""
Function welcomeWith() returns appropriate greetings
according to the hour of the day
"""
import datetime

def welcomeWith_funct():
    now = datetime.datetime.now()
    hour = now.hour

    if 5 <= hour <= 13:
        welcomeWith = "Good morning!"
    elif 14 <= hour <= 17:
        welcomeWith = "Good afternoon!"
    elif 18 <= hour <= 22:
        welcomeWith = "Good evening!"
    else:
        welcomeWith = "Good night!"
    
    return welcomeWith

Qual è il problema con questa funzione? Che è molto difficile da testare. Infatti il metodo datetime.now() è una sorta di input in continuo cambiamento, per cui ogni chiamata restituisce potenzialmente valori differenti. Per di più il test è condizionato dall’orologio di sistema della macchina che fa girare la funzione, che non è detto sia la stessa macchina su cui girano i test. Diventa così molto difficile se non impossibile testare la logica della funzione, Come dovremmo riscrivere allora il codice per renderlo testabile? Introducendo un nuovo parametro in sostituzione dell'”input nascosto” fornito dalla datetime.now(). Ecco il codice riscritto per poter esser testato facilmente:

def testable_welcomeWith_fuct(hour_of_day):

    hour = hour_of_day

    if 5 <= hour <= 13:
        welcomeWith = "Good morning!"
    elif 14 <= hour <= 17:
        welcomeWith = "Good afternoon!"
    elif 18 <= hour <= 22:
        welcomeWith = "Good evening!"
    else:
        welcomeWith = "Good night!"
    print(welcomeWith, "testable function")

    return welcomeWith

La funzione deve ora essere chiamata con un input esplicito (il parametro hour_of_day) che può essere ripetuto identico ad ogni test e può essere così facilmente testata. Questo è però solo un caso specifico, esistono diversi anti-pattern che rendono il codice difficilmente testabile. Fortuna vuole che questi siano già ampiamente conosciuti (o conoscibili) e documentati. Ad esempio, Google ha reso pubblica nel 2008 la sua guida interna con i criteri per la testabilità del codice, che rappresenta un ottimo punto di partenza per qualsiasi team di sviluppo. Ancora più completo è Clean Code di Robert C. Martin dove sono presentati gli stessi concetti utilizzati in Google come la collezione di principi del SOLID, la Law of Demeter insieme ad altri ancora. Infine anche il TDD, con la scrittura del test prima del codice da testare, guida di fatto verso la scrittura di codice facilmente testabile.

Unit Test. Un esempio concreto

Gli Unit Test si svolgono in tre fasi:

  1. Arrange. In questa fase viene inizializzata la parte di sistema che si vuole testare (System Under Test)
  2. Act. Viene stimolato il System Under Test precedentemente inizializzato
  3. Assert. Si osserva il risultato dello stimolo e lo si confronta con un risultato predeterminato

Il test passa quando il risultato è coerente con quello predeterminato nell’Assert.

Vediamo adesso un esempio pratico di scrittura e utilizzo degli Unit Test. Gli esempi sono scritti in Python utiilzzando pytest, ma tutti i principali linguaggi hanno implementato uno o più framework per il supporto agli Unit Test, a partire dall’originale implementazione del padre dell’eXtreme Programming Kent Beck in Smalltalk nel 1998. Immaginiamo di dover testare un simulatore molto elementare di analisi finanziaria, che ci consenta di calcolare Margini, Tasse, ROI-Return of Investiment di una data organizzazione. Ecco il codice del simulatore.

def margin(r, c=100):
    return r - c
def roi(m, i):
    return m / i
def taxes(m, p):
    return m * p
def net_profit(m, t):
    return m - t

Per il testing useremo pytest, un framework Python evoluto che consente l’uso di fixtures e la scrittura di test molto compatti. Ecco il codice per lo Unit Test, che però contiene due errori. Riesci a vederli? Se non ci riesci per fortuna ci sono i test di unità che te li mostrano 🙂

import roi

#Testing margin func
def test_margin():
    assert roi.margin(100,70) == 30
    assert roi.margin(70) == -30

#Testing  roi func
def test_roi():
    assert round(roi.roi(100,700)) == 0.14

#Testing  taxes  func
def test_taxes():
        assert round(roi.taxes(100, 42)) == 42

#Testing  net profit  func
def test_net_profit():
        assert roi.net_profit(100, 42) == 58


DI seguito l’output di pytest, con il lancio in automatico di tutti i test presenti nella directory. Pytest permette di personalizzare facilmente l’output, il numero e tipo di test lanciati

(venv) PS C:\Users\documents\0-Python-Unit-Test-with-PY> pytest                                               ================================================= test session starts =================================================
platform win32 -- Python 3.8.2, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: C:\Users\documents\0-Python-Unit-Test-with-PY
collected 4 items

test_roi.py .FF.                                                                                                 [100%]

====================================================== FAILURES =======================================================
______________________________________________________ test_roi _______________________________________________________

    def test_roi():
>       assert round(roi.roi(100,700)) == 0.14
E       assert 0 == 0.14
E        +  where 0 = round(0.14285714285714285)
E        +    where 0.14285714285714285 = <function roi at 0x03AFD580>(100, 700)
E        +      where <function roi at 0x03AFD580> = roi.roi

test_roi.py:10: AssertionError
_____________________________________________________ test_taxes ______________________________________________________

    def test_taxes():
>           assert round(roi.taxes(100, 42)) == 42
E           assert 4200 == 42
E            +  where 4200 = round(4200)
E            +    where 4200 = <function taxes at 0x03AFD5C8>(100, 42)
E            +      where <function taxes at 0x03AFD5C8> = roi.taxes

test_roi.py:14: AssertionError
=============================================== short test summary info ===============================================
FAILED test_roi.py::test_roi - assert 0 == 0.14
FAILED test_roi.py::test_taxes - assert 4200 == 42
============================================= 2 failed, 2 passed in 0.13s =============================================
(venv) PS C:\Users\documents\0-Python-Unit-Test-with-PY>     

Il test segnala gli errori esistenti, che siano nella funzione da testare o nel test stesso. In questo caso entrambi gli errori sono proprio nel test:

  1. la funzione round è utilizzata senza indicare il numero di decimali per l’arrotondamento (default=0), e
  2. la funzione taxes() viene testata passando l’aliquota fiscale come valore percentuale (42) invece che decimale (0.42) . Correggiamo e rilanciamo il test per verificare il risultato.
(venv) PS C:\Users\documents\0-Python-Unit-Test-with-PY> pytest test_roi.py -v                     ================================================= test session starts =================================================
platform win32 -- Python 3.8.2, pytest-5.4.1, py-1.8.1, pluggy-0.13.1 -- c:\users\documents\0-python-unit-test-with-py\venv\scripts\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\0-Python-Unit-Test-with-PY
collected 4 items

test_roi.py::test_margin PASSED                                                                                  [ 25%]
test_roi.py::test_roi PASSED                                                                                     [ 50%]
test_roi.py::test_taxes PASSED                                                                                   [ 75%]
test_roi.py::test_net_profit PASSED                                                                              [100%]

================================================== 4 passed in 0.02s ==================================================
(venv) PS C:\Users\documents\0-Python-Unit-Test-with-PY>    

Ora tutti i quattro test passano senza problemi. Gli Unit Test possono essere lanciati in automatico ad ogni deploy, ad ogni build o in generale ogni volta che si desidera essere certi di non aver introdotto un bug nel sistema. Rappresentano una rete di sicurezza e la garanzia che il sistema si comporti e continui a comportarsi come desiderato.

Che nel 2020 ci siano ancora team di sviluppo che non fanno tesoro degli unit test è un peccato, e un punto debole per le organizzazioni dove questi team svolgono la loro funzione.

Spero che queste righe possano ispirare tutti quei Team che vogliono migliorarsi per entrare finalmente nel nuovo millennio.

Per approfondire

Marcello Del Bono è Product Owner di due team Agile e consulente per programmi di Trasformazione Agile. vanta esperienza pluriennale come ScrumMaster, Agile Coach e Project Manager tradizionale in progetti IT, System Integration, Marketing Digitale, e-commerce nei settori Finance/Banking, Media, Moda, Lifestyle

https://www.linkedin.com/in/marcellodelbono/