Test doubles: una panoramica
Parlando di test automatici, sarà inevitabile incappare in termini come mock, stub o fake. Questi oggetti vengono definiti “test doubles”, e ci aiutano a riprodurre comportamenti prevedibili all’interno dei nostri unit test o test d’integrazione.
Non sempre però è facile ricordare le differenze tra le varie tipologie, e così si finisce per usare il termine “mockare” in maniera indiscriminata.
In questo post ho quindi provato a raccogliere un po’ di definizioni ed esempi che chiariscano le caratteristiche di ciascun tipo di test double ed i suoi casi d’uso.
Premessa
Un test double può essere visto come la “controfigura” di un oggetto reale presente nella nostra applicazione, avente però una logica semplificata e controllabile.
In uno scenario object oriented, questo “doppione” dovrà implementare la stessa interfaccia dell’oggetto originale, così che, a tutti gli effetti, potremo sostituirlo in tutti i contesti in cui l’originale viene utilizzato.
Prima di esaminare i vari tipi di test double, cominciamo col definire un ipotetico sistema sotto test — nella fattispecie, la classe SignUpService
— e alcune sue dipendenze che andremo ad utilizzare negli esempi successivi:
A questo link trovate il codice completo del System Under Test (SUT).
Dummy
Un Dummy Object — o semplicemente Dummy — è la forma più semplice di test double. Si tratta di una replica che, a tutti gli effetti, non implementa un comportamento.
Possiamo usare un dummy per sostituire l’oggetto originale in test in cui il comportamento di quest’ultimo è ininfluente.
Un dummy, nella sua forma più semplice, può essere costituito da una null reference.
Vediamo un esempio:
L’eccezione UnsupportedOperationException
, sollevata da entrambi i metodi di DummyUserRepository
, fa sì che il nostro dummy non venga inavvertitamente utilizzato in contesti di produzione. Allo stesso tempo, se in fase di test dovessimo incappare in un eccezione di questo tipo avremmo evidenza del fatto che i metodi vengono invocati, quando invece non dovrebbero.
Il test verifica che, per un input non valido, il metodo signUp
scateni un eccezione di tipo IllegalArgumentException
.
Chiaramente, in questa situazione l’oggetto DummyUserRepository
può essere letteralmente ignorato, visto che non è coinvolto in alcun modo nel test. Il costruttore di SignUpService
dichiara però un parametro di tipo UserRepository
, per cui sarà necessario fornire un’istanza corrispondente.
NOTA In linguaggi come Java, che non implementano meccanismi di null-safety, avremmo potuto passare al costruttore un semplice null
come argomento.
Stub
Un Test Stub — o semplicemente Stub — è un oggetto programmato per restituire risposte predefinite.
Anch’esso, come un dummy, non possiede dei veri e propri comportamenti, ma (generalmente) implementa dei valori di ritorno hardcoded, specifici per il test che andremo ad eseguire.
A differenza del dummy, un oggetto stub gioca un ruolo attivo all’interno del test. Uno stub, infatti, viene normalmente utilizzato per guidare il flusso di esecuzione delle logiche applicative che vogliamo testare.
I più attenti avranno notato che già nel test precedente — insieme all’oggetto dummy — abbiamo usato uno stub:
La classe InvalidSignUpRequestStub
definisce una SignUpRequest
ostinatamente non valida, ottenendo quindi un comportamento prevedibile, indipendentemente dalla logica dell’oggetto reale. In questo modo possiamo focalizzarci sull’effettiva parte del sistema che vogliamo testare.
Ho riportato per chiarezza il test già visto nel paragrafo precedente.
Utilizzando lo stub InvalidSignUpRequestStub
come argomento di signUp
, possiamo essere certi che la validazione dell’input fallisca in ogni caso; in questo modo possiamo concentrare l’attenzione sul fatto che l’operazione di registrazione abbia come esito un’eccezione.
Spy
Un Test Spy — o semplicemente Spy — è un oggetto simile ad uno stub, che però implementa una caratteristica molto interessante: permette di “registrare” ed ispezionare le interazioni avvenute con l’oggetto sotto test.
La classe SuccessfullySavingUserRepositorySpy
, in particolare, implementa un contatore delle invocazioni per il metodo saveUser
. In questo modo, durante il test, possiamo sapere se il metodo signUp
provvede correttamente al salvataggio tramite lo UserRepository
.
Proprio in scenari come quello dell’esempio, in cui una classe debba interagire con sistemi esterni — come web service o database — l’utilizzo di uno spy risulta particolarmente utile. In questo modo, infatti, si riescono ad implementare test d’unità il cui esito non dipende dalla risposta di sistemi su cui non abbiamo controllo.
Però… c’è un però…
Sebbene questa tipologia di oggetti appaia molto potente e utile, è importante ricordare che l’utilizzo di spie nei nostri test introduce un certo livello di accoppiamento tra il sistema sotto test e il test implementato.
Asserzioni basate sull’ispezione di uno spy, infatti, prevedono la conoscenza di dettagli implementativi del sistema che stiamo testando. Questo rende più fragili i nostri test, che dovrebbero quanto più possibile seguire un approccio black box, basato cioè sul solo comportamento atteso da parte del sistema.
Mock
Un Mock Object — o semplicemente Mock —, come un oggetto spy, viene tipicamente utilizzato come punto di osservazione del sistema sotto test, avendo la capacità di memorizzare le interazioni con quest’ultimo.
Differentemente da quanto avviene con un spy però, in un mock la verifica delle interazioni avviene internamente all’oggetto. In altre parole, un mock può essere visto come uno stub contente delle asserzioni.
L’utilizzo di mock cambia l’usuale struttura di un test automatico che, tipicamente, prevede una fase di setup, una di esercizio ed una di verifica, quest’ultima consistente in una serie di asserzioni sull’output generato dal sistema testato.
Utilizzando un mock, le asserzioni si trasformano in precondizioni impostate durante il setup dell’oggetto. Ne consegue che dal test è assente una vera e propria fase di verifica.
A differenza delle altre tipologie di oggetti-replica, i mock vengono difficilmente implementati “a mano”. Tipicamente sono infatti generati “al volo” tramite appositi framework — es. Mockito, JMock, MockK, ecc. — e l’invocazione finale del verify
(o metodo analogo) avviene spesso in maniera implicita.
Fake
Un Fake Object — o semplicemente Fake — è il test double più simile all’oggetto replicato, in termini di comportamento.
Questa tipologia di oggetto non prevede logiche di ispezione o risposte hardcoded per i suoi metodi: implementa invece una versione semplificata dell’oggetto utilizzato in produzione.
La differenza fondamentale di un fake rispetto agli altri test double sta dunque nel fatto che esso possiede un vero e proprio comportamento.
Un fake viene utilizzato generalmente in contesti un cui la creazione dell’oggetto risulta difficile, o potrebbe rallentare l’esecuzione del test. Oppure in casi in cui l’utilizzo dell’oggetto originale potrebbe generare side effect indesiderati, in fase di test (per esempio l’interazione con filesystem o sistemi esterni).
In questo esempio ho semplicemente implementato uno UserRepository
che utilizza una lista come database per gli utenti. In questo modo il comportamento originale del sistema è mantenuto, ma non occorre scomodare DBMS esterni — che tra l’altro introdurrebbero ulteriori variabili da tenere in considerazione in fase di test.
NOTA Per l’implementazione di unit test — come quelli riportati in questo post —, è importante mantenere l’isolamento dell’unità sotto test rispetto ai sistemi esterni. Ovviamente se stessimo effettuando test d’integrazione, l’interazione con un database esterno non costituirebbe un problema, sarebbe invece auspicabile.
In conclusione
Come avrete notato, benché le differenze tra i vari test double sia a volte sottile, ciascuna tipologia di oggetto è pensata per risolvere problemi specifici e facilitare l’implementazione di test in scenari differenti.
Molto spesso ci ritroveremo ad implementare le nostre controfigure sfruttando mocking framework, senza renderci esattamente conto di quale tipologia di oggetto stiamo utilizzando.
Ad ogni modo, vale sempre la pena considerare un approccio hand-made, che in molte situazioni rende più comprensibile il codice dei nostri test e ci evita l’utilizzo di un’ulteriore dipendenza.
Se vi va, fatemi sapere cosa ne pensate.
Alla prossima,
David