REST Endpoint Testing With MockMvc
See how to test a Spring REST endpoint without a servlet container.
Join the DZone community and get the full member experience.
Join For FreeIn this post, I'm going to show you how to test a Spring REST endpoint without a servlet container. In the past, integration tests were the only meaningful way to test a Spring REST endpoint. This involved spinning up a container like Tomcat or Jetty, deploying the application, calling the endpoint, running some assertions, and then stopping the container. 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 write vanilla unit tests for each REST controller, manually instantiating the Controller and mocking out any dependencies. These tests will run much faster than integration tests, but they're of limited value. The problem is, by manually creating the Controller outside of the Spring Application Context, the controller loses all the useful request/response handling that Spring takes care of on our behalf. Things like:
- request routing/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 app to a container. Thankfully, that's exactly what MockMvc allows you to do. It stands up a Dispatcher Servlet and all required MVC components, allowing you to test an endpoint in a proper web environment, but without the overhead of running a container.
Defining a Controller
Before we can put MockMvc through its paces, we need a REST endpoint to test. The controller 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 the
AccountService
to create theAccount
, then returns theAccount
along with a HTTP header specifying its location for future retrieval. Later, we'll mock theAccountService
with Mockito so that we can keep our tests focused on the REST 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
InvalidAccountRequestException
is thrown. This exception is caught and translated by an exception handler (defined later). Next, the mockAccountService
is called to retrieve theAccount
, 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 before 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(webEnvironment=WebEnvironment.MOCK, 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 configuration for Spring web 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's injected into AccountController
. We have a handle on it in the test so that we can define its behavior before 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
Lets put the Account
MockMvc instance 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 behavior of the mock AccountService
. We tell the mock that when it receives an Account
it should return 12345.
Lines 6 to 9 use mockMvc to define a POST request to /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 endpoint the client expects a JSON response.
Lines 10 to 15 use statically imported methods from MockMvcResukltMatchers
to perform assertions on the response. We begin by checking that the response code returned is 201 'Created' and that the content type is JSON. We then check for the existence of the 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 in the format expected. JsonPath is a 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 pattern to test we 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
and use it to define the behavior of the mock AccountService
. The MockMvc
instance is used to perform GET request that expects a JSON response. We check the response for a 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());
}
Finally, we'll test an error scenario. The mock AccountService
will return null causing an AccountNotFoundException
to be thrown. This allows us to test ControllerExceptionHandler
we defined earlier. Line 9 above expects the AccountNotFoundExcepion
to result in a 404 response to the client.
Wrapping Up
In this post, we looked at how MockMvc can help you test your REST endpoints without standing up a servlet container. MockMvc hits the sweet spot between slow integration tests and fast (relatively low-value) unit tests. You get the benefit of testing your fully functional REST layer without the overhead of deploying to a container.
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.
Published at DZone with permission of Brian Hannaway, DZone MVB. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments