Open Source Your Knowledge, Become a Contributor
Technology knowledge has to be shared and made accessible for free. Join the movement.
Testing a HATEOAS service
This post is a follow up post to a few that I have written recently relating to writing a HATEOAS service with Spring (Applying HATEOAS to a REST API with Spring Boot and Global exception handling with @ControllerAdvice). Now that we have looked through setting up a service and how to add some error handling, it's probably now worth writing some tests to ensure that it works as expected.
There are a few extra dependencies that need to be added to allow the tests to be written. I have excluded the dependencies for setting up the HATEOAS service as they can be found in the previous post here. spring-boot-starter-test
is the default requirement for most tests when using Spring Boot and hamcrest-core
allows us to assert the JSON returned in responses more easily.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-core</artifactId>
<version>1.3</version>
<scope>test</scope>
</dependency>
Before we look at the test itself, lets have a look at the code that we are trying to test. The short story of the example below is that it is a HATEOAS service which returns a response that can contain an object plus links to relevant resources. For more information about what is going on refer to the previous post mentioned earlier (added again here if you still need to look at it).
@RestController
@RequestMapping(value = "/people", produces = "application/hal+json")
public class PersonController {
final PersonRepository personRepository;
public PersonController(final PersonRepository personRepository) {
this.personRepository = personRepository;
}
@GetMapping
public ResponseEntity<Resources<PersonResource>> all() {
final List<PersonResource> collection =
personRepository.findAll().stream().map(PersonResource::new).collect(Collectors.toList());
final Resources<PersonResource> resources = new Resources<>(collection);
final String uriString = ServletUriComponentsBuilder.fromCurrentRequest().build().toUriString();
resources.add(new Link(uriString, "self"));
return ResponseEntity.ok(resources);
}
@GetMapping("/{id}")
public ResponseEntity<PersonResource> get(@PathVariable final long id) {
return personRepository
.findById(id)
.map(p -> ResponseEntity.ok(new PersonResource(p)))
.orElseThrow(() -> new PersonNotFoundException(id));
}
@PostMapping
public ResponseEntity<PersonResource> post(@RequestBody final Person personFromRequest) {
final Person person = personRepository.save(new Person(personFromRequest));
final URI uri =
MvcUriComponentsBuilder.fromController(getClass())
.path("/{id}")
.buildAndExpand(person.getId())
.toUri();
return ResponseEntity.created(uri).body(new PersonResource(person));
}
@PutMapping("/{id}")
public ResponseEntity<PersonResource> put(
@PathVariable("id") final long id, @RequestBody Person personFromRequest) {
final Person person = new Person(personFromRequest, id);
personRepository.save(person);
final PersonResource resource = new PersonResource(person);
final URI uri = ServletUriComponentsBuilder.fromCurrentRequest().build().toUri();
return ResponseEntity.created(uri).body(resource);
}
@DeleteMapping("/{id}")
public ResponseEntity<?> delete(@PathVariable("id") final long id) {
return personRepository
.findById(id)
.map(
p -> {
personRepository.deleteById(id);
return ResponseEntity.noContent().build();
})
.orElseThrow(() -> new PersonNotFoundException(id));
}
}
Now we can write the test code.
@RunWith(SpringRunner.class)
@WebMvcTest(PersonController.class)
public class PersonControllerTest {
// Taken out due to configuring mockMvc manually before tests
// @Autowired
private MockMvc mockMvc;
@Autowired private ObjectMapper mapper;
@MockBean private PersonRepository personRepository;
private static final DateTimeFormatter formatter =
DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm");
// http://localhost path added because the href's returned contain localhost in their strings
private static String BASE_PATH = "http://localhost/people";
private static String MEMBERSHIPS_PATH = "/memberships";
private static final long ID = 1;
private Person person;
@Before
public void setup() {
// Added so @ControllerAdvice could be added to test exception handling.
mockMvc =
MockMvcBuilders.standaloneSetup(new PersonController(personRepository))
.setControllerAdvice(new PersonControllerAdvice())
.build();
setupPerson();
}
private void setupPerson() {
person = new Person();
person.setId(ID);
person.setFirstName("first");
person.setSecondName("second");
person.setDateOfBirth(LocalDateTime.now());
person.setProfession("developer");
person.setSalary(0);
}
@Test
public void getReturnsCorrectResponse() throws Exception {
given(personRepository.findById(ID)).willReturn(Optional.of(person));
final ResultActions result = mockMvc.perform(get(BASE_PATH + "/" + ID));
result.andExpect(status().isOk());
verifyJson(result);
}
private void verifyJson(final ResultActions action) throws Exception {
action
.andExpect(jsonPath("person.id", is(person.getId().intValue())))
.andExpect(jsonPath("person.firstName", is(person.getFirstName())))
.andExpect(jsonPath("person.secondName", is(person.getSecondName())))
.andExpect(jsonPath("person.dateOfBirth", is(person.getDateOfBirth().format(formatter))))
.andExpect(jsonPath("person.profession", is(person.getProfession())))
.andExpect(jsonPath("person.salary", is(person.getSalary())))
.andExpect(jsonPath("links[0].rel", is("people")))
.andExpect(jsonPath("links[0].href", is(BASE_PATH)))
.andExpect(jsonPath("links[1].rel", is("memberships")))
.andExpect(jsonPath("links[1].href", is(BASE_PATH + "/" + ID + MEMBERSHIPS_PATH)))
.andExpect(jsonPath("links[2].rel", is("self")))
.andExpect(jsonPath("links[2].href", is(BASE_PATH + "/" + ID)));
}
@Test
public void allReturnsCorrectResponse() throws Exception {
given(personRepository.findAll()).willReturn(Arrays.asList(person));
final ResultActions result = mockMvc.perform(get(BASE_PATH));
result.andExpect(status().isOk());
result
.andExpect(jsonPath("links[0].rel", is("self")))
.andExpect(jsonPath("links[0].href", is(BASE_PATH)))
.andExpect(jsonPath("content[0].person.id", is(person.getId().intValue())))
.andExpect(jsonPath("content[0].person.id", is(person.getId().intValue())))
.andExpect(jsonPath("content[0].person.firstName", is(person.getFirstName())))
.andExpect(jsonPath("content[0].person.secondName", is(person.getSecondName())))
.andExpect(
jsonPath(
"content[0].person.dateOfBirth", is(person.getDateOfBirth().format(formatter))))
.andExpect(jsonPath("content[0].person.profession", is(person.getProfession())))
.andExpect(jsonPath("content[0].person.salary", is(person.getSalary())))
.andExpect(jsonPath("content[0].links[0].rel", is("people")))
.andExpect(jsonPath("content[0].links[0].href", is(BASE_PATH)))
.andExpect(jsonPath("content[0].links[1].rel", is("memberships")))
.andExpect(
jsonPath("content[0].links[1].href", is(BASE_PATH + "/" + ID + MEMBERSHIPS_PATH)))
.andExpect(jsonPath("content[0].links[2].rel", is("self")))
.andExpect(jsonPath("content[0].links[2].href", is(BASE_PATH + "/" + ID)));
}
@Test
public void postReturnsCorrectResponse() throws Exception {
given(personRepository.save(any(Person.class))).willReturn(person);
final ResultActions result =
mockMvc.perform(
post(BASE_PATH)
.content(mapper.writeValueAsBytes(person))
.contentType(MediaType.APPLICATION_JSON_UTF8));
result.andExpect(status().isCreated());
verifyJson(result);
}
@Test
public void putReturnsCorrectResponse() throws Exception {
given(personRepository.save(any(Person.class))).willReturn(person);
final ResultActions result =
mockMvc.perform(
put(BASE_PATH + "/" + ID)
.content(mapper.writeValueAsBytes(person))
.contentType(MediaType.APPLICATION_JSON_UTF8));
result.andExpect(status().isCreated());
verifyJson(result);
}
@Test
public void deleteReturnsCorrectResponse() throws Exception {
given(personRepository.findById(ID)).willReturn(Optional.of(person));
mockMvc
.perform(delete(BASE_PATH + "/" + ID))
.andExpect(status().isNoContent())
.andExpect(content().string(""));
}
// Equivalent tests for PUT and DELETE could be made to test handling of same error
@Test
public void getPersonThatDoesNotExistReturnsError() throws Exception {
final PersonNotFoundException exception = new PersonNotFoundException(ID);
given(personRepository.findById(ID)).willReturn(Optional.empty());
final ResultActions result = mockMvc.perform(get(BASE_PATH + "/" + ID));
result.andExpect(status().isNotFound());
result
.andExpect(jsonPath("$[0].logref", is(String.valueOf(ID))))
.andExpect(jsonPath("$[0].message", is(exception.getMessage())))
.andExpect(jsonPath("$[0].links", is(new ArrayList<String>())));
}
}
There are a few comments scattered throughout the test above, but lets get have a proper look into it. Firstly we have some of the general setup. The SpringRunner
is used and @WebMvcTest
marks the controller that is being tested. @MockBean
allows the PersonRepository
to be mocked out as is not important to this specific test and ObjectMapper
allows you to convert objects into JSON so that they can be passed to the controller as if you were manually testing it yourself. MockMvc
is used to send requests to the controller being tested (the controller is spun up at the start of the test) and the response of the call can be asserted to assure that the code is running correctly.
There are a lot of static methods in this example which have been statically imported to make the code look tidier, unfortunately this can make it a little unclear where they are coming from, another version of the code with imports can be found here.
Below I have taken a snippet of the code in the test to focus on.
@Test
public void getReturnsCorrectResponse() throws Exception {
given(personRepository.findById(ID)).willReturn(Optional.of(person));
final ResultActions result = mockMvc.perform(get(BASE_PATH + "/" + ID));
result.andExpect(status().isOk());
verifyJson(result);
}
private void verifyJson(final ResultActions action) throws Exception {
action
.andExpect(jsonPath("person.id", is(person.getId().intValue())))
.andExpect(jsonPath("person.firstName", is(person.getFirstName())))
.andExpect(jsonPath("person.secondName", is(person.getSecondName())))
.andExpect(jsonPath("person.dateOfBirth", is(person.getDateOfBirth().format(formatter))))
.andExpect(jsonPath("person.profession", is(person.getProfession())))
.andExpect(jsonPath("person.salary", is(person.getSalary())))
.andExpect(jsonPath("links[0].rel", is("people")))
.andExpect(jsonPath("links[0].href", is(BASE_PATH)))
.andExpect(jsonPath("links[1].rel", is("memberships")))
.andExpect(jsonPath("links[1].href", is(BASE_PATH + "/" + ID + MEMBERSHIPS_PATH)))
.andExpect(jsonPath("links[2].rel", is("self")))
.andExpect(jsonPath("links[2].href", is(BASE_PATH + "/" + ID)));
}
Here we have a test which is checking if the JSON returned from a GET request is correct. The first line of the test mocks out the findById
repository method and returns the person that was created in the setup method. The following line sends the request. get
specifies the type of request and the string or URI passed into it tells it where to send the call to. Due to a lot of reused code I decided to split a large chunk of the assertion code out into a separate method. As I decided to split this code out the response has been stored in the ResultActions
object where it is then checked that it's response code was 200 OK
and then verifyJson
goes on to ensure the JSON is correct.
The jsonPath
method is very handy, allowing you to check the JSON one property at a time. the is
method comes from the hamcrest-core
dependency and when used in conjunction with jsonPath
makes for a very tidy way of checking the correctness of the returned JSON. If you decided to not to do it this way, you might need to store a long string to represent the JSON and basically compare two strings together, I can personally tell you that doing it this way is a tad annoying. Below is the JSON returned when the test is ran and we can use this to provide a very brief explanation into how jsonPath
works.
{
"person":{
"id":1,
"firstName":"first",
"secondName":"second",
"dateOfBirth":"16/09/2017 18:01",
"profession":"developer",
"salary":0
},
"links":[
{
"rel":"people",
"href":"http://localhost/people"
},
{
"rel":"memberships",
"href":"http://localhost/people/1/memberships"
},
{
"rel":"self",
"href":"http://localhost/people/1"
}
]
}
Separate a line for closer inspection.
.andExpect(jsonPath("person.firstName", is(person.getFirstName())))
So this is saying does the firstName
property of the Person
object (named "person") have the value of person.getFirstName
, if so then this line passes.
And a slightly different example.
.andExpect(jsonPath("links[0].rel", is("people")))
.andExpect(jsonPath("links[0].href", is(BASE_PATH)))
Due to links
being an array you need to retrieve values from it as such. Therefore this snippet says; get the first object in the links
array, get the property rel
and href
and check their values. These links are what are included due to it controller being a HATEOAS service. The code above is repeated for the remaining links. If you have noticed the URI's included in the response include "localhost" etc and therefore the string it is being compared to needs to also needs to contain it. The reason I mention this is because the URI passed into the get
method (or any of the other verbs) do not need to receive a whole URI and therefore can take in "/people/1" instead of "http://localhost/people/1". But if you tried to do the same inside of the jsonPath
methods they would fail instead. Just something I think is worth looking out for as it might trip you up if you are not careful.
Next we will look at another test snippet which focuses on response returned when an exception is thrown. Due to the exception being handled in PersonControllerAdvice
some extra configuration needs to be applied (which caused some weird things to happen which I'll get into later).
PersonControllerAdvice
@ControllerAdvice
@RequestMapping(produces = "application/vnd.error+json")
public class PersonControllerAdvice extends ResponseEntityExceptionHandler {
@ExceptionHandler(PersonNotFoundException.class)
public ResponseEntity<VndErrors> notFoundException(final PersonNotFoundException e) {
return error(e, HttpStatus.NOT_FOUND, e.getId().toString());
}
private ResponseEntity<VndErrors> error(
final Exception exception, final HttpStatus httpStatus, final String logRef) {
final String message =
Optional.of(exception.getMessage()).orElse(exception.getClass().getSimpleName());
return new ResponseEntity<>(new VndErrors(logRef, message), httpStatus);
}
}
The test case
@Test
public void getPersonThatDoesNotExistReturnsError() throws Exception {
final PersonNotFoundException exception = new PersonNotFoundException(ID);
given(personRepository.findById(ID)).willReturn(Optional.empty());
final ResultActions result = mockMvc.perform(get(BASE_PATH + "/" + ID));
result.andExpect(status().isNotFound());
result
.andExpect(jsonPath("$[0].logref", is(String.valueOf(ID))))
.andExpect(jsonPath("$[0].message", is(exception.getMessage())))
.andExpect(jsonPath("$[0].links", is(new ArrayList<String>())));
}
This time round the mocked repository method will return Optional.empty
which in turn causes a exception to be thrown inside the controller's GET method. If everything is setup correctly, this exception is handled by the ExceptionHandler
inside of PersonControllerAdvice
, converted into a ResponseEntity<VndErrors>
and returned. Below is the JSON included in the response.
[
{
"logref": "1",
"message": "Person could not be found with id: 1",
"links": []
}
]
It is then checked in the same way all the other tests are. The $[0]
represents first value of the unnamed array.
Oh, I should probably go over that configuration I mentioned a minute ago as well. The mockMvc
object meeds to be setup to allow classes annotated with @ControllerAdvice
to be used (in this case PersonControllerAdvice
). If you do not require this extra configuration (or the others available) then just injecting in MockMvc
with @Autowired
will suffice. setControllerAdvice
can take in an array of objects allowing you to enable multiple @ControllerAdvice
classes to use with your test.
mockMvc =
MockMvcBuilders.standaloneSetup(new PersonController(personRepository))
.setControllerAdvice(new PersonControllerAdvice())
.build();
I also mentioned that some weird things happened when I added this configuration. Depending on whether I setup mockMvc
manually using the snippet above or injected it using @Autowired
the JSON returned in the response would differ slightly. This lead to the JSON returned in the test being different from the output of manual testing, which is not the best outcome for a test. Below is the JSON with the manual configured mockMvc
followed by the injected version.
With configuration
{
"person":{
"id":1,
"firstName":"first",
"secondName":"second",
"dateOfBirth":"16/09/2017 18:01",
"profession":"developer",
"salary":0
},
"links":[
{
"rel":"people",
"href":"http://localhost/people"
},
{
"rel":"memberships",
"href":"http://localhost/people/1/memberships"
},
{
"rel":"self",
"href":"http://localhost/people/1"
}
]
}
By injection
{
"person": {
"id": 1,
"firstName": "first",
"secondName": "second",
"dateOfBirth":"16/09/2017 18:01",
"profession": "developer",
"salary": 0
},
"_links": {
"people": {
"href": "http://localhost/people"
},
"memberships": {
"href": "http://localhost/people/1/memberships"
},
"self": {
"href": "http://localhost/people/1"
}
}
}
As you can see, the difference lies in the JSON representing the links. Where in one version it is an array called links
and in the other it is an object called _links
with properties representing each link.
The correct code to test the JSON from the second snippet is as follows. Note the difference in the path passed into jsonPath
due to it being an object instead of an array.
private void verifyJson(final ResultActions action) throws Exception {
action
.andExpect(jsonPath("person.id", is(person.getId().intValue())))
.andExpect(jsonPath("person.firstName", is(person.getFirstName())))
.andExpect(jsonPath("person.secondName", is(person.getSecondName())))
.andExpect(jsonPath("person.dateOfBirth", is(person.getDateOfBirth().format(formatter))))
.andExpect(jsonPath("person.profession", is(person.getProfession())))
.andExpect(jsonPath("person.salary", is(person.getSalary())))
.andExpect(jsonPath("_links.people.href", is(BASE_PATH)))
.andExpect(jsonPath("_links.memberships.href", is(BASE_PATH + "/" + ID + MEMBERSHIPS_PATH)))
.andExpect(jsonPath("_links.self.href", is(BASE_PATH + "/" + ID)));
}
Personally I have not tried to look into the code that could cause this and therefore do not know whether it is a bug/oversight, or if I did something wrong myself... Either way it is worth knowing that this could happen.
Unfortunately this left my test output looking different to my production code, a possible solution for my scenario is splitting the test into separate classes where one uses an injected mockMvc
and the other tests the exception handling with the configured mockMvc
. This should allow the JSON to match the output of the production code in more situations.
In conclusion, writing tests for a HATEAOS service is not very different from testing a REST API and just requires a few more asserts to ensure that the links are returned correctly. We also had a brief look at using jsonPath
to help make verifying the output JSON easier and how we can configure the test to allow for error handling to be checked when a @ControllerAdvice
class is used.
The code used in this post can be found on my GitHub.
If this post was helpful, similar and different posts about Java can be found on my personal blog at www.lankydanblog.com