Nel libro ‘Working Effectively with Legacy Code‘, Michael Feathers definisce il codice legacy come “codice senza test”. Questo perché è praticamente impossibile sapere se, modificando parte di un programma senza una adeguata copertura di test (test coverage), abbiamo di fatto introdotto dei bug.

Test-Driven Development (TDD) è una disciplina di sviluppo software resa in parte famosa dalla sua presentazione nel libro ‘Clean Code‘ di Robert Martin, che prevede la scrittura dei test prima ancora di scrivere il codice che dovrà essere testato. Questa “inversione” delle operazioni non solo ci permetterà di riflettere sul comportamento che dovrà avere quella specifica porzione di software, ma ci obbligherà a scrivere codice facilmente testabile – ad esempio, funzioni pure.

Non tutti i test però sono uguali. A seconda del livello di astrazione, potremmo volere testare diverse funzionalità. Questo perché le funzionalità, prese singolarmente come negli unit test ad esempio, potrebbero non implementare il comportamento che ci aspettiamo quando le testiamo nel loro insieme.

Mike Cohn, nel suo libro ‘Succeeding with Agile’, ha introdotto un interessante concetto chiamato “The test pyramid” che parla proprio del testing delle funzionalità di un software a vari livelli di astrazione. Per avere un software production-ready infatti, dovremmo testarlo usando più tecniche.

In questo articolo vedremo quindi vari tipi di test, i loro punti di forza ed alcuni esempi.

Unit Testing

Questo tipo di test viene scritto da sviluppatori con conoscenza del codice. Viene scritto durante lo sviluppo del software di produzione stesso, ed è una tecnica che fa parte del white box testing. Che cosa si intende con “unit” (unità)? Non c’è un definizione generale, ma tende ad essere soggettiva. In un contesto OOP, si potrebbe intendere come unit una intera classe, o un singolo metodo. In un linguaggio funzionale, una unità è probabilmente una singola funzione.

Quando una funzione ha bisogno di interagire con dipendenze “esterne”, è possibile usare una tecnica chiamata mocking. Un mock (dall’inglese “imitare”) è sostanzialmente un oggetto che imiterà il comportamento delle dipendenze esterne.

Visto che la quantità di codice testato da uno unit test è (di solito) relativamente breve, i loro tempi di esecuzione tendono a essere bassi. A lungo andare però, il numero di unit test tenderà ad aumentare il che potrebbe nell’insieme rendere questo step (eseguire tutti gli unit test) abbastanza lungo. Il vantaggio però è che di solito è possibile eseguirli in maniera selettiva, perciò il ciclo iterativo di sviluppo può rimanere efficiente andando ad eseguire solamente gli unit test che riguardano il codice che stiamo modificando.

Acceptance Testing / Functional Testing

Talvolta Acceptance e Functional test sono usati in maniera interscambiabile – anche se alcuni sviluppatori non sono della stessa opinione.

I test di Accettazione, noti anche come User Acceptance Test, vengono svolti sul codice in modalità black box ovvero senza conoscerne i dettagli implementativi.

Quando si sviluppa software, vengono normalmente definiti degli acceptance criteria (AC) per una certa feature. Una feature può essere anche suddivisa in blocchi di funzionalità che devono essere implementati.

Un Acceptance / Functional test viene eseguito in modalità black box sul codice e andrà a testare un singolo criterio. Questo viene a volte implementato dal team di Quality Assurance (QA). Avere degli Acceptance test ci assicura che la funzionalità che vogliamo rilasciare funzioni come ci si aspetta.

Integration Testing

Nel caso degli unit test, quando abbiamo bisogno di riferirci ad altri moduli o dipendenze, possiamo usare un mock per imitarle. Nell’integration test invece possiamo riferici alla effettiva implementazione del modulo o dipendenza senza l’ausilio del mock.

Questo perché i test di integrazione servono a testare le parti di codice che spaziano, ovvero utilizzano più unit. Testare la coordinazione fra le varie unità è fondamentale per essere sicuri che il codice si comporti come ci aspettiamo.

Vista la quantità di codice che vanno a testare, gli integration test tendono ad avere un alto tempo di esecuzione.

Quando si vanno a modificare più units, un integration test è utile per dimostrare che il nostro cambiamento funziona come ci aspettiamo. Quindi per modifiche più semplici non sarà necessario scrivere un integration test. Prima di integrare un cambiamento però, è bene sempre verificare che le integrazioni che riguardano quel cambiamento continuino a funzionare correttamente.

End To End (E2E) Testing

I test End To End permettono di testare il sistema nel suo insieme. Se negli integration test andiamo a testare l’integrazione fra 2 o più moduli, il test e2e ha come scopo testare l’integrazione fra tutti i moduli del sistema.

Sebbene una simile suite di test sia molto importante, può presentare svantaggi quali un elevato tempo di esecuzione e (spesso) una “elevata” quantità di falsi positivi (flaky tests). Inoltre, mantenere aggiornata questa suite di test richiede una certa quantità di lavoro che può diventare delle volte molto oneroso. Per questo motivo si dovrebbe cercare di ridurre il numero di questo tipo di test al minimo, o di eseguirlo in maniera schedulata una volta a settimana (cronjob) invece che per ogni commit (in una pipeline di Continuous Integration).

Property Testing

Unit ed integration testing vengono chiamati example-based testing perché data una funzione da testare, creiamo manualmente dell’input a cui ci aspettiamo conseguirà un certo output.

Il property-based testing invece definisce delle proprietà per definire gli input.

Usiamo come esempio una funzione di concatenazione di stringhe concat(s: [strings]).

Con uno unit test, controlleremo che l’output di concat(["a","b"]) contiene ab, e che l’output di concat(["b", "a"]), contiene ba.

Quando scriviamo un property test, definiaimo una proprietà che verra poi verificata dal nostro framework di testing. Ad esempio, per la funzione concat potremmo definire la proprietà “dati a,b,c, tutte le concatenazioni di a, b,c conterranno la stringa b”.

Conclusione

In questo articolo abbiamo visto brevemente varie tecniche di testing. Il testing del codice non può provare l’assenza di bug, ma al massimo può provarne il corretto funzionamento secondo i casi d’uso da noi previsti.

Utilizzare diversi tipi di test, aumenta la nostra confidenza durante il rilascio in produzione di un cambiamento. La test pyramid suggerisce che diverse tecniche di testing andrebbero impiegate, variando il livello di astrazione del codice che si sta testando.

A seconda della quantità di codice testata, i tempi di attesa potrebbero allungarsi. Mentre alcuni test sono adatti ad un ciclo di sviluppo iterativo rapido (unit testing), è consigliabile eseguire altri test sul code base periodicamente (e2e testing) o solamente dalla CI (integration testing, property testing).

Riferimenti

  • Clean Code, Robert Martin
  • https://martinfowler.com/articles/practical-test-pyramid.html
  • Working Effectively with Legacy Code, Michael Feathers
  • Succeeding with Agile, Mike Cohn
  • https://medium.com/criteo-engineering/introduction-to-property-based-testing-f5236229d237