REST Endpoint Testing With MockMvc
In this post I’m going to show you how to test a Spring MVC Rest endpoint without deploying your application to a server. In the past, full integration tests were the only meaningful way to test a Spring REST endpoint. This involved spinning up a test server like Tomcat or Jetty, deploying the application, calling the test endpoint, running some assertions and then terminating the server. While this is an effective way to test an endpoint, it isn’t particularly fast. We’re forced to wait while the entire application is stood up, just to test a single endpoint.
An alternative approach is to unit test by manually instantiating the Controller and mocking out any required dependencies. While such a test will run much faster than a full integration test, its of limited value. The problem is that by manually instantiating the Controller we’re bypassing Springs Dispatcher Servlet, and as a result missing out on core features like
- request URL mapping
- request deserialization
- response serialization
- exception translation
What we’d really like is the best of both worlds. The ability to test a fully functional REST controller but without the overhead of deploying the entire application to a server. Thankfully that’s exactly what MockMvc allows us to do. It stands up the Dispatcher Servlet and all required MVC components, allowing us to test an endpoint in a proper web environment, but without the overhead of running a server.
MockMvc provides a fluent API, allowing you to write tests that are self descriptive. This is a great feature as your tests will pretty much read like English, making life easier for developers coming behind you.
Defining a Controller
Before we can put MockMvc through its paces we need a REST endpoint to test. The AccountController defined below exposes 2 endpoints, one to create an Account and one to retrieve an Account.
@RestController public class AccountController { private AccountService accountService; @Autowired public AccountController(AccountService accountService) { this.accountService = accountService; } @RequestMapping(value = { "/api/account" }, method = { RequestMethod.POST }) public Account createAccount(@RequestBody Account account, HttpServletResponse httpResponse, WebRequest request) { Long accountId = accountService.createAccount(account); account.setAccountId(accountId); httpResponse.setStatus(HttpStatus.CREATED.value()); httpResponse.setHeader("Location", String.format("%s/api/account/%s", request.getContextPath(), accountId)); return account; } @RequestMapping(value = "/api/account/{accountId}", method = RequestMethod.GET) public Account getAccount(@PathVariable("accountId") Long accountId) { /* validate account Id parameter */ if (accountId < 9999) { throw new InvalidAccountRequestException(); } Account account = accountService.loadAccount(accountId); if(null==account){ throw new AccountNotFoundException(); } return account; } }
- createAccount – calls an AccountService to create the Account, then returns the Account along with a HTTP header specifying its location for future retrieval. The AccountService will be mocked using Mockito so that we can keep our test focus solely on the web layer.
- retrieveAccount – takes an account Id from the URL and performs some simple validation to ensure the value is greater than 9999. If the validation fails a custom InvalidAccountRequestExcption is thrown. This exception is caught and translated by an exception handler we’ll define later. Next the mock AccountService is called to retrieve the specified Account, before returning it to the client.
Exception Handler
The custom runtime exceptions thrown in getAccount are intercepted and mapped to appropriate HTTP response codes using the exception handler defined below.
@Slf4j @ControllerAdvice public class ControllerExceptionHandler { @ResponseStatus(HttpStatus.NOT_FOUND) // 404 @ExceptionHandler(AccountNotFoundException.class) public void handleNotFound(AccountNotFoundException ex) { log.error("Requested account not found"); } @ResponseStatus(HttpStatus.BAD_REQUEST) // 400 @ExceptionHandler(InvalidAccountRequestException.class) public void handleBadRequest(InvalidAccountRequestException ex) { log.error("Invalid account supplied in request"); } @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // 500 @ExceptionHandler(Exception.class) public void handleGeneralError(Exception ex) { log.error("An error occurred processing request" + ex); } }
MockMVC Setup
The @SpringBootTest annotation is used to specify the application configuration to load prior to running the tests. We could have referenced a test specific configuration here, but given our simple project setup its fine to use the main Application config class.
import static org.mockito.Matchers.any; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.setup.MockMvcBuilders.webAppContextSetup; @RunWith(SpringRunner.class) @SpringBootTest(classes={ Application.class }) public class AccountControllerTest { private MockMvc mockMvc; @Autowired private WebApplicationContext webApplicationContext; @MockBean private AccountService accountServiceMock; @Before public void setUp() { this.mockMvc = webAppContextSetup(webApplicationContext).build(); }
The injected WebApplicationContext is a sub component of Springs main application context and encapsulates Springs configuration for web related components such as the controller and exception handler we defined earlier.
The @MockBean annotation tells Spring to create a mock instance of AccountService and add it to the application context so that it gets injected into the AccountController. We have a handle on it in the test so that we can define its behaviour prior to running each test.
The setup method uses the statically imported webAppContextSetup method from MockMvcBuilders and the injected WebApplicationContext to build a MockMvc instance.
Create Account Test
Now that we’ve created the MockMvc instance, its time to put it to work with a test for the create account endpoint.
@Test public void should_CreateAccount_When_ValidRequest() throws Exception { when(accountServiceMock.createAccount(any(Account.class))).thenReturn(12345L); mockMvc.perform(post("/api/account") .contentType(MediaType.APPLICATION_JSON) .content("{ "accountType": "SAVINGS", "balance": 5000.0 }") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isCreated()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(header().string("Location", "/api/account/12345")) .andExpect(jsonPath("$.accountId").value("12345")) .andExpect(jsonPath("$.accountType").value("SAVINGS")) .andExpect(jsonPath("$.balance").value(5000)); }
On line 4 we use mockito to define the expected behaviour of the mock AccountService which was injected into the AccountController. We tell the mock that upon receiving any Account instance it should return a 12345.
Lines 6 to 9 uses mockMvc to define a HTTP POST request to the URI /api/account. The request content type is JSON and the request body contains a JSON definition of the Account to be created. Finally an accept header is set to tell the server that the client expects a JSON response.
Lines 10 to 15 use statically imported methods from MockMvcResultMatchers to perform assertions on the response. We begin by checking that the response code returned is HTTP 200 ‘Created’ and that the content type is indeed JSON. We then check for the existence of a HTTP header ‘Location’ that contains the request URL for retrieving the created account. The final 3 lines use jsonPath to check that the JSON response is as expected. JsonPath is like an JSON equivalent to XPath that allows you to query JSON using path expressions. For more information take a look at their documentation.
Retrieve Account Test
The retrieve account test follows a similar format to that described above.
@Test public void should_GetAccount_When_ValidRequest() throws Exception { /* setup mock */ Account account = new Account(12345L, EnumAccountType.SAVINGS, 5000.0); when(accountServiceMock.loadAccount(12345L)).thenReturn(account); mockMvc.perform(get("/api/account/12345") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8)) .andExpect(jsonPath("$.accountId").value(12345)) .andExpect(jsonPath("$.accountType").value("SAVINGS")) .andExpect(jsonPath("$.balance").value(5000.0)); }
We begin by creating an Account object and use it to define the behaviour of the mock AccountService. The MockMvc instance is used to perform a HTTP GET request that expects a JSON response. We check the response for a HTTP 200 ‘OK’ response code, a JSON content type and a JSON response body containing the requested account.
Retrieve Account Error Test
@Test public void should_Return404_When_AccountNotFound() throws Exception { /* setup mock */ when(accountServiceMock.loadAccount(12345L)).thenReturn(null); mockMvc.perform(get("/api/account/12345") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isNotFound()); }
This test configures the mock AccountService to return null when called. This demonstrates the power of @MockBean and its ability to register a mock object with the application context, and in turn have that mock injected into the AccountController. We’re able to easily test non happy path logic in our controller and ensure that throwing an AccountNotFoundException results in a HTTP 404 response to the client.
Wrapping Up
In this post you saw how MockMvc allows you to thoroughly test spring web controllers. MockMvc strikes a great balance between full integration tests and relatively low value unit tests. You get the benefit of testing a fully functional web layer without the overhead of deploying to a server.
I’m not suggesting MockMvc as an alternative to full integration tests, but rather something to compliment them. I usually write a low number of pure integration tests that focus on the happy path. MockMvc tests are faster than integration tests so are ideal when you want more granular web layer tests that will run quickly.
The sample code for this post is available on Github so feel free to pull it and have a play around. If you have any comments or questions please leave a note below.
Leave A Comment