Bauen Sie mit Zuversicht: Ein Leitfaden für JUnit-Tests
Veröffentlicht: 2022-03-11Mit der Weiterentwicklung von Technologie und Industrie, die vom Wasserfallmodell zu Agile und jetzt zu DevOps übergeht, werden Änderungen und Verbesserungen in einer Anwendung in der Minute, in der sie vorgenommen werden, in der Produktion bereitgestellt. Da der Code so schnell in der Produktion bereitgestellt wird, müssen wir sicher sein, dass unsere Änderungen funktionieren und auch keine bereits vorhandenen Funktionen beeinträchtigen.
Um dieses Vertrauen aufzubauen, brauchen wir einen Rahmen für automatische Regressionstests. Um Regressionstests durchzuführen, gibt es viele Tests, die aus Sicht der API-Ebene durchgeführt werden sollten, aber hier behandeln wir zwei Haupttypen von Tests:
- Unit-Tests , bei denen ein bestimmter Test die kleinste Einheit eines Programms (eine Funktion oder Prozedur) abdeckt. Es kann einige Eingabeparameter annehmen oder nicht und kann einige Werte zurückgeben oder nicht.
- Integrationstests , bei denen einzelne Einheiten gemeinsam getestet werden, um zu überprüfen, ob alle Einheiten wie erwartet miteinander interagieren.
Für jede Programmiersprache stehen zahlreiche Frameworks zur Verfügung. Wir werden uns auf das Schreiben von Unit- und Integrationstests für eine Web-App konzentrieren, die in Javas Spring-Framework geschrieben ist.
Meistens schreiben wir Methoden in einer Klasse, und diese wiederum interagieren mit Methoden einer anderen Klasse. In der heutigen Welt – insbesondere in Unternehmensanwendungen – ist die Komplexität von Anwendungen so groß, dass eine einzelne Methode möglicherweise mehr als eine Methode mehrerer Klassen aufruft. Wenn wir also den Komponententest für eine solche Methode schreiben, brauchen wir eine Möglichkeit, verspottete Daten von diesen Aufrufen zurückzugeben. Dies liegt daran, dass die Absicht dieses Komponententests darin besteht, nur eine Methode und nicht alle Aufrufe zu testen, die diese bestimmte Methode durchführt.
Lassen Sie uns in Spring mit dem JUnit-Framework in Java-Komponententests einsteigen. Wir beginnen mit etwas, von dem Sie vielleicht schon gehört haben: Spott.
Was ist Spott und wann kommt er ins Bild?
Angenommen, Sie haben eine Klasse, CalculateArea
, die eine Funktion calculateArea(Type type, Double... args)
hat, die die Fläche einer Form des angegebenen Typs (Kreis, Quadrat oder Rechteck) berechnet.
Der Code sieht in einer normalen Anwendung, die keine Abhängigkeitsinjektion verwendet, in etwa so aus:
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; }
Wenn wir nun die Funktion calculateArea()
der Klasse CalculateArea
Unit-testen wollen, dann sollte unser Motiv sein, zu prüfen, ob die switch
Cases und Exception-Bedingungen funktionieren. Wir sollten nicht testen, ob die Shape-Dienste die richtigen Werte zurückgeben, denn wie bereits erwähnt, besteht das Motiv des Unit-Tests einer Funktion darin, die Logik der Funktion zu testen, nicht die Logik der Aufrufe der Funktion.
Daher werden wir die von einzelnen Dienstfunktionen zurückgegebenen Werte (z. B. rectangleService.area()
und die aufrufende Funktion (z. B. CalculateArea.calculateArea()
) basierend auf diesen mockierten Werten testen.
Ein einfacher Testfall für den Rectangle-Dienst – der testet, dass calculateArea()
tatsächlich rectangleService.area()
mit den richtigen Parametern aufruft – würde so aussehen:
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); } }
Zwei Hauptlinien, die hier zu beachten sind, sind:
-
rectangleService = Mockito.mock(RectangleService.class);
– Dadurch wird ein Mock erstellt, der kein tatsächliches Objekt ist, sondern ein verspottetes. -
Mockito.when(rectangleService.area(5.0d,4.0d)).thenReturn(20d);
– Dies besagt, dass, wenn verspottet und diearea
-Methode desrectangleService
-Objekts mit den angegebenen Parametern aufgerufen wird, dann20d
zurückgegeben wird.
Was passiert nun, wenn der obige Code Teil einer Spring-Anwendung ist?
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) } }
Hier haben wir zwei Anmerkungen für das zugrunde liegende Spring-Framework, die zum Zeitpunkt der Kontextinitialisierung erkannt werden müssen:
-
@Component
: Erstellt eine Bean vom TypCalculateArea
-
@Autowired
: Sucht nach den BeansrectangleService
,squareService
undcircleService
und injiziert sie in die BeancalculatedArea
In ähnlicher Weise erstellen wir auch Beans für andere Klassen:
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; } }
Wenn wir jetzt die Tests durchführen, sind die Ergebnisse gleich. Wir haben hier die Konstruktorinjektion verwendet und ändern glücklicherweise unseren Testfall nicht.
Aber es gibt noch eine andere Möglichkeit, die Bohnen von Square-, Circle- und Rectangle-Services zu injizieren: Feldinjektion. Wenn wir das verwenden, muss unser Testfall geringfügig geändert werden.
Wir werden nicht auf die Diskussion darüber eingehen, welcher Injektionsmechanismus besser ist, da dies nicht im Rahmen des Artikels liegt. Aber wir können Folgendes sagen: Egal, welche Art von Mechanismus Sie verwenden, um Beans zu injizieren, es gibt immer eine Möglichkeit, JUnit-Tests dafür zu schreiben.
Im Fall der Feldinjektion lautet der Code etwa so:
@Component public class CalculateArea { @Autowired SquareService squareService; @Autowired RectangleService rectangleService; @Autowired CircleService circleService; public Double calculateArea(Type type, Double... r ) { // (same implementation as before) } }
Hinweis: Da wir die Feldinjektion verwenden, ist kein parametrisierter Konstruktor erforderlich, sodass das Objekt mithilfe des Standardkonstruktors erstellt und die Werte mithilfe des Feldinjektionsmechanismus festgelegt werden.
Der Code für unsere Serviceklassen bleibt derselbe wie oben, aber der Code für die Testklasse lautet wie folgt:
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); } }
Ein paar Dinge laufen hier anders: nicht die Grundlagen, sondern die Art und Weise, wie wir es erreichen.
Erstens die Art und Weise, wie wir unsere Objekte verspotten: Wir verwenden @Mock-Anmerkungen zusammen mit @Mock
initMocks()
, um Verspottungen zu erstellen. Zweitens injizieren wir Mocks in das eigentliche Objekt mit @InjectMocks
zusammen mit initMocks()
.
Dies geschieht nur, um die Anzahl der Codezeilen zu reduzieren.
Was sind Testläufer und welche Arten von Läufern gibt es?
Im obigen Beispiel ist BlockJUnit4ClassRunner
der grundlegende Runner, der zum Ausführen aller Tests verwendet wird, der alle Anmerkungen erkennt und alle Tests entsprechend ausführt.
Wenn wir etwas mehr Funktionalität wünschen, können wir einen benutzerdefinierten Läufer schreiben. Wenn wir beispielsweise in der obigen Testklasse die Zeile MockitoAnnotations.initMocks(this);
dann könnten wir einen anderen Runner verwenden, der auf BlockJUnit4ClassRunner
, zB MockitoJUnitRunner
.
Mit MockitoJUnitRunner
müssen wir Mocks nicht einmal initialisieren und injizieren. Dies wird von MockitoJUnitRunner
selbst erledigt, indem einfach Anmerkungen gelesen werden.
(Es gibt auch SpringJUnit4ClassRunner
, das den für Spring-Integrationstests erforderlichen ApplicationContext
initialisiert – genau wie ein ApplicationContext
erstellt wird, wenn eine Spring-Anwendung gestartet wird. Darauf gehen wir später ein.)
Teilweise Spott
Wenn wir möchten, dass ein Objekt in der Testklasse einige Methoden verspottet, aber auch einige tatsächliche Methoden aufruft, müssen wir teilweise verspotten. Dies wird über @Spy
in JUnit erreicht.
Im Gegensatz zur Verwendung von @Mock
wird mit @Spy
ein echtes Objekt erstellt, aber die Methoden dieses Objekts können verspottet oder tatsächlich aufgerufen werden – was auch immer wir brauchen.

