Automatisches Testen des Controllers
https://bildung.social/@oerinformatik/
https://oer-informatik.de/sbb06_addresscontroller-test
tl/dr; (ca. 11 min Lesezeit): Es werden Unit-Tests für den REST-Controller erstellt. Dazu wird eine gemockte Umgebung konfiguriert. Das Testframework von Spring mit seinen zahllosen Klassen wird vorgestellt. Dieser Artikel ist ein Teil der Artikelserie zu einem Adressbuch-SpringBoot-Projekt. Weiter geht es dann mit der Bestimmung der Testabdeckung mit Hilfe des Frameworks JaCoCo. (Zuletzt geändert am 04.09.2023)
Der soeben erstellte Controller soll nun um isolierte Unit-Tests ergänzt werden. Es wird hierbei im ersten Schritt nur getestet, ob die Requests den korrekten Controller erreichen und dieser die korrekten Methoden des Repositories aufruft. Da dieser Test nur den Controller und nicht die Datenhaltungsschicht betreffen soll, werden an Stelle der Objekte für die Datenspeicherung (v.a. das Repository) Doubletten gestellt, die deren Funktion nachahmen (mocken).
Um einen Überblick zu erhalten, welche Methoden wir testen sollten, werfen wir nochmal einen Blick auf das Klassendiagramm des Repositories:
Es sollten für folgende Methoden Tests generiert werden:
die vier CRUD-Methoden (Create/Read/Update/Delete):
create:
post(new Address())
read:
get(id)
update:
put(address)
delete:
delete(id)
Darüber hinaus gibt es noch:
list()
Erstellen einer Test-Klasse für den AddressController
In jeder IDE lassen sich die Test-Klassen anders erstellen, in VSCode beispielsweise, in dem man in der zu testenden Klasse die Befehlsübersicht aufruft (str
-shift
-p
), dort “Java: Go to Test” …
und danach “Generate Testfile” auswählt:
Der korrekte Name und das Package werden schon vorausgewählt: kontrollieren, bestätigen!
Abschließend wir noch angeboten, für welche Methden Tests vorbereitet werden sollen:
Zunächst müssen wir die jUnit5-Integration in das Spring-Framework mit @ExtendWith(SpringExtension.class)
ergänzen und die Testmöglichkeiten des MVC-Frameworks mit @WebMvcTest
aktivieren. Daher müssen diese beiden Annotationen an der Klasse ergänzt werden:
@ExtendWith(SpringExtension.class)
@WebMvcTest(
includeFilters = @ComponentScan.Filter(
type = FilterType.ASSIGNABLE_TYPE,
classes=AddressController.class
))
public class AddressControllerTest {...}
Allein hierdurch werden eine Vielzahl an Imports nötig. Zum Abgleich: so müssen sie aussehen (und das ist nur der Anfang…):
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.context.annotation.FilterType;
Mocken des Repositories
Als Voraussetzung für den Test benötigen wir ein Umfeld (einen “Kontext”), in dem unsere HTTP-Requests aufgerufen werden. Über die Annotation @WebMvcTest
verknüpfen wir dieses Umfeld. Der Kontext soll auf fingierte HTTP-Aufrufe reagieren. Das wiederum übernimmt ein Objekt der Klasse MockMvc
. Über die Annotation @Autowired
weiß das SpringBoot-Framework, dass es selbst die zugehörigen Instanzen dieser Klassen erzeugen und verknüpfen muss.
Um den Controller unabhängig von dem Repository und Model testen zu können, muss das Repository gemockt werden - also ein Testdouble erstellt werden, was keinerlei Logik enthält, sondern einfach bei Aufruf einer Methode einen zuvor definierten Rückgabewert übergibt. Hierzu verwenden wir das Tool Mockito. Dass wir das Repository simulieren wollen, hatten wir ja bereits über @MockBean private AddressRepository addressRepository;
festgelegt.
Funktionsaufrufe nachahmen mit Mockito
Wie auf einen Funktionsaufruf reagiert werden soll können wir mit der Mockito-Methode when()
festlegen (die unglücklicherweise so heißt wie unser given/when/then
-Bereich in den Testmethoden, mit diesem aber nichts zu tun hat):
Die aufgerufene Methode kann hierbei direkt mit konkreten Parameterwerten genannt werden:
Wenn wir keine konkreten Parameter mocken wollen, sondern bei jedem Methodenaufruf den selben Rückgabewert simulieren wollen. kann dies mit any(KLASSENOBJEKT)
angegeben werden. Konkret sieht das so aus:
Die einzelnen Testmethoden
Wir ergänzen für jede HTTP-Request-Methode einen ersten Test der überprüft, ob:
- der korrekte HTTP-StatusCode,
- der korrekte Inhaltstyp (JSON) und
- der korrekte Inhalt
zurückgegeben wird.
Hierzu nutzen wir wieder die Aufteilung in given
, when
und then
.
Im folgenden müssen wir den HTTP-Request mocken. Der Ablauf nutzt wieder das Builder
-Pattern. Wer den Überblick behalten will, der kann gerne im angehängten UML-Klassendiagramm die Methoden und deren Rückgabewerte mitverfolgen.
Test der post()
-Methode
Am Beispiel eines Tests für die POST
-Methode zur Erzeugung neuer Ressourcen soll der Ablauf eines Tests exemplarisch dargestellt werden:
Vorbedingungen
//given
:- es wird ein beim Test zu speicherndes
Address
-Objekt erzeugt und als JSON-String serialisiert
Address testAddress = new Address(testFirstName, testLastName); String inputJson = new ObjectMapper().writeValueAsString(testAddress); //erzeugt JSON
- die
save()
-Methode des Repositories wird normalerweise aufgerufen. Diese Methode muss gemockt werden, da wir den Controller hier isoliert testen wollen. Das gemockte Repository soll beim Aufruf von save() immer mit demtestAdress
-Objekt antworten - unabhängig davon, was als Parameter übergeben wurde:
- es wird ein beim Test zu speicherndes
Ausführung
//when
:Hier wird ein gemockter Request erstellt. Im Gegensatz zu unseren Requests, die wir für die manuellen Tests per PowerShell oder Bash zusammengestellt hatten, lassen wir uns einen Request über das Builder-Pattern bauen:
Abgleich:
//then
:
- Der konfigurierteRequestBuilder
wird als Parameter anmockMVC.perform()
übergeben, dort ausgeführt und das Ergebnis des Aufrufs mit erwartetem Inhalt, Typ und Statuscode verglichen:
mockMvc.perform(testRequestBuilder)
.andExpect(status().isCreated())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.firstName", equalTo(testFirstName)))
.andExpect(jsonPath("$.lastName", equalTo(testLastName)));
Im Ganzen sieht diese Test-Methode für POST
folgendermaßen aus:
@Test
public void testPost() throws Exception {
//given
String testFirstName = "Herbert";
String testLastName = "Testkandidat";
Address testAddress = new Address(testFirstName, testLastName);
String inputJson = new ObjectMapper().writeValueAsString(testAddress); //erzeugt JSON
//Mocken des Repositories
when(addressRepository.save(any(Address.class))).thenReturn(testAddress);
//when
MockHttpServletRequestBuilder testRequestBuilder = post("/api/address")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(inputJson);
// then
mockMvc.perform(testRequestBuilder)
.andExpect(status().isCreated())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.firstName", equalTo(testFirstName)))
.andExpect(jsonPath("$.lastName", equalTo(testLastName)));
}
Die meisten Imports werden von den IDEs wieder gefunden, es gibt jedoch ein paar Knackpunkte, die oft händisch ergänzt werden müssen:
equalsTo()
ist eine statische Methode vonorg.hamcrest.Matchers
post()
(und später auchget()
usw.) sind statische Methoden vonorg.springframework.test.web.servlet.request.MockMvcRequestBuilders
content()
,status()
sind statische Methoden vonorg.springframework.test.web.servlet.result.MockMvcResultMatchers
Im Ganzen sehen die Imports dann so aus:
import java.util.ArrayList;
import java.util.Arrays;
import static org.hamcrest.Matchers.equalTo;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;
import com.fasterxml.jackson.databind.ObjectMapper;
Damit sollte es funktionieren. Leider reicht diese Erklärung nicht, um zu verstehen, was da genau passiert. Am Ende dieses Artikels habe ich mal einen Blick unter die Haube gewagt… aber erstmal wollen wir die Tests an laufen bekommen.
Ein guter Zeitpunkt, um schonmal zu schauen, ob der Test auch durchläuft. Menü: Anzeigen/Testen - und dort das “Play”-Symbol neben unserem gerade erstellten Test klicken:
List-Methode
Da die list()
-Methode des Controllers auf addressRepository.findAll()
zugreift müssen wir hier beim Mocken ansetzen.
Wir erzeugen den RequestBuilder über die get()
-Methode und überprüfen, ob der Statuscode 200 (“OK”) ist. Darüber hinaus stellt das JSON-Objekt, dass wir als Antwort erhalten, ein Array dar, deshalb müssen wir bei der Inhaltsprüfung die jeweilige Zeile ansprechen: "$[0].firstName"
Die sonstige Struktur des post()
-Tests bleibt weitgehend erhalten. Natürlich wieder im Dreisprung: given, when, then
!
@Test
public void testList() throws Exception {
//given
String testFirstName = "Herbert";
String testLastName = "Testkandidat";
//Mocken des Repositories
when(addressRepository.findAll()).thenReturn(
new ArrayList<Address>(Arrays.asList(
new Address(testFirstName,testLastName),
new Address(testFirstName,testLastName)
)));
//when
MockHttpServletRequestBuilder testRequestBuilder = get("/api/address")
.accept(MediaType.APPLICATION_JSON);
// then
mockMvc.perform(testRequestBuilder)
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$[0].firstName", equalTo(testFirstName)))
.andExpect(jsonPath("$[0].lastName", equalTo(testLastName)));
}
GET-Methode
Auch hier weichen nur die zu mockende Methode (findById()
) und der RequestBuilder-Aufruf (get()
) ab. Der Rest sollte uns bekannt vorkommen.
@Test
public void testGet() throws Exception{
//given
String testFirstName = "Herbert";
String testLastName = "Testkandidat";
//Mocken des Repositories
when(addressRepository.findById((long) 1)).thenReturn(Optional.of(new Address(testFirstName,testLastName)));
//when
MockHttpServletRequestBuilder testRequestBuilder = get("/api/address/{id}", 1)
.accept(MediaType.APPLICATION_JSON);
// then
mockMvc.perform(testRequestBuilder)
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.firstName", equalTo(testFirstName)))
.andExpect(jsonPath("$.lastName", equalTo(testLastName)));
}
Hier wird noch der Import für Optional
fällig:
PUT-Methode
Hier müssen zwei Methoden des Repositories gemockt werden: save()
und existsById()
@Test
public void testPut() throws Exception {
//given
String testFirstName = "Herbert";
String testLastName = "Testkandidat";
Long id = 1L;
Address testAddress = new Address(testFirstName,testLastName);
testAddress.setId(id);
String inputJson = new ObjectMapper().writeValueAsString(testAddress); //erzeugt JSON
//Mocken des Reposiroties
when(addressRepository.save(any(Address.class))).thenReturn(testAddress);
when(addressRepository.existsById(id)).thenReturn(true);
//when
MockHttpServletRequestBuilder testRequestBuilder = put("/api/address/"+id)
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(inputJson);
;
// then
mockMvc.perform(testRequestBuilder)
.andExpect(status().is2xxSuccessful())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.firstName", equalTo(testFirstName)))
.andExpect(jsonPath("$.lastName", equalTo(testLastName)));
}
Delete
Bei delete()
fallen die Tests deutlich schlanker aus, da wir bei Unit-Test zunächst nur den korrekten Aufruf der Repository-Methoden prüfen. Integrationstests wären hier im nächsten Schritt sicherlich angebracht, um das Zusammenspiel von Controller und Datenschicht zu testen.
@Test
public void testDelete() throws Exception {
//given
Long id = 1L;
//Mocken des Repositories
when(addressRepository.existsById(id)).thenReturn(true);
//when
MockHttpServletRequestBuilder testRequestBuilder = delete("/api/address/{id}", id);
// then
mockMvc.perform(testRequestBuilder)
.andExpect(status().is2xxSuccessful());
}
Die Tests aufrufen
Nur kurz zur Wiederholung: Wir haben verschiedene Möglichkeiten, die Ausführung dieses Tests zu starten:
Im Projektexplorer im Abschnitt “Java Projekts” gibt es ein Play-Symbol neben dem Java-Filenamen des Tests
Wir rufen das entsprechende Maven-Goal auf: führe alles aus bis inklusive der Unittestphase mit allen Tests:
shell mvn test -f ".\pom.xml"
Im Menü unter: Anzeigen/Testen findet sich ein Play-Symbol für die Tests (und später auch das Testergebnis)

