Spring Boot Tutorial
Web Layer Tests with @WebMvcTest
A controller does more than call a service: it maps URLs and HTTP methods, binds path variables and JSON bodies, runs validation, converts exceptions to status codes, and serialises responses. None of that is exercised by a unit test that calls the controller method directly.
@WebMvcTest starts a slice of the application containing only the web layer — controllers, @ControllerAdvice, Jackson, validation, filters and security configuration — and gives you MockMvc to send requests without a real server. This lesson uses Spring Boot 4's test starters and the AssertJ-based MockMvcTester.
Dependencies in Spring Boot 4
Spring Boot 4 splits test support by technology. Add spring-boot-starter-webmvc-test (test scope) for MVC slice tests; it includes the general test starter. The annotation now lives in org.springframework.boot.webmvc.test.autoconfigure. Services and other dependencies of the controller are replaced with @MockitoBean — Spring Boot's old @MockBean was removed in version 4.
What the Slice Includes
@WebMvcTest(ProductController.class) loads that controller plus web infrastructure: @ControllerAdvice, @JsonComponent/@JacksonComponent, converters, filters, WebMvcConfigurer beans and Spring Security configuration. It does not load @Service, @Repository or other components, so startup is fast and failures point at the web layer.
MockMvc vs MockMvcTester
The classic MockMvc API uses static builders and andExpect(...) matchers. MockMvcTester (Spring Framework 6.2+) wraps it with fluent AssertJ assertions: assertThat(mvc.get().uri("/api/products/1")).hasStatusOk().bodyJson().... Spring Boot auto-configures both in @WebMvcTest when AssertJ is present. New tests should prefer MockMvcTester.
What to Test
For each endpoint, cover: the happy path status and JSON shape; validation failures (400 with a ProblemDetail body); not-found and conflict mappings from your exception handler; and security rules (see the Security testing lesson). Check the response JSON with JSON paths or by comparing with an expected JSON document — this catches accidental field renames that would break API clients.
Examples
Test dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
(Brings JUnit, Mockito, AssertJ, Spring Test, MockMvc and the @WebMvcTest slice.)
The controller under test
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping("/{id}")
public ProductDto get(@PathVariable Long id) {
return service.find(id).orElseThrow(() -> new ProductNotFoundException(id));
}
@PostMapping
public ResponseEntity<ProductDto> create(@Valid @RequestBody CreateProductRequest req) {
ProductDto created = service.create(req);
return ResponseEntity.created(URI.create("/api/products/" + created.id())).body(created);
}
}
public record CreateProductRequest(@NotBlank String name, @Positive BigDecimal price) {}
public record ProductDto(Long id, String name, BigDecimal price) {}
(ProductNotFoundException is mapped to 404 by a @RestControllerAdvice returning ProblemDetail.)
Slice tests with MockMvcTester and @MockitoBean
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired MockMvcTester mvc;
@MockitoBean ProductService service;
@Test
void returnsProductAsJson() {
when(service.find(1L)).thenReturn(Optional.of(new ProductDto(1L, "Hoodie", new BigDecimal("1299.00"))));
assertThat(mvc.get().uri("/api/products/1"))
.hasStatusOk()
.bodyJson()
.isLenientlyEqualTo("""
{"id": 1, "name": "Hoodie", "price": 1299.00}
""");
}
@Test
void returns404ProblemDetailForUnknownProduct() {
when(service.find(99L)).thenReturn(Optional.empty());
assertThat(mvc.get().uri("/api/products/99"))
.hasStatus(HttpStatus.NOT_FOUND)
.bodyJson()
.extractingPath("$.title").isEqualTo("Product not found");
}
@Test
void createsProductAndReturnsLocation() {
when(service.create(any())).thenReturn(new ProductDto(7L, "Cap", new BigDecimal("499")));
assertThat(mvc.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "Cap", "price": 499}
"""))
.hasStatus(HttpStatus.CREATED)
.hasHeader("Location", "/api/products/7");
}
@Test
void rejectsInvalidRequestBody() {
assertThat(mvc.post().uri("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name": "", "price": -5}
"""))
.hasStatus(HttpStatus.BAD_REQUEST);
verifyNoInteractions(service);
}
}
ProductControllerTest
✔ returnsProductAsJson()
✔ returns404ProblemDetailForUnknownProduct()
✔ createsProductAndReturnsLocation()
✔ rejectsInvalidRequestBody()
Tests run: 4 (context started in 0.9 s — only the web layer)
The same style with classic MockMvc (still common in existing code)
@WebMvcTest(ProductController.class)
class ProductControllerClassicTest {
@Autowired MockMvc mockMvc;
@MockitoBean ProductService service;
@Test
void returnsProduct() throws Exception {
when(service.find(1L)).thenReturn(Optional.of(new ProductDto(1L, "Hoodie", new BigDecimal("1299.00"))));
mockMvc.perform(get("/api/products/1").accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Hoodie"))
.andExpect(jsonPath("$.price").value(1299.00))
.andDo(print());
}
}
MockHttpServletResponse:
Status = 200
Content type = application/json
Body = {"id":1,"name":"Hoodie","price":1299.00}
Common Mistakes
- Using @SpringBootTest for every controller test, making the suite slow and failures hard to locate.
- Still using @MockBean from Spring Boot 3 examples; it was removed in Spring Boot 4 — use @MockitoBean.
- Forgetting that @WebMvcTest loads your SecurityFilterChain, then being surprised by 401/403 responses — test security explicitly with @WithMockUser.
- Only asserting the status code, missing broken JSON field names that clients depend on.
- Mocking the controller itself or calling its methods directly, bypassing binding, validation and exception handling.
Key Points to Remember
- @WebMvcTest loads only the web layer; dependencies are replaced with @MockitoBean.
- Spring Boot 4 uses spring-boot-starter-webmvc-test for MVC slice tests.
- MockMvcTester provides fluent AssertJ assertions for status, headers and JSON.
- Test happy paths, validation errors, error mappings and security for every endpoint.
- Compare JSON bodies to catch accidental API contract changes.
Practice the examples
Change an input, predict the result, then compare it with the output. Explain why the result changes.
Use your local JDK or project IDE for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.