Costruisci con fiducia: una guida ai test JUnit
Pubblicato: 2022-03-11Con il progresso della tecnologia e del settore che passa dal modello a cascata all'Agile e ora al DevOps, le modifiche e i miglioramenti in un'applicazione vengono implementati nella produzione non appena vengono apportati. Con il codice che viene distribuito alla produzione così velocemente, dobbiamo essere certi che le nostre modifiche funzionino e anche che non interrompano alcuna funzionalità preesistente.
Per creare questa fiducia, dobbiamo disporre di un framework per i test di regressione automatici. Per eseguire test di regressione, ci sono molti test che dovrebbero essere eseguiti da un punto di vista a livello di API, ma qui tratteremo due tipi principali di test:
- Test unitario , in cui un determinato test copre l'unità più piccola di un programma (una funzione o una procedura). Potrebbe o meno richiedere alcuni parametri di input e potrebbe restituire o meno alcuni valori.
- Test di integrazione , in cui le singole unità vengono testate insieme per verificare se tutte le unità interagiscono tra loro come previsto.
Sono disponibili numerosi framework per ogni linguaggio di programmazione. Ci concentreremo sulla scrittura di unità e test di integrazione per un'app Web scritta nel framework Spring di Java.
La maggior parte delle volte, scriviamo metodi in una classe e questi, a loro volta, interagiscono con metodi di un'altra classe. Nel mondo odierno, specialmente nelle applicazioni aziendali, la complessità delle applicazioni è tale che un singolo metodo potrebbe chiamare più di un metodo di più classi. Quindi, quando scriviamo lo unit test per un tale metodo, abbiamo bisogno di un modo per restituire dati presi in giro da quelle chiamate. Questo perché l'intento di questo unit test è di testare solo un metodo e non tutte le chiamate effettuate da questo particolare metodo.
Passiamo ai test di unità Java in primavera utilizzando il framework JUnit. Inizieremo con qualcosa di cui potresti aver sentito parlare: beffardo.
Che cos'è il beffardo e quando entra in scena?
Supponiamo di avere una classe, CalculateArea
, che ha una funzione calculateArea(Type type, Double... args)
che calcola l'area di una forma del tipo specificato (cerchio, quadrato o rettangolo).
Il codice funziona in questo modo in una normale applicazione che non utilizza l'iniezione di dipendenza:
public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; CalculateArea(SquareService squareService, RectangleService rectangeService, CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { switch (type) { case RECTANGLE: if(r.length >=2) return rectangleService.area(r[0],r[1]); else throw new RuntimeException("Missing required params"); case SQUARE: if(r.length >=1) return squareService.area(r[0]); else throw new RuntimeException("Missing required param"); case CIRCLE: if(r.length >=1) return circleService.area(r[0]); else throw new RuntimeException("Missing required param"); default: throw new RuntimeException("Operation not supported"); } } }
public class SquareService { public Double area(double r) { return r * r; } }
public class RectangleService { public Double area(Double r, Double h) { return r * h; } }
public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
public enum Type { RECTANGLE,SQUARE,CIRCLE; }
Ora, se vogliamo testare la funzione calculateArea()
della classe CalculateArea
, il nostro motivo dovrebbe essere quello di verificare se i casi di switch
e le condizioni di eccezione funzionano. Non dovremmo verificare se i servizi di forma stanno restituendo i valori corretti, perché come accennato in precedenza, il motivo del test unitario di una funzione è testare la logica della funzione, non la logica delle chiamate che la funzione sta effettuando.
Quindi prenderemo in giro i valori restituiti dalle singole funzioni di servizio (es rectangleService.area()
e testeremo la funzione chiamante (es CalculateArea.calculateArea()
) in base a quei valori presi in giro.
Un semplice test case per il servizio rettangolo, testare calculateArea()
chiama effettivamente rectangleService.area()
con i parametri corretti, sarebbe simile a questo:
import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; public class CalculateAreaTest { RectangleService rectangleService; SquareService squareService; CircleService circleService; CalculateArea calculateArea; @Before public void init() { rectangleService = Mockito.mock(RectangleService.class); squareService = Mockito.mock(SquareService.class); circleService = Mockito.mock(CircleService.class); calculateArea = new CalculateArea(squareService,rectangleService,circleService); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Due linee principali da notare qui sono:
-
rectangleService = Mockito.mock(RectangleService.class);
—Questo crea una presa in giro, che non è un oggetto reale, ma una presa in giro. -
Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d);
—Questo dice che, quando viene deriso, e il metodoarea
dell'oggettorectangleService
viene chiamato con i parametri specificati, quindi restituisce20d
.
Ora, cosa succede quando il codice sopra fa parte di un'applicazione Spring?
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Component public class CalculateArea { SquareService squareService; RectangleService rectangleService; CircleService circleService; public CalculateArea(@Autowired SquareService squareService, @Autowired RectangleService rectangeService, @Autowired CircleService circleService) { this.squareService = squareService; this.rectangleService = rectangeService; this.circleService = circleService; } public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
Qui abbiamo due annotazioni per il framework Spring sottostante da rilevare al momento dell'inizializzazione del contesto:
-
@Component
: crea un bean di tipoCalculateArea
-
@Autowired
: cerca i beanrectangleService
,squareService
ecircleService
e li inserisce nel beancalculatedArea
Allo stesso modo, creiamo bean anche per altre classi:
import org.springframework.stereotype.Service; @Service public class SquareService { public Double area(double r) { return r*r; } }
import org.springframework.stereotype.Service; @Service public class CircleService { public Double area(Double r) { return Math.PI * r * r; } }
import org.springframework.stereotype.Service; @Service public class RectangleService { public Double area(Double r, Double h) { return r*h; } }
Ora, se eseguiamo i test, i risultati sono gli stessi. Abbiamo usato l'iniezione del costruttore qui e, fortunatamente, non cambiamo il nostro test case.
Ma c'è un altro modo per iniettare i bean dei servizi quadrato, cerchio e rettangolo: iniezione di campo. Se lo usiamo, il nostro test case avrà bisogno di alcune piccole modifiche.
Non entreremo nella discussione su quale meccanismo di iniezione sia migliore, poiché non rientra nell'ambito dell'articolo. Ma possiamo dire questo: indipendentemente dal tipo di meccanismo utilizzato per iniettare i bean, c'è sempre un modo per scrivere i test JUnit per esso.
Nel caso dell'iniezione di campo, il codice va in questo modo:
@Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
Nota: poiché utilizziamo l'iniezione di campo, non è necessario un costruttore parametrizzato, quindi l'oggetto viene creato utilizzando quello predefinito e i valori vengono impostati utilizzando il meccanismo di iniezione di campo.
Il codice per le nostre classi di servizio rimane lo stesso di sopra, ma il codice per la classe di test è il seguente:
public class CalculateAreaTest { @Mock RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Before public void init() { MockitoAnnotations.initMocks(this); } @Test public void calculateRectangleAreaTest() { Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Alcune cose vanno diversamente qui: non le basi, ma il modo in cui le raggiungiamo.
Primo, il modo in cui prendiamo in giro i nostri oggetti: usiamo le annotazioni @Mock
insieme a initMocks()
per creare mock. In secondo luogo, iniettiamo mock nell'oggetto reale usando @InjectMocks
insieme a initMocks()
.
Questo viene fatto solo per ridurre il numero di righe di codice.
Cosa sono i corridori di prova e quali tipi di corridori ci sono?
Nell'esempio sopra, il corridore di base utilizzato per eseguire tutti i test è BlockJUnit4ClassRunner
che rileva tutte le annotazioni ed esegue tutti i test di conseguenza.
Se desideriamo qualche funzionalità in più, potremmo scrivere un corridore personalizzato. Ad esempio, nella classe di test sopra, se vogliamo saltare la riga MockitoAnnotations.initMocks(this);
quindi potremmo usare un corridore diverso che è costruito su BlockJUnit4ClassRunner
, ad esempio MockitoJUnitRunner
.
Usando MockitoJUnitRunner
, non abbiamo nemmeno bisogno di inizializzare i mock e iniettarli. Ciò verrà fatto dallo stesso MockitoJUnitRunner
semplicemente leggendo le annotazioni.
(C'è anche SpringJUnit4ClassRunner
, che inizializza l' ApplicationContext
necessario per i test di integrazione Spring, proprio come un ApplicationContext
viene creato all'avvio di un'applicazione Spring. Questo lo tratteremo più avanti.)
Scherzo parziale
Quando vogliamo che un oggetto nella classe test deride alcuni metodi, ma chiama anche alcuni metodi effettivi, allora abbiamo bisogno di una presa in giro parziale. Ciò si ottiene tramite @Spy
in JUnit.
A differenza dell'utilizzo di @Mock
, con @Spy
, viene creato un oggetto reale, ma i metodi di quell'oggetto possono essere presi in giro o possono essere effettivamente chiamati, qualunque cosa ci serva.

Ad esempio, se il metodo area
nella classe RectangleService
chiama un metodo extra log()
e vogliamo effettivamente stampare quel log, il codice cambia in qualcosa di simile al seguente:
@Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println("skip this"); } }
Se cambiamo l'annotazione @Mock
di rectangleService
in @Spy
, e apportiamo anche alcune modifiche al codice come mostrato di seguito, nei risultati vedremmo effettivamente la stampa dei log, ma il metodo area()
verrà deriso. Cioè, la funzione originale viene eseguita esclusivamente per i suoi effetti collaterali; i suoi valori di ritorno sono sostituiti da quelli derisi.
@RunWith(MockitoJUnitRunner.class) public class CalculateAreaTest { @Spy RectangleService rectangleService; @Mock SquareService squareService; @Mock CircleService circleService; @InjectMocks CalculateArea calculateArea; @Test public void calculateRectangleAreaTest() { Mockito.doCallRealMethod().when(rectangleService).log(); Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d); Double calculatedArea = this.calculateArea.calculateArea(Type.RECTANGLE, 5.0d, 4.0d); Assert.assertEquals(new Double(20d),calculatedArea); } }
Come procediamo per testare un Controller
o un RequestHandler
?
Da quanto abbiamo appreso sopra, il codice di test di un controller per il nostro esempio sarebbe qualcosa di simile al seguente:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; @Controller public class AreaController { @Autowired CalculateArea calculateArea; @RequestMapping(value = "api/area", method = RequestMethod.GET) @ResponseBody public ResponseEntity calculateArea( @RequestParam("type") String type, @RequestParam("param1") String param1, @RequestParam(value = "param2", required = false) String param2 ) { try { Double area = calculateArea.calculateArea( Type.valueOf(type), Double.parseDouble(param1), Double.parseDouble(param2) ); return new ResponseEntity(area, HttpStatus.OK); } catch (Exception e) { return new ResponseEntity(e.getCause(), HttpStatus.INTERNAL_SERVER_ERROR); } } }
import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.MockitoJUnitRunner; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @RunWith(MockitoJUnitRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; @Test public void calculateAreaTest() { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); ResponseEntity responseEntity = areaController.calculateArea("RECTANGLE", "5", "4"); Assert.assertEquals(HttpStatus.OK,responseEntity.getStatusCode()); Assert.assertEquals(20d,responseEntity.getBody()); } }
Osservando il codice di test del controller sopra, funziona bene, ma presenta un problema di base: verifica solo la chiamata al metodo, non la chiamata API effettiva. Mancano tutti quei casi di test in cui i parametri API e lo stato delle chiamate API devono essere testati per input diversi.
Questo codice è migliore:
import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) public class AreaControllerTest { @Mock CalculateArea calculateArea; @InjectMocks AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { Mockito .when(calculateArea.calculateArea(Type.RECTANGLE,5.0d, 4.0d)) .thenReturn(20d); mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE¶m1=5¶m2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }
Qui possiamo vedere come MockMvc
assume il compito di eseguire chiamate API effettive. Ha anche alcuni abbinatori speciali come status()
e content()
che semplificano la convalida del contenuto.
Test di integrazione Java con JUnit e Mocks
Ora che sappiamo che le singole unità del codice funzionano, assicuriamoci che interagiscano anche tra loro come previsto.
Innanzitutto, è necessario creare un'istanza di tutti i bean, le stesse cose che si verificano al momento dell'inizializzazione del contesto Spring durante l'avvio dell'applicazione.
Per questo, definiamo tutti i bean in una classe, diciamo TestConfig.java
:
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class TestConfig { @Bean public AreaController areaController() { return new AreaController(); } @Bean public CalculateArea calculateArea() { return new CalculateArea(); } @Bean public RectangleService rectangleService() { return new RectangleService(); } @Bean public SquareService squareService() { return new SquareService(); } @Bean public CircleService circleService() { return new CircleService(); } }
Ora vediamo come utilizziamo questa classe e scriviamo un test di integrazione:
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = {TestConfig.class}) public class AreaControllerIntegrationTest { @Autowired AreaController areaController; MockMvc mockMvc; @Before public void init() { mockMvc = standaloneSetup(areaController).build(); } @Test public void calculateAreaTest() throws Exception { mockMvc.perform( MockMvcRequestBuilders.get("/api/area?type=RECTANGLE¶m1=5¶m2=4") ) .andExpect(status().isOk()) .andExpect(content().string("20.0")); } }
Qui cambiano alcune cose:
-
@ContextConfiguration(classes = {TestConfig.class})
— indica al test case dove risiedono tutte le definizioni del bean. - Ora invece di
@InjectMocks
usiamo:
@Autowired AreaController areaController;
Tutto il resto rimane lo stesso. Se eseguiamo il debug del test, vedremmo che il codice viene effettivamente eseguito fino all'ultima riga del metodo area()
in RectangleService
dove viene calcolato il return r*h
. In altre parole, viene eseguita la logica aziendale effettiva.
Ciò non significa che non vi sia alcuna presa in giro delle chiamate di metodo o delle chiamate di database disponibili nei test di integrazione. Nell'esempio sopra, non è stato utilizzato alcun servizio o database di terze parti, quindi non è stato necessario utilizzare mock. Nella vita reale, tali applicazioni sono rare e spesso colpiamo un database o un'API di terze parti, o entrambi. In tal caso, quando creiamo il bean nella classe TestConfig
, non creiamo l'oggetto reale, ma uno deriso, e lo usiamo dove necessario.
Bonus: come creare dati di test su oggetti di grandi dimensioni
Spesso ciò che impedisce agli sviluppatori back-end di scrivere test di unità o integrazione sono i dati di test che dobbiamo preparare per ogni test.
Normalmente se i dati sono abbastanza piccoli, con una o due variabili, è facile creare semplicemente un oggetto di una classe di dati di test e assegnare alcuni valori.
Ad esempio, se ci aspettiamo che un oggetto deriso restituisca un altro oggetto, quando una funzione viene chiamata sull'oggetto deriso, faremmo qualcosa del genere:
Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);
E quindi per utilizzare questo oggetto, faremmo qualcosa del genere:
Mockito.when(service.method(arguments...)).thenReturn(object);
Questo va bene negli esempi JUnit precedenti, ma quando le variabili membro nella classe Class1
sopra continuano ad aumentare, l'impostazione dei singoli campi diventa piuttosto problematica. A volte può anche accadere che una classe abbia un altro membro di classe non primitivo definito. Quindi, la creazione di un oggetto di quella classe e l'impostazione dei singoli campi richiesti aumenta ulteriormente lo sforzo di sviluppo solo per realizzare alcuni standard.
La soluzione è generare uno schema JSON della classe sopra e aggiungere i dati corrispondenti nel file JSON una volta. Ora nella classe di test in cui creiamo l'oggetto Class1
, non è necessario creare l'oggetto manualmente. Invece, leggiamo il file JSON e, utilizzando ObjectMapper
, lo mappiamo nella classe Class1
richiesta:
ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get("src/test/resources/"+fileName)) ), Class1.class );
Questo è uno sforzo una tantum per creare un file JSON e aggiungere valori ad esso. Eventuali nuovi test successivi possono utilizzare una copia di quel file JSON con campi modificati in base alle esigenze del nuovo test.
Fondamenti di JUnit: approcci multipli e abilità trasferibili
È chiaro che ci sono molti modi per scrivere unit test Java a seconda di come scegliamo di iniettare i bean. Sfortunatamente, la maggior parte degli articoli sull'argomento tende a presupporre che ci sia un solo modo, quindi è facile confondersi, specialmente quando si lavora con codice che è stato scritto con un presupposto diverso. Si spera che il nostro approccio qui faccia risparmiare tempo agli sviluppatori nel capire il modo corretto di prendere in giro e quale test runner usare.
Indipendentemente dal linguaggio o dal framework che utilizziamo, forse anche qualsiasi nuova versione di Spring o JUnit, la base concettuale rimane la stessa spiegata nel tutorial JUnit sopra. Buon test!