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 scopeEntire applicationWeb layer only
SpeedSlowFast
DB connectionOften requiredNot needed
Use caseIntegration testsController 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)
  • MockMvc is auto-configured when using @WebMvcTest, so you just inject it with @Autowired
  • Mock UserService with @MockBean. Since Service isn’t included in the context, without this, a NoSuchBeanDefinitionException will 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

ErrorMain causeSolution
NoSuchBeanDefinitionException: UserServiceService etc. not prepared with @MockBeanAdd @MockBean UserService userService; to the test class
403 Forbidden (POST/PUT/DELETE)CSRF token missing when Security is enabledAdd .with(csrf())
401 UnauthorizedAccessing without authenticationAdd @WithMockUser, or set the relevant endpoint to permitAll()
MediaType not supported (415)contentType() not specified / value mismatchExplicitly specify .contentType(MediaType.APPLICATION_JSON)
jsonPath returns nullResponse structure and path don’t matchCheck the response with andDo(print()) and fix the path

Cheat sheet

What you want to doHow to write
GETmockMvc.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.