Testing Spring Boot Applications

Testing the Web Layer with @WebMvcTest

18 min Lesson 4 of 13

Testing the Web Layer with @WebMvcTest

When you write a REST controller you care about three things: does the endpoint respond to the right HTTP method and URL, does it map the request body and path variables correctly, and does it return the right status code and JSON? You do not care whether the database is reachable. Loading the entire application context — Hibernate, connection pools, message brokers — to answer those three questions is slow, fragile, and unnecessary. That is exactly the problem @WebMvcTest solves.

What Is a Slice Test?

Spring Boot's test slices load only a well-defined subset of the application context. @WebMvcTest is the slice for the web layer: it brings up Spring MVC infrastructure (filters, argument resolvers, message converters, DispatcherServlet) but deliberately excludes @Service, @Repository, @Component, and JPA/datasource auto-configuration. The result is a context that starts in hundreds of milliseconds instead of several seconds, and that never touches a database.

Slice vs. unit test vs. integration test: A pure unit test uses no Spring context at all — just plain Java and Mockito. A @SpringBootTest integration test loads the full context. @WebMvcTest sits in the middle: you get real MVC dispatch (deserialization, validation, exception handlers) without the full context cost.

Setting Up @WebMvcTest

Suppose you have a simple REST controller for managing products:

// src/main/java/com/example/shop/product/ProductController.java package com.example.shop.product; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; import java.util.List; @RestController @RequestMapping("/api/products") public class ProductController { private final ProductService productService; public ProductController(ProductService productService) { this.productService = productService; } @GetMapping public List<ProductDto> list() { return productService.findAll(); } @GetMapping("/{id}") public ResponseEntity<ProductDto> getById(@PathVariable Long id) { return productService.findById(id) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } @PostMapping public ResponseEntity<ProductDto> create(@Valid @RequestBody CreateProductRequest request) { ProductDto created = productService.create(request); return ResponseEntity.status(201).body(created); } }

To test this controller in isolation, annotate the test class with @WebMvcTest and specify the controller under test:

// src/test/java/com/example/shop/product/ProductControllerTest.java package com.example.shop.product; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Optional; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @WebMvcTest(ProductController.class) // only this controller is loaded class ProductControllerTest { @Autowired MockMvc mockMvc; // pre-configured by the slice @Autowired ObjectMapper objectMapper; // the same Jackson mapper the app uses @MockBean ProductService productService; // replace the real bean with a mock @Test void list_returnsAllProducts() throws Exception { when(productService.findAll()) .thenReturn(List.of(new ProductDto(1L, "Keyboard", 79.99))); mockMvc.perform(get("/api/products")) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].name").value("Keyboard")) .andExpect(jsonPath("$[0].price").value(79.99)); } @Test void getById_notFound_returns404() throws Exception { when(productService.findById(99L)).thenReturn(Optional.empty()); mockMvc.perform(get("/api/products/99")) .andExpect(status().isNotFound()); } @Test void create_validRequest_returns201() throws Exception { var request = new CreateProductRequest("Mouse", 39.99); var response = new ProductDto(2L, "Mouse", 39.99); when(productService.create(request)).thenReturn(response); mockMvc.perform(post("/api/products") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.id").value(2)); } }

What @WebMvcTest Provides Automatically

  • A fully configured MockMvc bean — no need to call MockMvcBuilders.standaloneSetup().
  • Jackson ObjectMapper configured exactly as in production (custom serializers, module registration, etc.).
  • All @ControllerAdvice classes in the application are loaded — exception handling is tested end-to-end.
  • Filters registered as Spring beans are applied (security filters, CORS, etc.).
  • Bean Validation (@Valid) is wired and fires during request processing.

Scoping the Slice: Which Controllers Are Loaded?

When you write @WebMvcTest(ProductController.class), only that controller is added to the context. If you omit the value — @WebMvcTest with no argument — Spring loads all @RestController and @Controller beans found in your packages. That also means all their dependencies must be mocked. Prefer the explicit form to keep tests narrow and fast.

Always specify the controller class: @WebMvcTest(MyController.class). As the application grows, the no-argument form silently pulls in new controllers and their transitive @MockBean requirements, slowing the build and making failures harder to diagnose.

Testing Bean Validation

Because @Valid is processed by the MVC layer, a @WebMvcTest is the right place to verify that invalid input is rejected:

@Test void create_missingName_returns400() throws Exception { // price present but name is blank — violates @NotBlank var badRequest = new CreateProductRequest("", 39.99); mockMvc.perform(post("/api/products") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(badRequest))) .andExpect(status().isBadRequest()); }

This test does not require a running service or database, yet it proves that your @NotBlank constraint on CreateProductRequest.name is wired up and will return a 400 when violated.

Security in @WebMvcTest

If your project includes spring-boot-starter-security, @WebMvcTest applies your security configuration automatically. Endpoints that require authentication will return 401 or 403 in tests. You have several options:

  • Use @WithMockUser (from spring-security-test) to run a request as a specific authenticated user.
  • Provide a dedicated @TestConfiguration that relaxes security for the test context.
  • Call .with(SecurityMockMvcRequestPostProcessors.csrf()) on POST/PUT requests when CSRF is enabled.
Do not disable security globally in tests. If you exclude the security auto-configuration with excludeAutoConfiguration, you will stop testing the security contract of your API. Use @WithMockUser or a test-specific security config that retains realistic rules while allowing test execution.

Performance Trade-offs

The web slice is fast precisely because it is narrow. But that narrowness has consequences: your service and repository layers are completely absent, so you must mock every dependency your controller uses with @MockBean. If a controller has many collaborators this can become verbose. The boundary signals a design concern: controllers with many direct service dependencies are harder to test and harder to reason about.

Another trade-off is context caching. Spring Boot caches the application context across tests that share the same configuration. Adding different @MockBean definitions in different test classes creates different context configurations, which defeats caching and slows the suite. Group controllers with the same mock requirements into one test class where practical.

Summary

@WebMvcTest gives you a fast, focused slice test for the web layer: real MVC dispatch, real Jackson serialization, real Bean Validation, and real exception handlers — with zero database cost. Declare the specific controller under test, replace all services with @MockBean, and use MockMvc to make HTTP-level assertions. The next lesson takes a deeper look at MockMvc itself and the rich set of assertions and customizations it provides.