[10] Mocking
Link consigliato dal prof http://xunitpatterns.com
Four-Phase Test
Un aspetto importante da considerare durante la scrittura dei test è la chiarezza del loro scopo.
Chiunque li legga deve essere in grado di determinare rapidamente quale comportamento si sta testando. Tuttavia, questo può risultare molto difficile se i test non sono strutturati in modo ottimale. L’obiettivo di un test può essere molto confusionario se, ad esempio, vengono invocati senza alcun criterio diversi comportamenti del system under test (SUT, ciò che sta venendo testato), e.g. alcuni per impostare lo stato pre-test (fixture) del SUT, altri per utilizzare il SUT e altri ancora per verificare lo stato post-test del SUT.
Un modo per rendere evidente ciò che si sta testando è strutturare ogni test in modo che abbia quattro fasi distinte, eseguite in sequenza:
- SET UP: si inizializza tutto il necessario affinché il SUT esibisca il comportamento atteso e il test, successivamente, sia in grado di osservare il risultato effettivo (ad esempio, creare i vari Test Double).
- EXERCISE: si interagisce con il SUT, facendo dunque eseguire il codice che si vuole effettivamente testare.
- VERIFY: si fa tutto il necessario per determinare se il risultato atteso è stato ottenuto o meno (e.g. tramite asserzioni di vario tipo).
- TEARDOWN: fase di pulizia atta a riportare l’ambiente nello stato in cui è stato trovato.
Per aumentare ulteriormente la leggibilità dei nostri test è desiderabile anche fare in modo che ogni metodo di test verifichi una e una sola funzionalità. Ciò non significa che un metodo di test che verifica più funzionalità sia scorretto, ma fornirà sicuramente una minore localizzazione delle anomalie rispetto a un gruppo di test che testano singole funzionalità; in altre parole, sarà meno leggibile e contraddistinto da una logica più complessa.
Mocking and Test Double
Approcci al testing
Può risultare assai difficile testare un SUT che dipende da componenti software non utilizzabili per un motivo o per l’altro. Questi componenti prendono il nome di depended-on component (DOC) e i problemi che questi possono far emergere durante la stesura di un test sono molteplici. Ad esempio, i DOC potrebbero non essere disponibili in quel momento, non restituire i risultati che servono a un determinato test (o restituirli solo tramite artifici troppo complessi) oppure perché la loro esecuzione avrebbe effetti collaterali indesiderati. In altri casi ancora, la strategia di testing adottata richiede un maggiore controllo o più visibilità sul comportamento interno del SUT e l’utilizzo di un DOC reale rende l’operazione complessa.
Quando si scrive un test in cui non si può (o si sceglie di non) usare il vero componente da cui si è dipendenti, si può sostituire quest’ultimo con un Test Double, durante la fase di set up.
Test Double è un termine generico utilizzato per indicare un qualunque oggetto con cui si sostituisce un DOC reale a scopo di test.
Ovviamente, a seconda del tipo di test che si sta eseguendo, si può codificare diversamente il comportamento del Test Double.
Non è necessario che questo si comporti come il DOC reale: il suo scopo è solo quello di fornire le stesse API in modo che la sua presenza risulti essere trasparente al SUT.
In altre parole, per il SUT interagire con il DOC reale o con il Test Double deve essere esattamente la stessa cosa.
L’utilizzo di Test Double rende possibile la scrittura di test che precedentemente risultavano troppo articolati, complicati o dispersivi da realizzare.
Il mocking è la tecnica di testing che ci permette di sostituire i DOC reali con i vari Test Double. Effettuare mocking permette di ottenere test più efficienti, affidabili e puliti, consentendo agli sviluppatori di isolare il SUT in un ambiente più controllato.
Come si può osservare dall’immagine sottostante, vi sono diversi tipi di Test Double:
Dummy Objects
Nella maggior parte dei test è necessario fare in modo che il SUT si trovi in uno stato opportuno prima di quella che è la fase di exercise; a tale scopo, durante la fase di set up, vengono effettuate chiamate ad alcuni suoi metodi. Questi possono prendere come argomenti degli oggetti che, a volte, vengono solo memorizzati in variabili d’istanza e non sono di fatto mai utilizzati nel codice testato. É dunque necessario crearli unicamente per conformarsi alla firma di qualche metodo che si pianifica di chiamare nella fase di set up. La costruzione di questi oggetti può essere non banale e aggiunge una complessità del tutto superflua al test.
In questi casi, si può passare come argomento un dummy object. Questi oggetti sono una forma degenere di Test Double in quanto esistono solo per poter essere passati da un metodo all’altro e non vengono mai realmente utilizzati. La loro utilità risiede nell’eliminare la necessità di costruire gli oggetti reali.
Si noti che un dummy object non è la stessa cosa di un null object. Un dummy object non viene utilizzato dal SUT, quindi il suo comportamento è irrilevante. Al contrario, un null object viene utilizzato dal SUT, ma è progettato per non fare nulla o produrre un risultato sempre “innocuo”.
Senza Mockito | Con Mockito |
|
|
Stub Objects
Altre volte risulta difficile testare il SUT perché il suo comportamento dipende dai cosiddetti input indiretti: valori restituiti da altri componenti software (DOC) con i quali interagisce. Gli input indiretti possono essere valori di ritorno dei metodi del DOC, parametri aggiornati, errori o eccezioni sollevate dal DOC.
In presenza di input indiretti la verifica del comportamento del SUT richiede di sostituire i DOC reali con Test Double che immettano gli input desiderati nel SUT. Test Double con questo scopo prendono il nome di stub object: sostituiscono un componente reale, da cui dipende il SUT, e forniscono risposte (input) “preconfezionate” alle sole chiamate fatte durante il testing. L’utilizzo di stub consente al test di forzare la realizzazione di determinati scenari particolari o di interesse specifico.
Senza Mockito | Con Mockito |
|
|
Mock Objects
Il comportamento del SUT può includere azioni che non possono essere osservate attraverso la sua API pubblica, ma che sono osservate o sperimentate da altri sistemi o componenti dell’applicazione. Tali attività ricadono sotto il nome di output indiretti del SUT. Gli output indiretti possono includere chiamate a metodi di un altro componente, record inseriti in un database, record scritti su un file etc.
Testare il comportamento del SUT può voler dire anche verificare che gli output indiretti siano quelli corretti e a tale scopo servono punti di osservazione appropriati. Un punto di osservazione è un modo con cui il test può ispezionare lo stato post-exercise del SUT. I punti di osservazione utili a verificare gli output indiretti sono costituiti da Test Double che prendono il nome di mock object. Questi intercettano gli output indiretti del SUT nella fase di exercise e permettono di confrontarli con gli output attesi in un momento successivo (i.e. la fase di verifying).
Un mock object è dunque utilizzato per instrumentare e controllare le chiamate fatte dal SUT. In genere, l’oggetto Mock include anche la funzionalità di uno Stub; deve infatti essere in grado di restituire valori al SUT, anche se l’enfasi è posta sulla verifica delle chiamate effettuate e non dal loro risultato.
Senza Mockito | Con Mockito |
|
|
Spy objects
Un altro modo per implementare punti di osservazione che controllino e instrumentino le chiamate effettuate dal SUT su determinati DOC sono gli spy object.
A differenza dei mock, questi sono costruiti a partire da oggetti reali.
Successivamente alla fase d’interazione con il SUT (exercise), durante la fase di verifica dei risultati (verify), il test confronta le chiamate effettuate dal SUT sul Test Spy con il comportamento desiderato (expected).
Senza Mockito | Con Mockito |
|
|
Fake Objects
Un fake object è un oggetto reale che implementa a tutti gli effetti le funzionalità del DOC, ma per farlo impiega una qualche “scorciatoia” in una maniera che non risulterebbe applicabile ad un contesto di produzione e.g. database in memoria invece di un database reale, soluzioni inefficienti, parti di codice open source utilizzabili solo in fase di testing.
Riepilogo
La tabella sottostante fornisce un riepilogo di ciò che rappresenta ciascuna variante dei Test Double.
Test Double | Purpose | Has behavior? | Injects Indirect Inputs into SUT |
Handles Indirect Outputs of SUT |
Values Provided by Test(er) |
Dummy Object | Utilizzato come segnaposto quando è necessario passare un argomento a un metodo | NO | NO, mai usato | NO, mai usato | Nessuno |
Stub Object | Fornisce risposte preconfezionate alle sole chiamate fatte durante il testing | SI | SI | NO, li ignora | Input indiretti per il SUT |
Mock Object | Instrumentare e controllare le chiamate | SI | Opzionale | Verifica la correttezza rispetto alle aspettative. | Input indiretti per il SUT (opzionali) e output indiretti attesi dal SUT |
Spy Object | Instrumentare e controllare le chiamate ad oggetti reali | SI | Opzionale | Li cattura per una verifica successiva | Input indiretti per il SUT (opzionali) |
Fake Object | Permette di eseguire test che altrimenti sarebbero impossibili o avrebbero effetti collaterali indesiderati (es test molto lenti) | SI | NO | Li utilizza | Nessuno |
Mockito
Mockito è un framework di testing open source per Java rilasciato sotto la licenza MIT.
Il framework facilita di gran lunga la creazione di mock objects e in generale di tutti i tipi di Test Double, permettendo quindi di concentrarsi sulla scrittura della logica di testing.
Inoltre, l’impiego di mockito aumenta notevolmente la leggibilità dei test.
Creare Test Double
Mockito mette a disposizione principalmente due metodi per creare Test Double: il metodo mock()
e il metodo spy()
.
Il metodo mock()
è usato per creare Test Double (dummy, stub o mock objects) a partire da una determinata classe o interfaccia: l’oggetto creato si presenterà con la stessa interfaccia (metodi e firme di questi ultimi) del tipo specificato in fase di costruzione.
Di default, per ogni metodo dell’oggetto reale, il Test Double creato fornisce un’implementazione minimale.
Questo si limiterà a restituirà dei valori di default per il tipo di ritorno del metodo oppure a non fare nulla se il metodo ritorna void
.
Ad esempio, se si crea un oggetto con mock()
a partire da una classe che ha un metodo getValue()
che restituisce un int
, il metodo getValue()
del Test Double restituirà 0, che è il valore predefinito per un int
in Java.
Il Test Double può essere configurato, mediante opportuna operazione di stubbing anche per restituire valori specifici o lanciare eccezioni qualora vengano chiamati determinati metodi.
Il metodo spy()
viene utilizzato per creare spy objects a partire da oggetti reali.
Quello che si ottiene è un oggetto che ha le stesse funzionalità dell’oggetto originale, ma che può essere utilizzato per fare il “tracciamento” delle chiamate ai suoi metodi e per verificare che esse vengano portate a termine come previsto.
A differenza degli oggetti creati con il metodo mock()
, uno spy continuerà a chiamare il metodo reale, a meno che non si specifichi il contrario.
Stubbing
when(mockedObj.methodname(args)).thenXXX(values);
- args: values, matchers, argumentCaptor
- matchers: anyInt(), argThat(is(closeTo(1.0, 0.001)))
- thenXXX: thenReturn, thenThrows, thenAnswer, thenCallRealMethod
Il metodo when()
insieme ai vari thenXXX()
(es thenReturn()
, thenThrow()
) è usato per specificare il comportamento di un Test Double (stub mock o spy obj) quando viene chiamato un suo determinato metodo.
Si supponga, ad esempio, di avere una classe Foo con un metodo getValue()
che restituisce un int
.
Per fare in modo che restituisca un valore diverso dallo 0 (default per int), è possibile scrivere:
Foo foo = mock(Foo.class);
when(foo.getValue()).thenReturn(42);
Da questo momento in poi, il metodo getValue()
del Test Double restituirà l’intero 42 ogni volta che verrà chiamato.
Ovviamente il metodo when()
può essere usato per specificare il comportamento di qualsiasi metodo del Test Double.
Se quindi viene scritto:
Foo foo = mock(Foo.class);
when(foo.someMethod()).thenThrow(new SomeException());
il Test Double lancerà un’eccezione di tipo SomeException
quando viene chiamato il suo metodo someMethod()
.
Questo permette, per esempio, di testare il comportamento del nostro codice quando viene lanciata un’eccezione senza dover implementare l’oggetto reale.
Anche i metodi del tipo doXXX()
(es. doReturn()
, doThrow()
, doAnswer()
, doNothing()
) sono usati per specificare il comportamento del Test Double quando viene chiamato un suo metodo.
Tuttavia, a differenza del metodo when()
, questi possono essere usati anche per specificare il comportamento di un metodo che ha come tipo di ritorno void
; è consigliabile usarli solo in questo caso, oppure in tutti i casi in cui utilizzare il metodo when()
risulterebbe difficile (e.g. metodi con tipi di ritorno non banali come Optional
).
Questi metodi si utilizzano come segue:
doXXX(values).when(mockedObj).methodname(args)
Verifying
Con oggetti di tipo mock o spy si desidera spesso verificare l’occorrenza di una chiamata con certi parametri.
Mockito permette di farlo con il metodo verify()
: è possibile verificare che un metodo sia stato chiamato, con quali parametri e per quante volte.
verify(mockedclass, howMany).methodname(args)
Il parametro howMany del metodo verify specifica il numero di volte che il metodo associato all’oggetto mockato deve essere chiamato durante l’esecuzione del test.
Le possibili opzioni sono:
-
times(n)
: verifica chemethodname()
sia stato chiamato esattamenten
volte; -
never()
: verifica chemethodname()
non sia mai stato chiamato; -
atLeastOnce()
: verifica chemethodname()
sia stato chiamato almeno una volta; -
atLeast(n)
: verifica chemethodname()
sia stato chiamato almenon
volte; -
atMost(n)
: verifica chemethodname()
sia stato chiamato al massimon
volte.
Se si desidera verificare l’ordine delle occorrenze delle chiamate ai metodi di un oggetto, si può utilizzare il metodo inOrder()
:
InOrder inO = inOrder(mock1, mock2, ...)
inO.verify...
È possibile anche catturare un parametro per farci delle asserzioni.
ArgumentCaptor<Person> arg = ArgumentCaptor.forClass(Person.class);
verify(mock).doSomething(arg.capture());
assertEquals("John", arg.getValue().getName());
Argument Matchers
Quando si esegue un’operazione di stubbing oppure quando si verifica la chiamata a un metodo, al posto di specificare i valori precisi (values) si può utilizzare quello che è un argument matcher.
Questo agisce come un segnaposto che corrisponde a qualsiasi valore corretto (i.e. che soddisfa la condizione di match), consentendo di specificare il comportamento senza dover conoscere il valore esatto dell’argomento che sarà passato al metodo.
Alcuni possibili matcher sono:
-
any()
,anyInt()
,anyString()
, etc.: questi metodi sono usati per creare degli argument matcher, che permettono di specificare che un particolare argomento del metodo può essere qualsiasi valore di un particolare tipo. Per esempio, si può utilizzareanyInt()
per specificare che un argomento può essere un qualsiasi valore int. Questo risulta utile qualora si desideri fare lo stub di un metodo che restituisca un valore indipendentemente dagli argomenti passatigli. -
eq()
: questo metodo viene utilizzato per creare un argument matcher che corrisponde a un valore specifico. Per esempio, si può utilizzareeq(42)
per specificare che un argomento deve avere il valore 42 per poter essere confrontato. Ciò è utile quando si vuole fare lo stub di un metodo in modo che questo restituisca un valore solo quando viene chiamato con argomenti specifici. -
Il metodo
argThat()
è un modo più generale per specificare argument matchers. Permette di creare matcher personalizzati implementando l’interfacciaArgumentMatcher
. Questa definisce un metodomatches()
che può essere utilizzato per determinare se un particolare argomento corrisponde al matcher. Tale metodologia risulta utile quando si vuole abbinare gli argomenti in maniere più complesse o articolate rispetto ai matcher di argomenti di tipoany()
.
Reset a Test Double
Infine, il metodo reset()
è usato per ripristinare un Test Double al suo stato iniziale, cancellando qualsiasi metodo che era stato precedentemente ridefinito.
È utile quando si vuole riutilizzare un Test Double in più test.
Esempio di testing con pattern OBSERVER (PULL)
@Test
void modelTest {
// setup
Model model = new Model();
Observer obs = mock(Observer.class);
Observer obs1 = mock(Observer.class);
// exercise
model.addObserver(obs);
model.addObserver(obs1);
model.setTemp(42.0, scale);
// verify
verify(obs).update(eq(model), eq("42.0"));
verify(obs1).update(eq(model), eq("42.0"));
}
Test di un observer con un modello non generico, ma di cui si ha solo interfaccia di cui viene fornita una versione dummy:
@Test
void observerTest {
abstract class MockObservableIModel extends Observable implements Model {};
MockOBservableIModel model = mock(MockObservableIModel.class);
when(model.getTemp()).thenReturn(42.42);
observer.update(model, null);
verify(model).getTemp();
assertThat(val).isCloseTo(42.42, Offset.offset(.01));
}