@WebMvcTest and MockMvcTester

Test controllers through the real Spring MVC stack without a server: status codes, JSON, validation and headers, with fluent AssertJ checks.

Syntax@WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired MockMvcTester mvc; @MockitoBean OrderService orders; assertThat(mvc.get().uri("/orders/{id}", 1)).hasStatusOk().bodyJson()...; }

A controller test should answer one question: does this HTTP request produce the right HTTP response? @WebMvcTest (from org.springframework.boot.webmvc.test.autoconfigure in Spring Boot 4) starts a slice of the application with only the web layer: @Controller and @RestControllerAdvice beans, converters, filters, Jackson and validation. Services, repositories and the database are not loaded, so you supply the controller's collaborators with @MockitoBean.

Requests do not go through a network or a servlet container. MockMvc calls the DispatcherServlet directly, so a test still exercises request mapping, JSON conversion, @Valid and exception handling, but runs in milliseconds once the context is up.

MockMvcTester

MockMvcTester (from org.springframework.test.web.servlet.assertj) is the AssertJ flavour of MockMvc. Spring Boot auto-configures one in @WebMvcTest and with @AutoConfigureMockMvc, so you inject it. Build the request with mvc.get(), mvc.post() and friends, pass it to assertThat, and chain the checks:

assertThat(mvc.get().uri("/orders/{id}", 42))
        .hasStatusOk()
        .bodyJson()
        .extractingPath("$.status")
        .asString()
        .isEqualTo("PAID");

Unlike the older perform(...).andExpect(...) style there are no static matcher imports to find, and a failure message shows the whole response.

Name the controller

@WebMvcTest(OrderController.class) loads one controller. Without it the slice loads every controller in the application, and each of their dependencies must then be mocked, or the context fails to start.

Mistakes worth knowing

  • Expecting Spring Boot's default error JSON. That body is produced by forwarding to /error inside a servlet container, which MockMvc does not do. A thrown @ResponseStatus exception gives the right status and an empty body. Bodies written by your own @RestControllerAdvice do appear.
  • Asserting on the mock instead of the response. Verifying that orders.create(...) was called proves the controller made a call, not that the client got a 201 with a Location header.
  • Forgetting security. With Spring Security on the classpath the slice applies security too: an anonymous GET gets 401, and a POST without a CSRF token gets 403 before any rule runs. See the Testing Security lesson.

Controller tests are where you pin down the API contract: field names, status codes, headers and validation messages. Keep business rules out of them; those belong in service unit tests.

Example

Example · java
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import java.net.URI;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

record CreateOrder(@NotBlank String sku, @Min(1) int quantity) {
}

record OrderView(long id, String sku, int quantity, String status) {
}

interface OrderService {
    Optional<OrderView> find(long id);

    OrderView create(CreateOrder command);
}

@ResponseStatus(HttpStatus.NOT_FOUND)
class OrderNotFoundException extends RuntimeException {
    OrderNotFoundException(long id) {
        super("Order " + id + " not found");
    }
}

@RestController
@RequestMapping("/orders")
class OrderController {

    private final OrderService orders;

    OrderController(OrderService orders) {
        this.orders = orders;
    }

    @GetMapping("/{id}")
    OrderView get(@PathVariable long id) {
        return orders.find(id).orElseThrow(() -> new OrderNotFoundException(id));
    }

    @PostMapping
    ResponseEntity<OrderView> create(@Valid @RequestBody CreateOrder body) {
        OrderView created = orders.create(body);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);
    }
}

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    OrderService orders;

    @Test
    void returnsTheOrderAsJson() {
        given(orders.find(42)).willReturn(Optional.of(new OrderView(42, "SKU-1", 2, "PAID")));

        assertThat(mvc.get().uri("/orders/{id}", 42))
                .hasStatusOk()
                .hasContentTypeCompatibleWith(MediaType.APPLICATION_JSON)
                .bodyJson()
                .extractingPath("$.status")
                .asString()
                .isEqualTo("PAID");
    }

    @Test
    void returnsNotFoundForUnknownOrder() {
        given(orders.find(99)).willReturn(Optional.empty());

        assertThat(mvc.get().uri("/orders/{id}", 99)).hasStatus(HttpStatus.NOT_FOUND);
    }

    @Test
    void createsAnOrderAndReturnsItsLocation() {
        given(orders.create(any())).willReturn(new OrderView(7, "SKU-1", 3, "NEW"));

        assertThat(mvc.post().uri("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                        {"sku": "SKU-1", "quantity": 3}
                        """))
                .hasStatus(HttpStatus.CREATED)
                .hasHeader("Location", "http://localhost/orders/7")
                .bodyJson()
                .extractingPath("$.id")
                .asNumber()
                .isEqualTo(7);
    }

    @Test
    void rejectsAnInvalidOrderWithoutCallingTheService() {
        assertThat(mvc.post().uri("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                        {"sku": "", "quantity": 0}
                        """))
                .hasStatus(HttpStatus.BAD_REQUEST);

        then(orders).shouldHaveNoInteractions();
    }
}

When to use it

  • Lock down the JSON field names of an orders endpoint so a renamed record component cannot silently break a mobile app.
  • Check that an invalid request returns 400 and never reaches the service or the database.
  • Prove that a domain exception becomes a 409 problem detail with the title the front end shows to users.

