Controller mit @WebMvcTest und MockMvc testen
1. Übersicht
Das Testen von Controllern mit @SpringBootTest lädt die gesamte Anwendung, einschließlich Datenbank, Message Broker und aller anderen Beans. Dies ist langsam und macht es schwer, Fehler zu isolieren. Spring Boot bietet @WebMvcTest, um nur die Web-Schicht zu laden. In Kombination mit MockMvc kannst du simulierte Anfragen senden, ohne einen echten Server zu starten.
Nach dem Lesen dieses Artikels wirst du wissen, wie man einen REST-Controller mit @WebMvcTest testet. Speziell geht es um das Überprüfen von Status-Codes, Headern und zurückgegebenem JSON, das Testen von Validierung und Exception-Handleren, und wie man häufige Fehler vermeidet.
2. Abhängigkeiten
Du brauchst spring-boot-starter-web für den Controller, spring-boot-starter-validation für Bean Validation und spring-boot-starter-test für das Testing:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
spring-boot-starter-test enthält bereits JUnit 5, AssertJ, Mockito, JsonPath und Spring Test. Du brauchst nichts Weiteres hinzufügen.
3. Controller unter Test
Wir verwenden eine einfache Bestell-API. Zuerst das Model und die Request-Klasse:
public record Order(Long id, String customerName, BigDecimal total) {}
public record CreateOrderRequest(
@NotBlank String customerName,
@NotNull @Positive BigDecimal total) {}
Die Service-Schicht ist ein Interface. Im Test werden wir es mocken:
public interface OrderService {
Order findById(Long id);
Order create(CreateOrderRequest request);
}
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("Bestellung " + id + " nicht gefunden");
}
}
Der Controller hat zwei Endpoints:
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public Order getOrder(@PathVariable Long id) {
return orderService.findById(id);
}
@PostMapping
public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
Order created = orderService.create(request);
return ResponseEntity
.created(URI.create("/api/orders/" + created.id()))
.body(created);
}
}
Abschließend ein @RestControllerAdvice, um OrderNotFoundException in HTTP 404 umzuwandeln:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}
4. Erster Test mit @WebMvcTest
4.1. Test-Struktur
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private OrderService orderService;
}
Die einzelnen Teile funktionieren folgendermaßen:
@WebMvcTest(OrderController.class)erstellt einen Spring Context, der nur Beans enthält, die mit MVC zusammenhängen. Das sindOrderController, die@ControllerAdvice,Converter,Filter,WebMvcConfigurerund Jackson. Reguläre@Service-,@Repository- oder@Component-Beans werden nicht geladen.MockMvcwird automatisch konfiguriert, du musst es nur injizieren.ObjectMapperist ein Jackson-Bean, den Spring Boot konfiguriert. Verwende es, um Anfragen zu serialisieren und sicherzustellen, dass der Test mit der echten Konfiguration übereinstimmt.@MockitoBeanerstellt ein Mockito-Mock und registriert es im Context. Dadurch hatOrderControllerdie notwendige Abhängigkeit zum Initialisieren.
Hinweis: Ab Spring Boot 3.4 ist
@MockBeanveraltet. Du solltest@MockitoBeanaus dem Packageorg.springframework.test.context.bean.override.mockitoverwenden. Wenn du mit Spring Boot 3.3 oder älter arbeitest, verwende@MockBean. Die Funktionsweise ist die gleiche.
4.2. Test GET-Request mit JSON-Response
@Test
void shouldReturnOrderWhenFound() throws Exception {
given(orderService.findById(1L))
.willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));
mockMvc.perform(get("/api/orders/{id}", 1L))
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.customerName").value("Nguyen Van A"))
.andExpect(jsonPath("$.total").value(250000));
}
Die erforderlichen statischen Imports sind:
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
Schritt-für-Schritt-Erklärung:
given(...).willReturn(...)definiert, dass das Mock eine festgelegteOrderzurückgibt, wenn es mit der ID1aufgerufen wird.perform(get(...))sendet eineGET-Anfrage über denDispatcherServlet. Die Anfrage geht nicht über das Netzwerk.andDo(print())gibt die gesamte Anfrage und Antwort auf der Konsole aus. Dieser Schritt ist sehr hilfreich, wenn der Test fehlschlägt.jsonPath("$.customerName")fragt ein Feld im JSON-Body ab.
Die Ausgabe von print() sieht ungefähr so aus:
MockHttpServletResponse:
Status = 200
Error message = null
Headers = [Content-Type:"application/json"]
Content type = application/json
Body = {"id":1,"customerName":"Nguyen Van A","total":250000}
5. Test POST mit Request-Body
5.1. Erfolgsfall
@Test
void shouldCreateOrder() throws Exception {
var request = new CreateOrderRequest("Tran Thi B", new BigDecimal("99000"));
given(orderService.create(request))
.willReturn(new Order(10L, "Tran Thi B", new BigDecimal("99000")));
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/orders/10"))
.andExpect(jsonPath("$.id").value(10));
}
Hier gibt es zwei wichtige Punkte:
contentType(MediaType.APPLICATION_JSON)ist erforderlich. Ohne diesen wird Spring 415 Unsupported Media Type zurückgeben.given(orderService.create(request))funktioniert, weilrecordautomatischequals()generiert. Die vom Controller deserialisierte Anfrage ist gleich mit dem Objektrequestim Test. Wenn du eine normale Klasse verwendest, dieequals()nicht überschreibt, verwende stattdessenany(CreateOrderRequest.class).
5.2. Validierungstest
@WebMvcTest lädt die Validierungskonfiguration. Daher funktioniert @Valid genauso wie in der echten Anwendung:
@Test
void shouldReturnBadRequestWhenCustomerNameIsBlank() throws Exception {
String invalidJson = """
{"customerName": "", "total": -5}
""";
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidJson))
.andExpect(status().isBadRequest());
verify(orderService, never()).create(any());
}
Die Zeile verify(orderService, never()).create(any()) stellt sicher, dass der Service nicht aufgerufen wird, wenn die Daten ungültig sind. Das ist genau das Verhalten, das du schützen möchtest: Die Validierung blockiert Anfragen auf der Web-Ebene.
Dieser Test verwendet einen JSON-Text-Block anstelle von ObjectMapper. Dieser Ansatz ermöglicht es dir, auch Payloads zu senden, die Java Records nicht darstellen können, zum Beispiel JSON mit falschen Datentypen.
6. Test für Exception Handler
Da @WebMvcTest automatisch @RestControllerAdvice lädt, kannst du das Mapping von Exceptions auf HTTP-Status direkt testen:
@Test
void shouldReturnNotFoundWhenOrderDoesNotExist() throws Exception {
given(orderService.findById(99L)).willThrow(new OrderNotFoundException(99L));
mockMvc.perform(get("/api/orders/{id}", 99L))
.andExpect(status().isNotFound())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.detail").value("Bestellung 99 nicht gefunden"));
}
Der Response-Body folgt dem RFC-9457-Standard (Problem Details):
{"type":"about:blank","title":"Not Found","status":404,"detail":"Bestellung 99 nicht gefunden","instance":"/api/orders/99"}
Das ist eine Stärke von @WebMvcTest im Vergleich zu reinen Unit Tests, die direkt aufrufen. Reine Unit Tests sehen nur die geworfene Exception. Sie können nicht überprüfen, welchen Status und Body der Client tatsächlich erhält.
7. Assertions mit AssertJ und MockMvcTester schreiben
Ab Spring Boot 3.4 (Spring Framework 6.2) konfiguriert Spring Boot zusätzlich MockMvcTester, wenn AssertJ auf dem Classpath liegt. Dieses Bean ermöglicht es, Assertions mit AssertJ zu schreiben anstelle von andExpect-Ketten:
@WebMvcTest(OrderController.class)
class OrderControllerAssertJTest {
@Autowired
private MockMvcTester mvc;
@MockitoBean
private OrderService orderService;
@Test
void shouldReturnOrderWhenFound() {
given(orderService.findById(1L))
.willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));
assertThat(mvc.get().uri("/api/orders/{id}", 1L))
.hasStatusOk()
.hasContentType(MediaType.APPLICATION_JSON)
.bodyJson()
.extractingPath("$.customerName")
.isEqualTo("Nguyen Van A");
}
}
Im Vergleich zu MockMvc hat dieser Ansatz drei Vorteile:
- Keine
throws Exceptionerforderlich. - Keine Notwendigkeit, sich viele statische Imports von
MockMvcResultMatcherszu merken. - Fehlermeldungen von AssertJ sind normalerweise leichter zu lesen.
Beide APIs sind in ihren Fähigkeiten gleichwertig. Wähle eine und verwende sie konsistent im Projekt.
8. Einschränken, was geladen wird
8.1. Controller angeben
Wenn du @WebMvcTest ohne Parameter schreibst, lädt Spring alle Controller. Dann musst du die Abhängigkeiten aller Controller mocken, einschließlich solcher, die mit dem Test nichts zu tun haben. Deshalb solltest du immer den zu testenden Controller angeben:
@WebMvcTest(OrderController.class)
8.2. Spezifische Beans laden
Manchmal verwendet ein Controller ein kleines Bean ohne Nebenwirkungen, zum Beispiel ein OrderMapper. Ein Mock dieses Beans bringt wenig Nutzen. Du kannst das echte Bean mit @Import laden:
@WebMvcTest(OrderController.class)
@Import(OrderMapper.class)
class OrderControllerTest {
// ...
}
8.3. Mit Spring Security
Wenn spring-boot-starter-security auf dem Classpath liegt, lädt @WebMvcTest auch die Security-Filter-Kette. Die obigen Tests geben dann 401 oder 403 zurück. Du musst die folgende Abhängigkeit hinzufügen:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
Danach simulierst du einen Benutzer und sendest einen CSRF-Token mit für Anfragen, die Daten ändern:
@Test
@WithMockUser(roles = "USER")
void shouldCreateOrderWhenAuthenticated() throws Exception {
// ... Mock orderService wie in Abschnitt 5.1
mockMvc.perform(post("/api/orders")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerName": "Le Van C", "total": 120000}
"""))
.andExpect(status().isCreated());
}
Hinweis: Die
SecurityFilterChain-Klasse, die du selbst definierst (mit@Configurationmarkiert), wird nicht automatisch geladen in allen Versionen. Wenn du deine eigenen Autorisierungsregeln korrekt testen möchtest, füge@Import(SecurityConfig.class)hinzu.
9. Häufig auftretende Fehler
NoSuchBeanDefinitionException beim Starten des Tests. Der Controller hängt von einem Bean ab, das nicht zur Web-Ebene gehört und das du noch nicht gemockt hast. Lösung: Füge @MockitoBean hinzu oder importiere es mit @Import.
Annotations auf der @SpringBootApplication-Klasse beeinträchtigen den Test. Zum Beispiel platzierst du @EnableJpaRepositories oder @EnableScheduling direkt in der Main-Klasse. @WebMvcTest liest diese Klasse weiterhin, versucht also, ein Repository zu erstellen und schlägt fehl, weil DataSource fehlt. Lösung: Trenne diese Annotations auf in eine separate @Configuration-Klasse.
contentType vergessen. Das Ergebnis ist 415 anstelle des erwarteten Status.
Mit MockMvc Ergebnisse überprüfen, aber die Logik befindet sich im Service. @WebMvcTest sollte nur die Aufgaben der Web-Schicht überprüfen:
- Routing,
- Serialisierung,
- Validierung,
- Status-Codes,
- Exception-Mapping.
Die Geschäftslogik sollte separat im Service mit reinen Unit Tests getestet werden.
Zu viel Mocking. Wenn ein Test fünf oder sechs Methods stubben muss, könnte der Controller zu viel tun. Das ist ein Signal zum Refaktorieren, nicht um mehr Stubs zu schreiben.
Hinweis: Du solltest
@WebMvcTestnicht verwenden, wenn du einen End-to-End-Flow über die Datenbank überprüfen möchtest oder wenn du das Verhalten des echten Servers testen musst (Port, HTTP/2, Container-Filter). Verwende dann@SpringBootTest(webEnvironment = RANDOM_PORT)zusammen mitTestRestTemplateoderWebTestClient. Wenn deine Anwendung WebFlux verwendet, ist der entsprechende Slice@WebFluxTest, nicht@WebMvcTest.
10. Fazit
@WebMvcTest hilft dir, Controller schnell und fokussiert zu testen. Es lädt nur die Web-Schicht, während die dahinterliegenden Abhängigkeiten durch @MockitoBean ersetzt werden. In Kombination mit MockMvc oder MockMvcTester kannst du die Verhaltensweisen überprüfen, die der Client tatsächlich sieht: Status-Codes, Header, JSON, Validierung und Exception-Mapping.
Die wichtigsten Regeln zum Merken:
- Gib immer den Controller in
@WebMvcTestan. - Mocke genau, was der Controller braucht.
- Vergiss nicht
contentTypebeim Senden des Body. - Halte die
@SpringBootApplication-Klasse sauber, damit die Test-Slices nicht mit überflüssiger Konfiguration belastet werden.