Have you ever felt stressed when trying to write Controller tests using @SpringBootTest, only to find that DB configuration becomes necessary and startup is slow? If you only want to verify the HTTP layer, using @WebMvcTest lets you write tests more simply and quickly.
This time, I’ll introduce how to write Controller-specific tests by combining @WebMvcTest and MockMvc, along with implementation examples. If you have basic knowledge of JUnit and Mockito, you can follow along (for the basics, see Spring Boot Testing Introduction with JUnit and Mockito).
Difference between @WebMvcTest and @SpringBootTest
@SpringBootTest starts the entire application context, so everything including DB and message queues is launched. It’s useful for integration tests, but it’s clearly overkill when you only want to check the Controller.
@WebMvcTest starts only the Web layer (Controller, Filter, Converter, etc.). Service and Repository aren’t included in the context, so they’re mocked with @MockBean.
| @SpringBootTest | @WebMvcTest | |
|---|---|---|
| Startup scope | Entire application | Web layer only |
| Speed | Slow | Fast |
| DB connection | Often required | Not needed |
| Use case | Integration tests | Controller unit tests |
If you only want to verify the HTTP layer, @WebMvcTest is appropriate. For integration tests including the DB, the combination of @SpringBootTest and Testcontainers is suitable.
Prepare the Controller to be tested
First, let’s prepare a simple UserController to be tested.
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.findById(id));
}
@PostMapping
public ResponseEntity<Void> createUser(@Valid @RequestBody UserRequest request) {
Long id = userService.create(request);
return ResponseEntity.created(URI.create("/users/" + id)).build();
}
}
Basic setup of @WebMvcTest
The test class looks like this.
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@MockBean
UserService userService;
}
There are three key points.
- Specify the Controller to be tested with
@WebMvcTest(UserController.class) MockMvcis auto-configured when using@WebMvcTest, so you just inject it with@Autowired- Mock
UserServicewith@MockBean. Since Service isn’t included in the context, without this, aNoSuchBeanDefinitionExceptionwill occur at startup
Testing GET requests
Pass the request to perform() and chain with andExpect() for verification.
@Test
void canGetUserById() throws Exception {
// Arrange
UserResponse user = new UserResponse(1L, "Taro", "taro@example.com");
when(userService.findById(1L)).thenReturn(user);
// Act & Assert
mockMvc.perform(get("/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Taro"))
.andExpect(jsonPath("$.email").value("taro@example.com"));
}
Adding static imports makes it cleaner.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import static org.mockito.Mockito.*;
import static org.hamcrest.Matchers.*;
jsonPath can also specify nested fields. For array elements, $.items[0].name works. For checking list size, jsonPath("$.items", hasSize(3)) is convenient.
Testing POST requests
When sending a request body, add .content() and .contentType().
@Test
void canCreateUser() throws Exception {
// Arrange
UserRequest request = new UserRequest("Hanako", "hanako@example.com");
when(userService.create(any())).thenReturn(2L);
// Act & Assert
mockMvc.perform(post("/users")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/users/2"));
}
ObjectMapper is automatically provided in the @WebMvcTest context, so you can inject it with @Autowired.
Testing validation errors (400 Bad Request)
When you’ve set up input validation with @Valid, sending an invalid request returns 400. Let’s write this pattern too.
@Test
void invalidRequestReturns400() throws Exception {
// Empty name causes validation error
UserRequest request = new UserRequest("", "hanako@example.com");
mockMvc.perform(post("/users")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest());
}
If you’ve configured a custom error response format, you can verify each field with jsonPath("$.errors[0].field").value("name") (see here for REST ExceptionHandler). For custom validation annotations, please also refer to this article.
Verifying Service calls
You can confirm whether the Controller calls the Service correctly with verify().
@Test
void callsServiceFindByIdOnce() throws Exception {
when(userService.findById(1L))
.thenReturn(new UserResponse(1L, "Taro", "taro@example.com"));
mockMvc.perform(get("/users/1"))
.andExpect(status().isOk());
verify(userService, times(1)).findById(1L);
}
When you want to confirm that a method wasn’t called, you can use verify(userService, never()).delete(any()).
When Spring Security is in use
Debugging responses with print()
When tests don’t pass as expected, passing MockMvcResultHandlers.print() to andDo() will output the full request/response to the console. It’s extremely useful as the first step in investigating the cause.
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
mockMvc.perform(get("/users/1"))
.andDo(print())
.andExpect(status().isOk());
Since you can view headers, body, and handler information all at once, you can also use it to check when you’ve written the wrong jsonPath path.
Testing file uploads (Multipart)
By combining multipart() and MockMultipartFile, you can write file upload tests.
@Test
void canUploadFile() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file", "avatar.png", MediaType.IMAGE_PNG_VALUE, "dummy-bytes".getBytes());
mockMvc.perform(multipart("/users/1/avatar").file(file).with(csrf()))
.andExpect(status().isOk());
}
If you also want to send request parameters together, chain .param("key", "value").
Testing POST/PUT/DELETE that requires a CSRF token
When testing POST, PUT, DELETE with Spring Security enabled, 403 Forbidden will be returned due to the missing CSRF token. Adding .with(csrf()) lets you send requests with a CSRF token.
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@Test
@WithMockUser
void canPostWithCsrfToken() throws Exception {
mockMvc.perform(post("/users")
.with(csrf())
.content(objectMapper.writeValueAsString(new UserRequest("Hanako", "hanako@example.com")))
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isCreated());
}
It’s unnecessary if you’ve disabled CSRF for API-only use, but since @WebMvcTest loads Security’s auto-configuration, it’s safer to explicitly add .with(csrf()) in the default configuration.
Adding spring-security-test allows you to send requests as an authenticated user with @WithMockUser.
@Test
@WithMockUser(username = "admin", roles = "ADMIN")
void adminReceives200() throws Exception {
mockMvc.perform(get("/admin/users"))
.andExpect(status().isOk());
}
Since @WebMvcTest also loads Security’s auto-configuration, accessing without authentication returns 401 or 302. Please check the detailed test configuration for Security separately.
Common errors and solutions
| Error | Main cause | Solution |
|---|---|---|
NoSuchBeanDefinitionException: UserService | Service etc. not prepared with @MockBean | Add @MockBean UserService userService; to the test class |
403 Forbidden (POST/PUT/DELETE) | CSRF token missing when Security is enabled | Add .with(csrf()) |
401 Unauthorized | Accessing without authentication | Add @WithMockUser, or set the relevant endpoint to permitAll() |
MediaType not supported (415) | contentType() not specified / value mismatch | Explicitly specify .contentType(MediaType.APPLICATION_JSON) |
jsonPath returns null | Response structure and path don’t match | Check the response with andDo(print()) and fix the path |
Cheat sheet
| What you want to do | How to write |
|---|---|
| GET | mockMvc.perform(get("/path")) |
| POST (JSON) | .contentType(MediaType.APPLICATION_JSON).content(json) |
| Query parameter | .param("key", "value") |
| Status verification | .andExpect(status().isOk()) |
| JSON verification | .andExpect(jsonPath("$.field").value(...)) |
| Array size | .andExpect(jsonPath("$.items", hasSize(3))) |
| Header verification | .andExpect(header().string("Location", "...")) |
| Authenticated user | @WithMockUser(roles = "ADMIN") |
| Add CSRF | .with(csrf()) |
| Debug output | .andDo(print()) |
Summary
With @WebMvcTest, you can quickly test only the Controller’s HTTP layer without starting up the DB or external services. By mastering the pattern of mocking the Service with @MockBean and verifying with the perform/andExpect chain of MockMvc, the quality of your Controllers will improve significantly.
For integration tests involving DB connections, also check out the combination of @SpringBootTest and Testcontainers, and for mocking external APIs, try WireMock.
Related Articles
- Customizing JSON Serialization with Spring Boot’s Jackson Configuration - Organizing the format design of response JSON, which is a prerequisite for verification with
jsonPath, stabilizes your verification logic. - How to Create Custom Validation Annotations in Spring Boot - Useful when you want to return 400 responses from
@Validwith your own custom rules.