More examples

Comparing the whole JSON body

<code>isLenientlyEqualTo</code> compares JSON structurally: field order and extra fields do not matter, but every field you list must match. Use <code>isStrictlyEqualTo</code> when an extra field would itself be a bug, for example a leaked internal ID. <code>convertTo</code> reads part of the body back into a type with the application's JSON mapper, which is handy for large responses where you only care about a few properties.

Example · java
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

record Product(String sku, String name, BigDecimal price) {
}

interface Catalogue {
    List<Product> featured();
}

@RestController
class ProductController {

    private final Catalogue catalogue;

    ProductController(Catalogue catalogue) {
        this.catalogue = catalogue;
    }

    @GetMapping("/products/featured")
    List<Product> featured() {
        return catalogue.featured();
    }
}

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    Catalogue catalogue;

    @Test
    void returnsFeaturedProducts() {
        given(catalogue.featured()).willReturn(List.of(
                new Product("SKU-1", "Kettle", new BigDecimal("24.99"))));

        assertThat(mvc.get().uri("/products/featured"))
                .hasStatusOk()
                .bodyJson()
                .isLenientlyEqualTo("""
                        [{"sku": "SKU-1", "name": "Kettle", "price": 24.99}]
                        """);
    }

    @Test
    void convertsTheBodyBackIntoRecords() {
        given(catalogue.featured()).willReturn(List.of(
                new Product("SKU-2", "Toaster", new BigDecimal("39.00"))));

        assertThat(mvc.get().uri("/products/featured"))
                .bodyJson()
                .extractingPath("$[0]")
                .convertTo(Product.class)
                .satisfies(p -> assertThat(p.name()).isEqualTo("Toaster"));
    }
}

Testing an error body from your own advice

The slice picks up <code>@RestControllerAdvice</code> classes automatically, so the error handling runs exactly as in production. Asserting on <code>application/problem+json</code> and the <code>title</code> pins down the error contract that client code branches on. <code>willThrow(...).given(mock)</code> is the BDD form for stubbing a <code>void</code> method.

Example · java
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.willThrow;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;

class OrderAlreadyShippedException extends RuntimeException {
    OrderAlreadyShippedException(long id) {
        super("Order " + id + " has already shipped");
    }
}

interface Cancellations {
    void cancel(long orderId);
}

@RestController
class CancellationController {

    private final Cancellations cancellations;

    CancellationController(Cancellations cancellations) {
        this.cancellations = cancellations;
    }

    @PostMapping("/orders/{id}/cancel")
    void cancel(@PathVariable long id) {
        cancellations.cancel(id);
    }
}

@RestControllerAdvice
class ApiErrors {

    @ExceptionHandler(OrderAlreadyShippedException.class)
    ProblemDetail shipped(OrderAlreadyShippedException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
        problem.setTitle("Order already shipped");
        return problem;
    }
}

@WebMvcTest(CancellationController.class)
class CancellationControllerTest {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    Cancellations cancellations;

    @Test
    void answersConflictWithAProblemDetail() {
        willThrow(new OrderAlreadyShippedException(5)).given(cancellations).cancel(anyLong());

        assertThat(mvc.post().uri("/orders/{id}/cancel", 5))
                .hasStatus(HttpStatus.CONFLICT)
                .hasContentType(MediaType.APPLICATION_PROBLEM_JSON)
                .bodyJson()
                .extractingPath("$.title")
                .asString()
                .isEqualTo("Order already shipped");
    }
}

Query parameters and printing a failing exchange

<code>param</code> adds query parameters without building the URL by hand, so encoding is handled for you. <code>debug()</code> prints the full request and response to the console, which is the fastest way to see why an assertion failed; remove it once the test passes. The first test also documents a server-side cap that protects the database from a client asking for five thousand rows.

Example · java
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;

import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

record OrderSummary(long id, String status) {
}

interface OrderSearch {
    List<OrderSummary> byStatus(String status, int limit);
}

@RestController
class OrderSearchController {

    private final OrderSearch search;

    OrderSearchController(OrderSearch search) {
        this.search = search;
    }

    @GetMapping("/orders")
    List<OrderSummary> list(@RequestParam(defaultValue = "NEW") String status,
                            @RequestParam(defaultValue = "20") int limit) {
        return search.byStatus(status, Math.min(limit, 100));
    }
}

@WebMvcTest(OrderSearchController.class)
class OrderSearchControllerTest {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    OrderSearch search;

    @Test
    void capsTheLimitAtOneHundred() {
        given(search.byStatus("PAID", 100)).willReturn(List.of(new OrderSummary(1, "PAID")));

        assertThat(mvc.get().uri("/orders").param("status", "PAID").param("limit", "5000"))
                .debug()
                .hasStatusOk()
                .bodyJson()
                .extractingPath("$")
                .asArray()
                .hasSize(1);
    }

    @Test
    void rejectsANonNumericLimit() {
        assertThat(mvc.get().uri("/orders").param("limit", "lots"))
                .hasStatus4xxClientError();
    }
}

Discussion

  • Be the first to comment on this lesson.