Construiește cu încredere: un ghid pentru testele JUnit
Publicat: 2022-03-11Odată cu evoluția tehnologiei și a industriei care trec de la modelul cascadă la Agile și acum la DevOps, modificările și îmbunătățirile unei aplicații sunt implementate în producție în momentul în care sunt realizate. Odată cu implementarea codului în producție atât de rapid, trebuie să fim încrezători că modificările noastre funcționează și, de asemenea, că nu distrug nicio funcționalitate preexistentă.
Pentru a construi această încredere, trebuie să avem un cadru pentru testarea regresiei automate. Pentru a efectua teste de regresie, există multe teste care ar trebui efectuate din punct de vedere la nivel de API, dar aici vom acoperi două tipuri majore de teste:
- Testarea unitară , unde orice test dat acoperă cea mai mică unitate a unui program (o funcție sau o procedură). Poate sau nu să ia niște parametri de intrare și poate returna sau nu unele valori.
- Testare de integrare , în care unitățile individuale sunt testate împreună pentru a verifica dacă toate unitățile interacționează între ele așa cum era de așteptat.
Există numeroase cadre disponibile pentru fiecare limbaj de programare. Ne vom concentra pe testarea unității de scriere și a integrării pentru o aplicație web scrisă în cadrul Spring din Java.
De cele mai multe ori, scriem metode într-o clasă, iar acestea, la rândul lor, interacționează cu metodele unei alte clase. În lumea de astăzi, în special în aplicațiile de întreprindere, complexitatea aplicațiilor este de așa natură încât o singură metodă poate apela mai mult de o metodă din mai multe clase. Deci, atunci când scriem testul unitar pentru o astfel de metodă, avem nevoie de o modalitate de a returna datele batjocorite din acele apeluri. Acest lucru se datorează faptului că intenția acestui test unitar este de a testa o singură metodă și nu toate apelurile pe care le face această metodă specială.
Să trecem la testarea unitară Java în Spring folosind cadrul JUnit. Vom începe cu ceva despre care poate ați auzit: batjocură.
Ce este batjocorirea și când apare în imagine?
Să presupunem că aveți o clasă, CalculateArea
, care are o funcție calculateArea(Type type, Double... args)
care calculează aria unei forme de tipul dat (cerc, pătrat sau dreptunghi.)
Codul merge cam așa într-o aplicație normală care nu utilizează injecția de dependență:
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; }
Acum, dacă dorim să testăm unitar funcția calculateArea()
din clasa CalculateArea
, atunci motivul nostru ar trebui să fie să verificăm dacă cazurile de switch
și condițiile de excepție funcționează. Nu ar trebui să testăm dacă serviciile de formă returnează valorile corecte, deoarece așa cum am menționat mai devreme, motivul testării unitare a unei funcții este de a testa logica funcției, nu logica apelurilor pe care funcția le face.
Deci vom bate joc de valorile returnate de funcțiile de serviciu individuale (de exemplu rectangleService.area()
și vom testa funcția de apelare (de exemplu CalculateArea.calculateArea()
) pe baza acelor valori batjocorite.
Un caz de testare simplu pentru serviciul dreptunghi - testarea că calculateArea()
apelează într-adevăr rectangleService.area()
cu parametrii corecti - ar arăta astfel:
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); } }
Două linii principale de remarcat aici sunt:
-
rectangleService = Mockito.mock(RectangleService.class);
— Acest lucru creează o simulare, care nu este un obiect real, ci unul batjocorit. -
Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d);
—Aceasta spune că, atunci când este batjocorită și metodaarea
a obiectuluirectangleService
este apelată cu parametrii specificați, apoi returnează20d
.
Acum, ce se întâmplă când codul de mai sus face parte dintr-o aplicație 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) } }
Aici avem două adnotări pentru cadru Spring subiacent pe care să le detectăm în momentul inițializării contextului:
-
@Component
: creează un bean de tipCalculateArea
-
@Autowired
: caută beansrectangleService
,squareService
șicircleService
și le injectează în beancalculatedArea
În mod similar, creăm fasole și pentru alte clase:
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; } }
Acum, dacă rulăm testele, rezultatele sunt aceleași. Am folosit injectarea constructorului aici și, din fericire, nu ne schimbați cazul de testare.
Dar există o altă modalitate de a injecta boabele de servicii pătrat, cerc și dreptunghi: injecția în câmp. Dacă folosim asta, atunci cazul nostru de testare va avea nevoie de câteva modificări minore.
Nu vom intra în discuția despre care mecanism de injecție este mai bun, deoarece acesta nu intră în domeniul de aplicare al articolului. Dar putem spune acest lucru: indiferent de tipul de mecanism pe care îl utilizați pentru a injecta boabele, există întotdeauna o modalitate de a scrie teste JUnit pentru acesta.
În cazul injectării în câmp, codul merge cam așa:
@Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
Notă: Deoarece folosim injecția de câmp, nu este nevoie de un constructor parametrizat, astfel încât obiectul este creat folosind cel implicit și valorile sunt setate folosind mecanismul de injectare de câmp.
Codul pentru clasele noastre de servicii rămâne același ca mai sus, dar codul pentru clasa de test este următorul:
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); } }
Câteva lucruri merg diferit aici: nu elementele de bază, ci modul în care le realizăm.
În primul rând, modul în care ne batem joc de obiectele noastre: folosim adnotări @Mock
împreună cu initMocks()
pentru a crea simulari. În al doilea rând, injectăm simulari în obiectul real folosind @InjectMocks
împreună cu initMocks()
.
Acest lucru se face doar pentru a reduce numărul de linii de cod.
Ce sunt alergătorii de testare și ce tipuri de alergători există?
În eșantionul de mai sus, runnerul de bază care este utilizat pentru a rula toate testele este BlockJUnit4ClassRunner
, care detectează toate adnotările și rulează toate testele în consecință.
Dacă dorim mai multe funcționalități, atunci putem scrie un alergător personalizat. De exemplu, în clasa de test de mai sus, dacă vrem să sărim peste linia MockitoAnnotations.initMocks(this);
atunci am putea folosi un alt runner care este construit pe BlockJUnit4ClassRunner
, de exemplu MockitoJUnitRunner
.
Folosind MockitoJUnitRunner
, nici măcar nu trebuie să inițializam false și să le injectăm. Acest lucru va fi făcut chiar de MockitoJUnitRunner
doar citind adnotări.
(Există și SpringJUnit4ClassRunner
, care inițializează ApplicationContext
necesar pentru testarea integrării Spring, la fel cum este creat un ApplicationContext
când pornește o aplicație Spring. Acest lucru îl vom trata mai târziu.)
Batjocură parțială
Când dorim ca un obiect din clasa de testare să bată joc de anumite metode, dar să apeleze și să apelăm la unele metode reale, atunci avem nevoie de batjocură parțială. Acest lucru se realizează prin @Spy
în JUnit.
Spre deosebire de folosirea @Mock
, cu @Spy
, se creează un obiect real, dar metodele acelui obiect pot fi batjocorite sau pot fi de fapt numite — orice avem nevoie.

