@WebMvcTest and MockMvcTester
Test controllers through the real Spring MVC stack without a server: status codes, JSON, validation and headers, with fluent AssertJ checks.
@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
/errorinside a servlet container, which MockMvc does not do. A thrown@ResponseStatusexception gives the right status and an empty body. Bodies written by your own@RestControllerAdvicedo 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 aLocationheader. - Forgetting security. With Spring Security on the classpath the slice applies security too: an anonymous
GETgets 401, and aPOSTwithout 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
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( String sku, (1) int quantity) {
}
record OrderView(long id, String sku, int quantity, String status) {
}
interface OrderService {
Optional<OrderView> find(long id);
OrderView create(CreateOrder command);
}
(HttpStatus.NOT_FOUND)
class OrderNotFoundException extends RuntimeException {
OrderNotFoundException(long id) {
super("Order " + id + " not found");
}
}
("/orders")
class OrderController {
private final OrderService orders;
OrderController(OrderService orders) {
this.orders = orders;
}
("/{id}")
OrderView get( long id) {
return orders.find(id).orElseThrow(() -> new OrderNotFoundException(id));
}
ResponseEntity<OrderView> create( CreateOrder body) {
OrderView created = orders.create(body);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
}
(OrderController.class)
class OrderControllerTest {
MockMvcTester mvc;
OrderService orders;
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");
}
void returnsNotFoundForUnknownOrder() {
given(orders.find(99)).willReturn(Optional.empty());
assertThat(mvc.get().uri("/orders/{id}", 99)).hasStatus(HttpStatus.NOT_FOUND);
}
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);
}
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.
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.
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.
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