Fazit
Die vier Testmethoden laufen durch. Es treten keine Fehler auf. Wunderbar.
Was haben wir gemacht? Getestet? Qualität gesichert? Sicher noch nicht. Wir haben die Technik ausprobiert, die wir zur Qualitätssicherung einsetzen werden. Bislang wurden weder allgemeine Testfälle formuliert noch wurden Testfälle systematisch ermittelt.
Bis hierhin kann allenfalls von einem rudimentären ersten Testfragment gesprochen werden: die eigentlichen Testfälle gemäß der Blackbox-Systematik sowie eine Messung der Testabdeckung mit entsprechender Ergänzung der Testfälle müssen nun folgen.
Exkurs: Was geschieht hinter den Kulissen - am Beispiel des post()-Tests?
Disclaimer: Der folgende Abschnitt erfordert etwas Konzentration und ein tieferes Verständnis von Objektorientierung. Sie können den Abschnitt überspringen, falls er mehr verwirrt als hilft.
In Springboot sind bei Operationen schnell sehr viele Klassen beteiligt, die jeweils nur eine Verantwortlichkeit haben (Single Responsibility Principle). Darunter leidet der Überblick, da nicht jeder Methodenaufruf direkt verrät, in welcher Klasse sich die Methode befindet. Das ist z.B. bei Methoden von statischen Imports der Fall (post()
), zum Teil bei Methodenverkettungen (perform().andExpect()
). Ich will hier einmal haarklein nachvollziehen, welche Methode in welcher Klasse steht, welche Datentypen als Parameter übergeben werden und welche Rückgabewerte sie haben. Hilfreich dazu ist v.a. die Dokumentation (JavaDoc) von SpringBoot, Spring und Java 17. Idealerweise bietet die IDE direkt die Links in die Dokumentation an.
Wiederholung: die `testPost()``-Methode
Wir haben oben eine Methode zum Testen von Post erstellt. Im //given
-Teil ist das Mocken und das Erstellen des JSON-Strings sicher auch einen Blick wert, aber wir wollen uns zunächst auf alles, was ab //when
passiert konzentrieren:
@Test
public void testPost() throws Exception {
//given
String testFirstName = "Herbert";
String testLastName = "Testkandidat";
Address testAddress = new Address(testFirstName, testLastName);
String inputJson = new ObjectMapper().writeValueAsString(testAddress); //erzeugt JSON
//Mocken des Repositories
when(addressRepository.save(any(Address.class))).thenReturn(testAddress);
//when
MockHttpServletRequestBuilder testRequestBuilder = post("/api/address")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(inputJson);
// then
mockMvc.perform(testRequestBuilder)
.andExpect(status().isCreated())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.firstName", equalTo(testFirstName)))
.andExpect(jsonPath("$.lastName", equalTo(testLastName)));
}
Aufrufe im //when
-Abschnitt
Es geht also um folgenden Teil:
//when
MockHttpServletRequestBuilder testRequestBuilder = post("/api/address")
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.content(inputJson);
Wir analysieren diesen Aufruf mal mit Hilfe eines UML-Sequenzdiagramms:

Es wird eine statische Methode
post()
der KlasseMockMvcRequestBuilders
aufgerufen. Durch den statischen Import kann der Aufruf abgekürzt werden (eigentlich:MockMvcRequestBuilders.post()
). Diese Methode erzeugt und konfiguriert ein Objekt. Hierbei wird zur Objekterzeugung dasBuilder
-Pattern genutzt: Typisch ist, dass erst das Objekt erstellt wird (new()
), dann einzelne Attribute gesetzt werden (accept()
,content()
,…) ehe das konfigurierte Objekt zurückgegeben wird.post()
erzeugte also ein Objekt vom TypRequestBuilder
, anschließend wird diesers konfiguriert: Accept- und Content-Type-Header des Requests werden festgelegt sowie der eigentliche Request-Body als UTF-8-JSON-String übergeben. Jede dieser Konfigurations-Methoden gibt selbst wieder ein angepasstesMockHttpServletRequestBuilder
-Objekt zurück, dadurch können sie in dieser Weise verkettet werden (Method-Chaining). Die Rückgabe-Datentypen lassen sich mit etwas Übung schnell aus dem UML-Klassendiagramm erkennen:

- Etwas verwirrend in dem Zusammenhang ist, dass wir mit Hilfe des
Builder
-Pattern eine Objekt erstellen, das wiederum selbst einBuilder
ist.MockMvcRequestBuilders
baut ein Objekt vom TypMockHttpServletRequestBuilder
. Dieses Objekt wird später (imthen
-Abschnitt) den Request selbst bauen.
Den Überblick verloren? Nicht verzagen. Wichtig ist: wir haben im //when
-Abschnitt mit testRequestBuilder
ein Objekt erstellt, das unseren Request erstellen wird. Weiter geht’s.
Aufrufe im //then
-Abschnitt
Der konfigurierte RequestBuilder
wird als Parameter an mockMVC.perform()
übergeben.
In der Methode perform()
wird das eigentliche MockHttpServletRequest
-Objekt vom Builder erstellt, der Request wird ausgeführt und das davon zurückgegebene response
-Objekt wird gemeinsam mit dem Request in ein Objekt des Typs MvcResult
gekapselt. Dieses Objekt wird per Dependency Injection in ein neu erstelltes ResultActions
-Objekt übergeben:
Als Ergebnis erhalten wir ein ResultActions
-Objekt, das uns erlaubt das Ergebnis über Methoden-Verkettung detailliert zu untersuchen. Jeder Aufruf von andExpect()
gibt wiederum ein ResultActions
-Objekt zurück:
.andExpect(status().isCreated())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.firstName", equalTo(testFirstName)))
.andExpect(jsonPath("$.lastName", equalTo(testLastName)));
Diese verketteten Methoden bilden die eigentlichen Zusicherungen, die wir in bisherigen Unit-Tests mit den assert*(*)
-Methoden umgesetzt hatten. Ein Blick hinter die Kulissen zeigt auch, dass am Ende der Prozesse ein assertEquals()
-Aufruf steht:
Es wird zunächst ein ResultMatcher
-Objekt erzeugt und dieses wird der andExpect()
-Methode übergeben. andExpect()
ist eine Methode des ResultActions
-Objekts des vorigen perform()
bzw. andExpect()
-Aufrufs und verfügt als solches bereits über die Abhängigkeit des MvcResult
(darin enthalten das Request- und Response-Objekt).
Es ist nicht immer offensichtlich, zu welchen Klassen aufgerufene Methoden gehören, da die IDE einige statische Imports vornimmt. Um beispielsweise herauszufinden, an welcher Stelle status()
, andExpect()
oder isCreated()
implementiert werden lohnt ein Blick in die Sourcen, in JavaDoc oder in die Zusammenfassung in diesem UML-Klassen-Diagramm:
Manchmal ist etwas kriminalistisches Gespür von Nöten, um herauszufinden, was da genau geschieht. Es trägt jedoch zum sicheren Umgang mit dem Framework bei, wenn man auch immer mal der Neugier nachtgibt und hinabtaucht in das, was wie Zauberhand aussieht.
Nächste Schritte
Dieser Artikel ist ein Teil der Artikelserie zu einem Adressbuch-SpringBoot-Projekt.
Weiter geht es dann mit der Bestimmung der Testabdeckung mit Hilfe des Frameworks JaCoCo.
Links und weitere Informationen
Hinweis zur Nachnutzung als Open Educational Resource (OER)
Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY-SA 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Automatisches Testen des Controllers” von Hannes Stein, Lizenz: CC BY-SA 4.0. Der Artikel wurde unter https://oer-informatik.de/sbb06_addresscontroller-test veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/java-springboot/Backend. Stand: 04.09.2023.
[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]