Wenn beispielsweise die area
-Methode in der Klasse RectangleService
eine zusätzliche Methode log()
aufruft und wir dieses Protokoll tatsächlich drucken möchten, ändert sich der Code wie folgt:
@Service public class RectangleService { public Double area(Double r, Double h) { log(); return r*h; } public void log() { System.out.println("skip this"); } }
Wenn wir die Annotation @Mock
von rectangleService
in @Spy
und auch einige Codeänderungen wie unten gezeigt vornehmen, würden wir in den Ergebnissen tatsächlich sehen, dass die Protokolle gedruckt werden, aber die Methode area()
wird verspottet. Das heißt, die ursprüngliche Funktion wird nur wegen ihrer Nebeneffekte ausgeführt; seine Rückgabewerte werden durch verspottete ersetzt.
@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); } }
Wie testen wir einen Controller
oder RequestHandler
?
Nach dem, was wir oben gelernt haben, würde der Testcode eines Controllers für unser Beispiel etwa wie folgt aussehen:
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()); } }
Wenn man sich den obigen Controller-Testcode ansieht, funktioniert er gut, hat aber ein grundlegendes Problem: Er testet nur den Methodenaufruf, nicht den eigentlichen API-Aufruf. All jene Testfälle, bei denen die API-Parameter und der Status von API-Aufrufen für verschiedene Eingaben getestet werden müssen, fehlen.
Dieser Code ist besser:
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")); } }
Hier können wir sehen, wie MockMvc
die Aufgabe übernimmt, tatsächliche API-Aufrufe durchzuführen. Es hat auch einige spezielle Matcher wie status()
und content()
, die es einfach machen, den Inhalt zu validieren.
Java-Integrationstests mit JUnit und Mocks
Nachdem wir nun wissen, dass einzelne Einheiten des Codes funktionieren, stellen wir sicher, dass sie auch wie erwartet miteinander interagieren.
Zuerst müssen wir alle Beans instanziieren, die gleichen Dinge, die zum Zeitpunkt der Spring-Kontextinitialisierung während des Anwendungsstarts passieren.
Dazu definieren wir alle Beans in einer Klasse, sagen wir 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(); } }
Lassen Sie uns nun sehen, wie wir diese Klasse verwenden und einen Integrationstest schreiben:
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")); } }
Hier ändert sich einiges:
-
@ContextConfiguration(classes = {TestConfig.class})
– dies teilt dem Testfall mit, wo sich alle Bean-Definitionen befinden. - Jetzt verwenden wir anstelle von
@InjectMocks
:
@Autowired AreaController areaController;
Alles andere bleibt gleich. Wenn wir den Test debuggen, sehen wir, dass der Code tatsächlich bis zur letzten Zeile der area()
Methode in RectangleService
wird, wo return r*h
berechnet wird. Mit anderen Worten, die eigentliche Geschäftslogik läuft.
Dies bedeutet nicht, dass beim Integrationstest kein Mocking von Methodenaufrufen oder Datenbankaufrufen verfügbar ist. Im obigen Beispiel wurde kein Dienst oder keine Datenbank eines Drittanbieters verwendet, daher mussten wir keine Mocks verwenden. Im wirklichen Leben sind solche Anwendungen selten, und wir stoßen oft auf eine Datenbank oder eine Drittanbieter-API oder beides. In diesem Fall erstellen wir beim Erstellen der Bean in der TestConfig
-Klasse nicht das eigentliche Objekt, sondern ein nachgebildetes Objekt und verwenden es bei Bedarf.
Bonus: So erstellen Sie Testdaten für große Objekte
Was Backend-Entwickler beim Schreiben von Unit- oder Integrationstests oft abhält, sind die Testdaten, die wir für jeden Test vorbereiten müssen.
Wenn die Daten klein genug sind und eine oder zwei Variablen haben, ist es normalerweise einfach, einfach ein Objekt einer Testdatenklasse zu erstellen und einige Werte zuzuweisen.
Wenn wir beispielsweise erwarten, dass ein verspottetes Objekt ein anderes Objekt zurückgibt, würden wir beim Aufrufen einer Funktion für das verspottete Objekt etwa so vorgehen:
Class1 object = new Class1(); object.setVariable1(1); object.setVariable2(2);
Und dann, um dieses Objekt zu verwenden, würden wir so etwas tun:
Mockito.when(service.method(arguments...)).thenReturn(object);
In den obigen JUnit-Beispielen ist das in Ordnung, aber wenn die Member-Variablen in der obigen Class1
-Klasse weiter zunehmen, wird das Festlegen einzelner Felder ziemlich mühsam. Manchmal kann es sogar vorkommen, dass für eine Klasse ein anderes nicht-primitives Klassenmitglied definiert ist. Wenn Sie dann ein Objekt dieser Klasse erstellen und einzelne erforderliche Felder festlegen, wird der Entwicklungsaufwand weiter erhöht, nur um einige Standardbausteine zu erstellen.
Die Lösung besteht darin, ein JSON-Schema der oben genannten Klasse zu generieren und die entsprechenden Daten einmalig in der JSON-Datei hinzuzufügen. In der Testklasse, in der wir das Class1
Objekt erstellen, müssen wir das Objekt nicht manuell erstellen. Stattdessen lesen wir die JSON-Datei und ordnen sie mit ObjectMapper
der erforderlichen Class1
-Klasse zu:
ObjectMapper objectMapper = new ObjectMapper(); Class1 object = objectMapper.readValue( new String(Files.readAllBytes( Paths.get("src/test/resources/"+fileName)) ), Class1.class );
Dies ist ein einmaliger Aufwand, um eine JSON-Datei zu erstellen und ihr Werte hinzuzufügen. Alle neuen Tests danach können eine Kopie dieser JSON-Datei verwenden, wobei die Felder entsprechend den Anforderungen des neuen Tests geändert werden.
JUnit-Grundlagen: Mehrere Ansätze und übertragbare Fähigkeiten
Es ist klar, dass es viele Möglichkeiten gibt, Java-Komponententests zu schreiben, je nachdem, wie wir Beans injizieren. Leider gehen die meisten Artikel zu diesem Thema davon aus, dass es nur einen Weg gibt, sodass man leicht verwirrt wird, insbesondere wenn man mit Code arbeitet, der unter einer anderen Annahme geschrieben wurde. Hoffentlich spart unser Ansatz hier Entwicklern Zeit, um herauszufinden, wie man richtig mockt und welcher Test Runner zu verwenden ist.
Unabhängig von der Sprache oder dem Framework, das wir verwenden – vielleicht sogar jede neue Version von Spring oder JUnit – bleibt die konzeptionelle Basis die gleiche wie im obigen JUnit-Tutorial erklärt. Viel Spaß beim Testen!