Writing Controller Tests with @WebMvcTest and MockMvc
1. Overview
Testing a controller with @SpringBootTest loads the entire application, including the database, message broker, and all other beans. This approach is slow and makes it hard to isolate errors. Spring Boot provides @WebMvcTest to load only the web layer. Combined with MockMvc, you can send mock requests without starting a real server.
After reading this article, you’ll know how to test a REST controller with @WebMvcTest. Specifically, checking status, headers, and returned JSON, testing validation, testing exception handlers, and avoiding common pitfalls.
2. Dependency
You need spring-boot-starter-web for the controller, spring-boot-starter-validation for Bean Validation, and spring-boot-starter-test for 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 already includes JUnit 5, AssertJ, Mockito, JsonPath, and Spring Test. You don’t need anything else.
3. Controller to test
We’ll use a simple order management API. First, here’s the model and request:
public record Order(Long id, String customerName, BigDecimal total) {}
public record CreateOrderRequest(
@NotBlank String customerName,
@NotNull @Positive BigDecimal total) {}
The service layer is an interface. In tests, we’ll mock it:
public interface OrderService {
Order findById(Long id);
Order create(CreateOrderRequest request);
}
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("Order " + id + " not found");
}
}
The controller has two 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);
}
}
Finally, a @RestControllerAdvice to convert OrderNotFoundException to HTTP 404:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}
4. First test with @WebMvcTest
4.1. Basic test setup
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private OrderService orderService;
}
Each part works as follows:
@WebMvcTest(OrderController.class)creates a Spring context with only beans related to MVC. These areOrderController,@ControllerAdvice,Converter,Filter,WebMvcConfigurer, and Jackson.@Service,@Repository, or regular@Componentbeans are not loaded.MockMvcis auto-configured; you just need to inject it.ObjectMapperis a Jackson bean configured by Spring Boot. Use it to serialize requests so tests match the actual configuration.@MockitoBeancreates a Mockito mock forOrderServiceand registers it in the context. This wayOrderControllerhas the dependency to initialize.
Note: Starting from Spring Boot 3.4,
@MockBeanis deprecated. You should use@MockitoBeanfrom theorg.springframework.test.context.bean.override.mockitopackage. If you’re on Spring Boot 3.3 or earlier, use@MockBean. The behavior is the same.
4.2. Test GET returning JSON
@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));
}
Required static imports:
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.*;
Step-by-step explanation:
given(...).willReturn(...)specifies that the mock returns a fixedOrderwhen called with id1.perform(get(...))sends aGET /api/orders/1request throughDispatcherServlet. The request doesn’t go over the network.andDo(print())prints the entire request and response to console. This step is very useful when a test fails.jsonPath("$.customerName")queries a field in the JSON body.
The output from print() looks like this:
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. Testing POST with request body
5.1. Success case
@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));
}
Two important points here:
contentType(MediaType.APPLICATION_JSON)is required. If missing, Spring returns 415 Unsupported Media Type.given(orderService.create(request))works becauserecordauto-generatesequals(). The request deserialized by the controller will equal therequestobject in the test. If using a regular class without overridingequals(), useany(CreateOrderRequest.class)instead.
5.2. Testing validation
@WebMvcTest loads the validation configuration. Therefore, @Valid works the same as in production:
@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());
}
The line verify(orderService, never()).create(any()) ensures the service is not called when data is invalid. This is exactly the behavior you want to protect: validation stops the request at the web layer.
This test uses a JSON text block instead of ObjectMapper. This way you can send payloads that Java records can’t represent, for example JSON with incorrect data types.
6. Testing exception handlers
Since @WebMvcTest automatically loads @RestControllerAdvice, you can also test exception-to-HTTP-status mapping:
@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("Order 99 not found"));
}
Response body follows the RFC 9457 standard (Problem Details):
{"type":"about:blank","title":"Not Found","status":404,"detail":"Order 99 not found","instance":"/api/orders/99"}
This is a strength of @WebMvcTest compared to pure unit tests that directly call orderController.getOrder(99L). Pure unit tests only see the thrown exception. They can’t check what status and body the client actually receives.
7. Writing AssertJ-style assertions with MockMvcTester
From Spring Boot 3.4 (Spring Framework 6.2), if AssertJ is on the classpath, Spring Boot auto-configures MockMvcTester. This bean lets you write assertions using AssertJ instead of chaining andExpect:
@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");
}
}
Compared to MockMvc, this approach has three advantages:
- No need for
throws Exception. - Don’t need to remember many static imports from
MockMvcResultMatchers. - AssertJ’s error messages are usually easier to read.
Both APIs are equivalent in capability. Choose one and use it consistently throughout your project.
8. Limiting what gets loaded
8.1. Specifying the controller
If you write @WebMvcTest without parameters, Spring loads all controllers in the application. Then you must mock the dependencies of every controller, even those unrelated to the test. Therefore, always specify the controller you’re testing:
@WebMvcTest(OrderController.class)
8.2. Loading specific beans
Sometimes a controller uses a small bean with no side effects, like an OrderMapper. Mocking this bean doesn’t add much value. You can load the real bean using @Import:
@WebMvcTest(OrderController.class)
@Import(OrderMapper.class)
class OrderControllerTest {
// ...
}
8.3. When using Spring Security
If spring-boot-starter-security is on the classpath, @WebMvcTest will also load the security filter chain. The tests above will return 401 or 403. You need to add this dependency:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
Then simulate a user and include CSRF token for requests that modify data:
@Test
@WithMockUser(roles = "USER")
void shouldCreateOrderWhenAuthenticated() throws Exception {
// ... stub orderService like section 5.1
mockMvc.perform(post("/api/orders")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerName": "Le Van C", "total": 120000}
"""))
.andExpect(status().isCreated());
}
Note: A custom
SecurityFilterChainclass (marked@Configuration) is not automatically loaded in all versions. If you want to test your authorization rules correctly, add@Import(SecurityConfig.class).
9. Common pitfalls
NoSuchBeanDefinitionException when starting the test. The controller depends on a bean outside the web layer that you haven’t mocked. Solution: add @MockitoBean for that bean, or @Import it.
Annotations on the @SpringBootApplication class break tests. For example, putting @EnableJpaRepositories or @EnableScheduling directly on the main class. @WebMvcTest still reads this class, so it tries to create repositories and fails due to missing DataSource. Solution: move those annotations to a separate @Configuration class.
Forgetting contentType. Results in 415 instead of the expected status.
Checking results with MockMvc but the logic is in the service. @WebMvcTest should only check the web layer’s responsibilities:
- routing,
- serialization,
- validation,
- status code,
- exception mapping.
Test business logic separately in the service using pure unit tests.
Over-mocking. If a test needs to stub five or six methods, the controller might be doing too much. That’s a signal to refactor, not to add more stubs.
Note: Don’t use
@WebMvcTestwhen you want to test end-to-end flows with the database, or when you need to test real server behavior (port, HTTP/2, container filters). In that case, use@SpringBootTest(webEnvironment = RANDOM_PORT)withTestRestTemplateorWebTestClient. If your application uses WebFlux, the corresponding slice is@WebFluxTest, not@WebMvcTest.
10. Conclusion
@WebMvcTest helps you test controllers quickly and focused. It loads only the web layer; backend dependencies are replaced with @MockitoBean. Combined with MockMvc or MockMvcTester, you can verify the behavior that clients actually see: status codes, headers, JSON, validation, and exception mapping.
Key rules to remember:
- Always specify the controller in
@WebMvcTest. - Mock exactly what the controller needs.
- Don’t forget
contentTypewhen sending a body. - Keep the
@SpringBootApplicationclass clean so test slices don’t pull in unnecessary configuration.