Unit Testing for Spring MVC Controllers

Once the services, DAO, and supporting classes are tested, it’s time for the controller. Controllers are often the hardest layer to test, so many developers (based on observation) reach for Selenium or, worse, test by hand. That works, but it makes exercising individual logic branches awkward and slow, and no one wants to wait for a browser to spin up before checking in code. The Spring MVC Test framework solves this by driving full controller logic through fast unit tests, and it has lived in Spring’s core ever since Spring 4.

Note: This post has been updated for Spring Framework 7.0 and JUnit 6. It was originally written in 2014 against Spring 4 with JUnit 4 (SpringJUnit4ClassRunner and MockitoAnnotations.initMocks). The test class now runs on JUnit Jupiter with Mockito’s MockitoExtension, the JSON content-type assertion was corrected (modern Spring no longer appends ;charset=UTF-8), and every dependency has been bumped to its current GA line.

Getting Ready

We can pull in the test dependencies by adding the following declarations to the pom.xml of our example application:

  • JUnit Jupiter: the JUnit 6 programming and extension model for writing tests.
  • Spring Test: provides MockMvc and the request/response helpers.
  • Mockito: mocks the lower layers so the controller is tested in isolation.
  • JsonPath: lets us assert against fields of a JSON response.
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>6.1.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-test</artifactId>
    <version>${spring.version}</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.23.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.23.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>com.jayway.jsonpath</groupId>
    <artifactId>json-path</artifactId>
    <version>3.0.0</version>
    <scope>test</scope>
</dependency>

Here ${spring.version} is the Spring Framework version your project already manages (7.0.8 at the time of writing). JSON serialization itself is handled by Jackson, which a Spring MVC web application already has on its classpath via spring-webmvc, so there is no extra dependency to add for that.

The Controller Under Test

Let’s start from the thing we want to test. AccountsController has two endpoints: one renders the login page, and one validates a submitted account and returns the result as JSON.

@Controller
@RequestMapping("/accounts")
public class AccountsController {
    private final UserService userService;

    public AccountsController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/login")
    public ModelAndView loginView() {
        return new ModelAndView("accounts/login");
    }

    @PostMapping("/login.action")
    @ResponseBody
    public Map<String, Object> loginAction(
            @RequestParam String username,
            @RequestParam String password) {
        Map<String, Object> result = new HashMap<>();
        boolean usernameEmpty = username.isEmpty();
        boolean passwordEmpty = password.isEmpty();
        result.put("isUsernameEmpty", usernameEmpty);
        result.put("isPasswordEmpty", passwordEmpty);

        boolean accountValid = false;
        if (!usernameEmpty && !passwordEmpty) {
            User user = userService.isAccountValid(username, DigestUtils.md5Hex(password));
            accountValid = (user != null);
        }
        result.put("isAccountValid", accountValid);
        result.put("isSuccessful", accountValid);
        return result;
    }
}

The controller depends on UserService to decide whether an account is valid. Note that loginAction hashes the raw password with MD5 before handing it to the service. We’ll have to mirror that in the test.

How the Spring MVC Test Framework Works

Every test we write follows the same two steps: send a request to the controller method, then verify the response. The framework gives us three “core” classes for exactly that:

  • MockMvcRequestBuilders: static factory methods (get, post, …) that build the request we want to send.
  • MockMvc: the entry point; we fire a request with its perform(RequestBuilder) method.
  • MockMvcResultMatchers: static methods (status, view, jsonPath, …) that assert on the response.

Importing these statically keeps the tests readable, so the snippets below assume import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; and the matching MockMvcResultMatchers.*, plus Mockito’s and JUnit’s static helpers.

Setting Up the Test Class

@ExtendWith(MockitoExtension.class)
class AccountControllerTest {
    @Mock
    private UserService userService;

    @InjectMocks
    private AccountsController accountsController;

    private MockMvc mockMvc;

    @BeforeEach
    void setUp() {
        mockMvc = MockMvcBuilders.standaloneSetup(accountsController).build();
    }
}

@ExtendWith(MockitoExtension.class) replaces the old JUnit 4 @RunWith plus the now-removed MockitoAnnotations.initMocks(this) call. The extension creates the @Mock and injects it into the @InjectMocks controller for us. Because AccountsController only depends on UserService, that’s all Mockito needs to wire.

MockMvcBuilders.standaloneSetup(accountsController) builds a MockMvc instance around a single controller without loading a Spring application context. This keeps the test a true unit test, and it’s why the original @ContextConfiguration and test-spring-context.xml are gone. They were never needed for standalone testing.

Writing the Tests

Rendering the Login Page

The loginView method handles GET /accounts/login and returns the name of the view to render. So the test sends that request, asserts the status is 200 OK, and asserts the resolved view name:

@Test
void rendersLoginView() throws Exception {
    MvcResult result = mockMvc.perform(get("/accounts/login"))
            .andExpect(status().isOk())
            .andExpect(view().name("accounts/login"))
            .andReturn();

    assertNotNull(result.getModelAndView());
}

Calling andReturn() hands back the MvcResult so we can make further plain JUnit assertions, such as confirming a ModelAndView was actually produced.

Accepting a Valid Login

loginAction posts a username and password, calls UserService to validate the account, and returns a JSON object. To test the success path we stub the service to return a User, then assert each field of the JSON response.

The key detail: the controller MD5-hashes the password before calling the service, so the stub has to expect the hashed value while the request sends the raw one.

@Test
void acceptsValidLogin() throws Exception {
    String hashedPassword = DigestUtils.md5Hex("Password");
    when(userService.isAccountValid("20116524", hashedPassword))
            .thenReturn(new User("20116524", hashedPassword));

    mockMvc.perform(post("/accounts/login.action")
                    .param("username", "20116524")
                    .param("password", "Password"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.isSuccessful").value(true))
            .andExpect(jsonPath("$.isAccountValid").value(true))
            .andExpect(jsonPath("$.isUsernameEmpty").value(false))
            .andExpect(jsonPath("$.isPasswordEmpty").value(false));
}

Each jsonPath("$.field").value(...) reaches into the JSON body and asserts a single field. $ is the document root. Note the content type is application/json: modern Spring drops the trailing ;charset=UTF-8 that older tutorials assert, since UTF-8 is already the default.

Rejecting an Invalid Login

The real payoff of unit-testing controllers is exercising the branches a browser test makes painful. Here we stub the service to reject the credentials and confirm the controller reports failure, and we use verify to prove the controller actually consulted the service:

@Test
void rejectsInvalidLogin() throws Exception {
    when(userService.isAccountValid(anyString(), anyString())).thenReturn(null);

    mockMvc.perform(post("/accounts/login.action")
                    .param("username", "20116524")
                    .param("password", "WrongPassword"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.isSuccessful").value(false))
            .andExpect(jsonPath("$.isAccountValid").value(false));

    verify(userService).isAccountValid(eq("20116524"), anyString());
}

Tip: Spring 6.2+ also ships MockMvcTester, a fluent, AssertJ-based API (assertThat(mockMvc.get()...)). It’s worth adopting on new code, but the classic perform(...).andExpect(...) style above remains fully supported and is the clearest starting point.

Summary

We’ve written unit tests for Spring MVC controllers with the Spring MVC Test framework. Along the way we covered how to:

  • build requests that the controller methods process,
  • write assertions for the responses they return,
  • test a method that renders a view, and
  • test a method that handles a form submission, including both its success and failure branches.

References