We have just tested a traditional controller redirecting to a view. Testing a REST controller is very similar in principle, but there are a few subtleties.
Since we are going to test the JSON output of our controller, we need a JSON assertion library. Add the following dependency to your build.gradle
file:
testCompile 'com.jayway.jsonpath:json-path'
Let's write a test for the SearchApiController
class, the controller that allows searching for a tweet and returns results as JSON or XML:
package masterSpringMvc.search.api; import masterSpringMvc.MasterSpringMvcApplication; import masterSpringMvc.search.StubTwitterSearchConfig; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.SpringApplicationConfiguration; import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; import static org.hamcrest.Matchers.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = { MasterSpringMvcApplication.class, StubTwitterSearchConfig.class }) @WebAppConfiguration public class SearchApiControllerTest { @Autowired private WebApplicationContext wac; private MockMvc mockMvc; @Before public void setup() { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); } @Test public void should_search() throws Exception { this.mockMvc.perform( get("/api/search/mixed;keywords=spring") .accept(MediaType.APPLICATION_JSON)) .andDo(print()) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$", hasSize(2))) .andExpect(jsonPath("$[0].text", is("tweetText"))) .andExpect(jsonPath("$[1].text", is("secondTweet"))); } }
Note the simple and elegant assertions on the JSON output. Testing our user controller will require a bit more work.
First, let's add assertj
to the classpath; it will help us write cleaner tests:
testCompile 'org.assertj:assertj-core:3.0.0'
Then, to simplify testing, add a reset()
method to our UserRepository
class that will help us with the test:
void reset(User... users) { userMap.clear(); for (User user : users) { save(user); } }
In real life, we should probably extract an interface and create a stub for testing. I will leave that as an exercise for you.
Here is the first test that gets the list of users:
package masterSpringMvc.user.api; import masterSpringMvc.MasterSpringMvcApplication; import masterSpringMvc.user.User; import masterSpringMvc.user.UserRepository; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.SpringApplicationConfiguration; import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; import static org.hamcrest.Matchers.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @RunWith(SpringJUnit4ClassRunner.class) @SpringApplicationConfiguration(classes = MasterSpringMvcApplication.class) @WebAppConfiguration public class UserApiControllerTest { @Autowired private WebApplicationContext wac; @Autowired private UserRepository userRepository; private MockMvc mockMvc; @Before public void setup() { this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); userRepository.reset(new User("[email protected]")); } @Test public void should_list_users() throws Exception { this.mockMvc.perform( get("/api/users") .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$", hasSize(1))) .andExpect(jsonPath("$[0].email", is("[email protected]"))); } }
For this to work, add a constructor to the User
class, taking the e-mail property as a parameter. Be careful: you also need to have a default constructor for Jackson.
The test is very similar to the previous test with the additional setup of UserRepository
.
Let's test the POST
method that creates a user now:
import static org.assertj.core.api.Assertions.assertThat; // Insert this test below the previous one @Test public void should_create_new_user() throws Exception { User user = new User("[email protected]"); this.mockMvc.perform( post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content(JsonUtil.toJson(user)) ) .andExpect(status().isCreated()); assertThat(userRepository.findAll()) .extracting(User::getEmail) .containsOnly("[email protected]", "[email protected]"); }
There are two things to be noted. The first one is the use of AssertJ to assert the content of the repository after the test. You will need the following static import for that to work:
import static org.assertj.core.api.Assertions.assertThat;
The second is that we use a utility method to convert our object to JSON before sending it to the controller. For that purpose, I created a simple utility class in the utils
package, as follows:
package masterSpringMvc.utils; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.IOException; public class JsonUtil { public static byte[] toJson(Object object) throws IOException { ObjectMapper mapper = new ObjectMapper(); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); return mapper.writeValueAsBytes(object); } }
The tests for the DELETE
method are as follows:
@Test public void should_delete_user() throws Exception { this.mockMvc.perform( delete("/api/user/[email protected]") .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()); assertThat(userRepository.findAll()).hasSize(0); } @Test public void should_return_not_found_when_deleting_unknown_user() throws Exception { this.mockMvc.perform( delete("/api/user/[email protected]") .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isNotFound()); }
Finally, here's the test for the PUT
method, which updates a user:
@Test public void put_should_update_existing_user() throws Exception { User user = new User("[email protected]"); this.mockMvc.perform( put("/api/user/[email protected]") .content(JsonUtil.toJson(user)) .contentType(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()); assertThat(userRepository.findAll()) .extracting(User::getEmail) .containsOnly("[email protected]"); }
Whoops! The last test does not pass! By checking the implementation of UserApiController
, we can easily see why:
@RequestMapping(value = "/user/{email}", method = RequestMethod.PUT) public ResponseEntity<User> updateUser(@PathVariable String email, @RequestBody User user) throws EntityNotFoundException { User saved = userRepository.update(email, user); return new ResponseEntity<>(saved, HttpStatus.CREATED); }
We returned the wrong status in the controller! Change it to HttpStatus.OK
and the test should be green again.
With Spring, one can easily write controller tests using the same configuration of our application, but we can just as efficiently override or change some elements in our testing setup.
Another interesting thing that you will notice while running all the tests is that the application context is only loaded once, which means that the overhead is actually very small.
Our application is small too, so we did not make any effort to split our configuration into reusable chunks. It can be a really good practice not to load the full application context inside of every test. You can actually split the component scanned into different units with the @ComponentScan
annotation.
This annotation has several attributes that allow you to define filters with includeFilter
and excludeFilter
(loading only the controller for instance) and scan specific packages with the basePackageClasses
and basePackages
annotations.
You can also split your configuration into multiple @Configuration
classes. A good example would be splitting the code for the users and for the tweet parts of our application into two independent parts.
We will now have a look at acceptance tests, which are a very different kind of beast.