Monday, 2 November, 2020
Improving Spring Mock-MVC tests
Spring Mock-MVC can be a great way to test Spring Boot REST APIs. Mock-MVC allows us to test Spring-MVC request handling without running a real server.
I used Mock-MVC tests in various projects and in my experience they often become quite verbose. This doesn't have to be bad. However, it often results in copy/pasting code snippets around in test classes. In this post we will look at a couple of ways to clean up Spring Mock-MVC tests.
Decide what to test with Mock-MVC
The first question we need to ask is what we want to test with Mock-MVC. Some example test scenarios are:
- Testing only the web layer and mocking all controller dependencies.
- Testing the web layer with domain logic and mocked third party dependencies like Databases or message queues.
- Testing the complete path from web to database by replacing third party dependencies with embedded alternatives if possible (e.g. H2 or embedded-Kafka)
All these scenarios have their own up- and downsides. However, I think there are two simple rules we should follow:
- Test as much in standard JUnit tests (without Spring) as possible. This improves test performance a lot and makes tests often easier to write.
- Pick the scenario(s) you want to test with Spring and be consistent in the dependencies you mock. This makes tests easier to understand and can speed them up as well. When running many different test configurations, Spring often has to re-initialize the application context which slows tests down.
When using standard JUnit tests as much as possible the last scenario mentioned above is often a good fit. After we tested all logic with fast unit tests, we can use a few Mock-MVC tests to verify that all pieces work together, from controller to database.
Cleaning up test configuration using custom annotations
Spring allows us to compose multiple Spring annotations to a single custom annotation.
For example, we can create a custom @MockMvcTest annotation:
@SpringBootTest @TestPropertySource(locations = "classpath:test.properties") @AutoConfigureMockMvc(secure = false) @Retention(RetentionPolicy.RUNTIME) public @interface MockMvcTest {}
Our test now only needs a single annotation:
@MockMvcTest public class MyTest { ... }
This way we can clean up tests from various annotations. This is also useful to standardize Spring configuration for our test scenarios.
Improving Mock-MVC requests
Let's look at the following example Mock-MVC request and see how we can improve it:
mockMvc.perform(put("/products/42") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .content("{\"name\": \"Cool Gadget\", \"description\": \"Looks cool\"}") .header("Authorization", getBasicAuthHeader("John", "secr3t"))) .andExpect(status().isOk());
This sends a PUT request with some JSON data and an Authorization header to /products/42.
The first thing that catches someone's eye is the JSON snippet within a Java string. This is obviously a problem as the double quote escaping required by Java strings makes it barely readable.
Typically we should use an object that is then converted to JSON. Before we look into this approach, it is worth to mention Text blocks. Java Text blocks have been introduced in JDK 13 / 14 as preview feature. Text blocks are strings that span over multiple lines and require no double quote escaping.
With text block we can format inline JSON in a prettier way. For example:
mvc.perform(put("/products/42") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .content(""" { "name": "Cool Gadget", "description": "Looks cool" } """) .header("Authorization", getBasicAuthHeader("John", "secr3t"))) .andExpect(status().isOk());
In certain situations this can be useful.
However, we should still prefer objects that are converted to JSON instead of manually writing and maintaining JSON strings.
For example:
Product product = new Product("Cool Gadget", "Looks cool"); mvc.perform(put("/products/42") .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .content(objectToJson(product)) .header("Authorization", getBasicAuthHeader("John", "secr3t"))) .andExpect(status().isOk());
Here we create a product object and convert it to JSON with a small objectToJson(..) helper method. This helps a bit. Nevertheless, we can do better.
Our request contains a lot of elements that can be grouped together. When building a JSON REST-API it is likely that we often have to send similar PUT request. Therefore, we create a small static shortcut method:
public static MockHttpServletRequestBuilder putJson(String uri, Object body) { try { String json = new ObjectMapper().writeValueAsString(body); return put(uri) .contentType(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON) .content(json); } catch (JsonProcessingException e) { throw new RuntimeException(e); } }
This method converts the body parameter to JSON using a Jackson ObjectMapper. It then creates a PUT request and sets Accept and Content-Type headers.
This reusable method simplifies our test request a lot:
Product product = new Product("Cool Gadget", "Looks cool"); mvc.perform(putJson("/products/42", product) .header("Authorization", getBasicAuthHeader("John", "secr3t"))) .andExpect(status().isOk())
The nice thing here is that we do not lose flexibility. Our putJson(..) method returns a MockHttpServletRequestBuilder. This allows us to add additional request properties within tests if required (like the Authorization header in this example).
Authentication headers are another topic we often have to deal with when writing Spring Mock-MVC tests. However, we should not add authentication headers to our previous putJson(..) method. Even if all PUT requests require authentication we stay more flexible if we deal with authentication in a different way.
RequestPostProcessors can help us with this. As the name suggests, RequestPostProcessors can be used to process the request. We can use this to add custom headers or other information to the request.
For example:
public static RequestPostProcessor authentication() { return request -> { request.addHeader("Authorization", getBasicAuthHeader("John", "secr3t")); return request; }; }
The authentication() method returns a RequestPostProcessor which adds Basic-Authentication to the request. We can apply this RequestPostProcessor in our test using the with(..) method:
Product product = new Product("Cool Gadget", "Looks cool"); mvc.perform(putJson("/products/42", product).with(authentication())) .andExpect(status().isOk())
This does not only simplify our test request. If we change the request header format we now only need to modify a single method to fix the tests. Additionally putJson(url, data).with(authentication()) is also quite expressive to read.
Improving response verification
Now let's see how we can improve response verification.
We start with the following example:
mvc.perform(get("/products/42")) .andExpect(status().isOk()) .andExpect(header().string("Cache-Control", "no-cache")) .andExpect(jsonPath("$.name").value("Cool Gadget")) .andExpect(jsonPath("$.description").value("Looks cool"));
Here we check the HTTP status code, make sure the Cache-Control header is set to no-cache and use JSON-Path expressions to verify the response payload.
The Cache-Control header looks like something we probably need to check for multiple responses. In this case, it can be a good idea to come up with a small shortcut method:
public ResultMatcher noCacheHeader() { return header().string("Cache-Control", "no-cache"); }
We can now apply the check by passing noCacheHeader() to andExpect(..):
mvc.perform(get("/products/42")) .andExpect(status().isOk()) .andExpect(noCacheHeader()) .andExpect(jsonPath("$.name").value("Cool Gadget")) .andExpect(jsonPath("$.description").value("Looks cool"));
The same approach can be used to verify the response body.
For example we can create a small product(..) method that compares the response JSON with a given Product object:
public static ResultMatcher product(String prefix, Product product) { return ResultMatcher.matchAll( jsonPath(prefix + ".name").value(product.getName()), jsonPath(prefix + ".description").value(product.getDescription()) ); }
Our test now looks like this:
Product product = new Product("Cool Gadget", "Looks cool"); mvc.perform(get("/products/42")) .andExpect(status().isOk()) .andExpect(noCacheHeader()) .andExpect(product("$", product));
Note that the prefix parameter gives us flexibility. The object we want to check might not always be located at the JSON root level of the response.
Assume a request might return a collection of products. We can then use the prefix parameter to select each product in the collection. For example:
Product product0 = .. Product product1 = .. mvc.perform(get("/products")) .andExpect(status().isOk()) .andExpect(product("$[0]", product0)) .andExpect(product("$[1]", product1));
With ResultMatcher methods you avoid scattering the exact response data structure over many tests. This again supports refactorings.
Summary
We looked into a few ways to reduce verbosity in Spring Mock-MVC tests. Before we even start writing Mock-MVC tests we should decide what we want to test and what parts of the application should be replaced with mocks. Often it is a good idea to test as much as possible with standard unit tests (without Spring and Mock-MVC).
We can use custom test annotations to standardize our Spring Mock-MVC test setup. With small shortcut methods and RequestPostProcessors we can move reusable request code out of test methods. Custom ResultMatchers can be used to improve response checks.
You can find the example code on GitHub.