De exemplu, dacă metoda area
din clasa RectangleService
apelează o metodă suplimentară log()
și de fapt dorim să tipărim acel jurnal, atunci codul se schimbă în ceva de genul următor:
@Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println("skip this"); } }
Dacă schimbăm adnotarea @Mock
a rectangleService
în @Spy
și facem, de asemenea, unele modificări de cod, așa cum se arată mai jos, atunci în rezultate vom vedea de fapt jurnalele tipărite, dar metoda area()
va fi batjocorită. Adică, funcția originală este rulată numai pentru efectele sale secundare; valorile sale returnate sunt înlocuite cu altele batjocoritoare.
@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); } }
Cum testam un Controller
sau RequestHandler
?
Din ceea ce am învățat mai sus, codul de testare al unui controler pentru exemplul nostru ar fi ceva ca cel de mai jos:
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()); } }
Privind codul de testare al controlerului de mai sus, funcționează bine, dar are o problemă de bază: testează doar apelul de metodă, nu apelul API real. Lipsesc toate acele cazuri de testare în care parametrii API și starea apelurilor API trebuie testate pentru diferite intrări.
Acest cod este mai bun:
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")); } }
Aici putem vedea cum MockMvc
își asumă sarcina de a efectua apeluri API reale. Are, de asemenea, niște potriviri speciale, cum ar fi status()
și content()
, care facilitează validarea conținutului.
Testarea integrării Java folosind JUnit și Mocks
Acum că știm că unitățile individuale ale codului funcționează, să ne asigurăm că și ele interacționează între ele așa cum ne așteptăm.
În primul rând, trebuie să instanțiem toate bean-urile, aceleași lucruri care se întâmplă la momentul inițializării contextului Spring în timpul pornirii aplicației.
Pentru aceasta, definim toate boabele dintr-o clasă, să spunem 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(); } }
Acum să vedem cum folosim această clasă și să scriem un test de integrare:
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")); } }
Câteva lucruri se schimbă aici:
-
@ContextConfiguration(classes = {TestConfig.class})
— aceasta indică cazul de testare unde se află toate definițiile bean-ului. - Acum, în loc de
@InjectMocks
, folosim:
@Autowired AreaController areaController;
Orice altceva rămâne la fel. Dacă depanăm testul, am vedea că codul rulează de fapt până la ultima linie a metodei area()
din RectangleService
unde se calculează return r*h
. Cu alte cuvinte, logica de afaceri reală rulează.
Acest lucru nu înseamnă că nu există nicio batjocură a apelurilor de metodă sau a apelurilor de baze de date disponibile în testarea integrării. În exemplul de mai sus, nu a fost folosit niciun serviciu sau bază de date terță parte, prin urmare nu a fost nevoie să folosim simulari. În viața reală, astfel de aplicații sunt rare și deseori vom accesa o bază de date sau un API terță parte sau ambele. În acest caz, când creăm bean-ul în clasa TestConfig
, nu creăm obiectul real, ci unul batjocorit și îl folosim oriunde este nevoie.
Bonus: Cum se creează date de testare pentru obiecte mari
Adesea, ceea ce îi oprește pe dezvoltatorii back-end să scrie teste de unitate sau de integrare sunt datele de testare pe care trebuie să le pregătim pentru fiecare test.
În mod normal, dacă datele sunt suficient de mici, având una sau două variabile, atunci este ușor să creați un obiect dintr-o clasă de date de testare și să atribuiți niște valori.
De exemplu, dacă ne așteptăm ca un obiect batjocorit să returneze un alt obiect, atunci când o funcție este apelată pe obiectul batjocorit, am face ceva de genul acesta:
Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);
Și apoi, pentru a folosi acest obiect, am face ceva de genul acesta:
Mockito.when(service.method(arguments...)).thenReturn(object);
Acest lucru este bine în exemplele JUnit de mai sus, dar când variabilele membre din clasa Class1
de mai sus continuă să crească, atunci setarea câmpurilor individuale devine destul de dificilă. Uneori s-ar putea întâmpla chiar ca o clasă să aibă un alt membru de clasă neprimitiv definit. Apoi, crearea unui obiect din acea clasă și setarea câmpurilor individuale necesare crește și mai mult efortul de dezvoltare doar pentru a realiza unele standarde.
Soluția este să generați o schemă JSON a clasei de mai sus și să adăugați datele corespunzătoare în fișierul JSON o dată. Acum, în clasa de testare în care creăm obiectul Class1
, nu trebuie să creăm manual obiectul. În schimb, citim fișierul JSON și, folosind ObjectMapper
, îl mapam în clasa Class1
necesară:
ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get("src/test/resources/"+fileName)) ), Class1.class );
Acesta este un efort unic de a crea un fișier JSON și de a adăuga valori acestuia. Orice teste noi ulterioare pot folosi o copie a acelui fișier JSON cu câmpuri modificate în funcție de nevoile noului test.
Bazele JUnit: abordări multiple și abilități transferabile
Este clar că există multe moduri de a scrie teste unitare Java, în funcție de modul în care alegem să injectăm fasole. Din păcate, majoritatea articolelor pe acest subiect tind să presupună că există o singură cale, așa că este ușor să fii confuz, mai ales când lucrezi cu cod care a fost scris sub o altă presupunere. Sperăm că abordarea noastră de aici economisește timp dezvoltatorilor în găsirea modului corect de batjocură și ce runner de testare să folosească.
Indiferent de limbajul sau cadrul pe care îl folosim – poate chiar orice versiune nouă a Spring sau JUnit – baza conceptuală rămâne aceeași așa cum este explicată în tutorialul JUnit de mai sus. Testare fericită!