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 sind OrderController, die @ControllerAdvice, Converter, Filter, WebMvcConfigurer und Jackson. Reguläre @Service-, @Repository- oder @Component-Beans werden nicht geladen.
  • MockMvc wird automatisch konfiguriert, du musst es nur injizieren.
  • ObjectMapper ist ein Jackson-Bean, den Spring Boot konfiguriert. Verwende es, um Anfragen zu serialisieren und sicherzustellen, dass der Test mit der echten Konfiguration übereinstimmt.
  • @MockitoBean erstellt ein Mockito-Mock und registriert es im Context. Dadurch hat OrderController die notwendige Abhängigkeit zum Initialisieren.

Hinweis: Ab Spring Boot 3.4 ist @MockBean veraltet. Du solltest @MockitoBean aus dem Package org.springframework.test.context.bean.override.mockito verwenden. 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 festgelegte Order zurückgibt, wenn es mit der ID 1 aufgerufen wird.
  • perform(get(...)) sendet eine GET-Anfrage über den DispatcherServlet. 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, weil record automatisch equals() generiert. Die vom Controller deserialisierte Anfrage ist gleich mit dem Objekt request im Test. Wenn du eine normale Klasse verwendest, die equals() nicht überschreibt, verwende stattdessen any(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 Exception erforderlich.
  • Keine Notwendigkeit, sich viele statische Imports von MockMvcResultMatchers zu 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 @Configuration markiert), 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 @WebMvcTest nicht 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 mit TestRestTemplate oder WebTestClient. 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 @WebMvcTest an.
  • Mocke genau, was der Controller braucht.
  • Vergiss nicht contentType beim Senden des Body.
  • Halte die @SpringBootApplication-Klasse sauber, damit die Test-Slices nicht mit überflüssiger Konfiguration belastet werden.
spring-boottestingmockmvcjunit5webmvctest