How to Write MockMvc Tests Without ObjectMapper, Part Two - Using a Template Engine

A few weeks ago I found a blog post which argues that we shouldn't build the request body with an ObjectMapper when we are writing integration tests with MockMvc (aka Spring MVC Test framework). Because I think that the original blog post is quite interesting, I decided to write a blog post that describes how we can build the request body that's send to the system under test if we cannot hard-code the request body and we must use Java.

After we have finished this blog post, we:

  • Can create a request builder that generates request bodies with Thymeleaf template engine.
  • Know how we can use our new request builder.

Let's start by identifying the requirements of our request builder.

The Requirements of Our Request Builder

The requirements of our request builder are:

  • It must not use hard-coded request bodies.
  • We must be able to specify the property values of the JSON document that's send to the system under test.
  • We must have full control over the structure of the JSON document that's send to the system under test. In other words, we must be able to add unknown properties to it or remove properties from it.

Next, we will take a closer look at the system under test.

Introduction to the System Under Test

During this blog post, we will rewrite the tests which we wrote when we learned how we can write unit tests for a REST API endpoint that inserts data into the database. This API endpoint processes POST requests send to the path: '/todo-item', and its implementation inserts new todo items into the database. The information of the created todo item must be added to the request body that could look as follows:

{
	"description": "Describe how we can replace ObjectMapper."
	"title": "Write a new blog post"
}

Before the system under test inserts a new todo item into the database, it ensures that the created todo item fulfills these validation rules:

  • Every todo item must have a title.
  • The title of a todo item must contain at least one non-whitespace character.
  • The maximum length of the title is 100 characters.
  • The maximum length of the description is 100 characters.

Also, the system under test must ignore all unknown properties found from the JSON document which contains the information of the created todo item.

The code samples of this blog post are based on the code which we wrote in the blog post: Writing Unit Tests for a Spring MVC REST API: Writing Data.

Let's move on and make the required changes to our request builder class.

Making the Required Changes to Our Request Builder Class

Now we have to remove the ObjectMapper dependency from our request builder class (TodoItemRequestBuilder) and replace it with the Thymeleaf template engine. We can make the required changes by following these steps:

First, add a TemplateEngine field to the TodoItemRequestBuilder class, and ensure that the constructor creates a new TemplateEngine object and sets the value of the created field. We will use the TemplateEngine object when we process a Thymeleaf template and build the request body that's send to the system under test.

After we have added this field to the TodoItemRequestBuilder class, its source code looks as follows:

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.thymeleaf.TemplateEngine;

import java.io.IOException;

import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.objectMapper;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

class TodoItemRequestBuilder {

    private final MockMvc mockMvc;
    private final TemplateEngine templateEngine;

    TodoItemRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
        this.templateEngine = new TemplateEngine();
    }

    ResultActions create(CreateTodoItemDTO input) throws Exception {
        return mockMvc.perform(post("/todo-item")
                .contentType(MediaType.APPLICATION_JSON)
                .content(convertObjectToJsonBytes(input))
        );
    }

    private static byte[] convertObjectToJsonBytes(Object object) throws IOException {
        ObjectMapper mapper = objectMapper();
        return mapper.writeValueAsBytes(object);
    }
}

Second, add a RequestBodyTemplate enum to the TodoItemRequestBuilder class. This enum specifies the Thymeleaf templates which we need when we want to write tests for the system under test. When we take a look at the requirements of the system under test, we notice that we need three Thymeleaf templates:

  • A template which is used to build an empty JSON document.
  • A template which can be used when we want to build a JSON document which has only the known properties (description and title).
  • A template which can be used for building a JSON document which has all known properties (description and title) and one unknown property.

After we have added the RequestBodyTemplate enum to the TodoItemRequestBuilder class, its source code looks as follows:

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.thymeleaf.TemplateEngine;

import java.io.IOException;

import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.objectMapper;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

class TodoItemRequestBuilder {

    private final MockMvc mockMvc;
    private final TemplateEngine templateEngine;

    TodoItemRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
        this.templateEngine = new TemplateEngine();
    }

    ResultActions create(CreateTodoItemDTO input) throws Exception {
        return mockMvc.perform(post("/todo-item")
                .contentType(MediaType.APPLICATION_JSON)
                .content(convertObjectToJsonBytes(input))
        );
    }

    private static byte[] convertObjectToJsonBytes(Object object) throws IOException {
        ObjectMapper mapper = objectMapper();
        return mapper.writeValueAsBytes(object);
    }

    enum RequestBodyTemplate {

        EMPTY_TODO_ITEM("{}"),
        TODO_ITEM(
                """
               {
                    "description":"[[${description}]]",
                    "title":"[[${title}]]"
               }
                """
        ),
        TODO_ITEM_WITH_UNKNOWN_PROPERTY(
                """
                {
                    "description":"[[${description}]]",
                    "title":"[[${title}]]",
                    "unknown":"[[${unknown}]]"
               }
                """
        );


        private final String template;

        private RequestBodyTemplate(String template) {
            this.template = template;
        }
        
        String getTemplate() {
            return template;
        }
    }
}
We should use an enum instead of just passing a string to our request builder method because:

  • If we use an enum, we ensure that our request builder is responsible of building the request body by using the information provided by the user of our request builder. If our request builder method would take the template as a String object, the user of our request builder would be responsible of building a part of the request (the request body).
  • If we keep our request body templates in one place, they are easier to maintain. If we have to make changes to these templates, we don't have to search our templates from different test classes. We can simply open our request builder class and make the required changes to the templates. However, we might still have to make changes to the code which configures the values of the required template variables. This code is found from our test classes.

Third, add three constants to the TodoItemRequestBuilder class. These constants specify the names of the variables which are used in our Thymeleaf templates. After we have added these constants to the TodoItemRequestBuilder class, its source code looks as follows:

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.thymeleaf.TemplateEngine;

import java.io.IOException;

import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.objectMapper;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

class TodoItemRequestBuilder {

    static final String TEMPLATE_VARIABLE_DESCRIPTION = "description";
    static final String TEMPLATE_VARIABLE_TITLE = "title";
    static final String TEMPLATE_VARIABLE_UNKNOWN  = "unknown";

    private final MockMvc mockMvc;
    private final TemplateEngine templateEngine;

    TodoItemRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
        this.templateEngine = new TemplateEngine();
    }

    ResultActions create(CreateTodoItemDTO input) throws Exception {
        return mockMvc.perform(post("/todo-item")
                .contentType(MediaType.APPLICATION_JSON)
                .content(convertObjectToJsonBytes(input))
        );
    }

    private static byte[] convertObjectToJsonBytes(Object object) throws IOException {
        ObjectMapper mapper = objectMapper();
        return mapper.writeValueAsBytes(object);
    }

    enum RequestBodyTemplate {

        EMPTY_TODO_ITEM("{}"),
        TODO_ITEM(
                """
               {
                    "description":"[[${description}]]",
                    "title":"[[${title}]]"
               }
                """
        ),
        TODO_ITEM_WITH_UNKNOWN_PROPERTY(
                """
                {
                    "description":"[[${description}]]",
                    "title":"[[${title}]]",
                    "unknown":"[[${unknown}]]"
               }
                """
        );


        private final String template;

        private RequestBodyTemplate(String template) {
            this.template = template;
        }

        String getTemplate() {
            return template;
        }
    }
}

Fourth, add a private buildRequestBody() method to the TodoItemRequestBuilder class. This method takes two method parameters:

  1. A value of the RequestBodyTemplate enum. This value identifies the template that specifies the structure of the request body that's build by this method.
  2. A map that contains the values of the template variables which are used to build the actual request body.

After we have added this method to our request builder class, we have to implement it by following these steps:

  1. Create a context that contains the values of the template variables.
  2. Put the values of the template variables to the created context.
  3. Build the request body with Thymeleaf and return the created request body.

After we have implemented the private buildRequestBody() method, the source code of our request builder class looks as follows:

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

import java.io.IOException;
import java.util.Map;

import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.objectMapper;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

class TodoItemRequestBuilder {

    static final String TEMPLATE_VARIABLE_DESCRIPTION = "description";
    static final String TEMPLATE_VARIABLE_TITLE = "title";
    static final String TEMPLATE_VARIABLE_UNKNOWN  = "unknown";

    private final MockMvc mockMvc;
    private final TemplateEngine templateEngine;

    TodoItemRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
        this.templateEngine = new TemplateEngine();
    }

    private String buildRequestBody(RequestBodyTemplate template,
                                    Map<String, Object> variables) {
        var context = new Context();
        var variableKeys = variables.keySet();

        for (String key: variableKeys) {
            context.setVariable(key, variables.get(key));
        }

        return this.templateEngine.process(template.getTemplate(), context);
    }

    ResultActions create(CreateTodoItemDTO input) throws Exception {
        return mockMvc.perform(post("/todo-item")
                .contentType(MediaType.APPLICATION_JSON)
                .content(convertObjectToJsonBytes(input))
        );
    }

    private static byte[] convertObjectToJsonBytes(Object object) throws IOException {
        ObjectMapper mapper = objectMapper();
        return mapper.writeValueAsBytes(object);
    }

    enum RequestBodyTemplate {

        EMPTY_TODO_ITEM("{}"),
        TODO_ITEM(
                """
               {
                    "description":"[[${description}]]",
                    "title":"[[${title}]]"
               }
                """
        ),
        TODO_ITEM_WITH_UNKNOWN_PROPERTY(
                """
                {
                    "description":"[[${description}]]",
                    "title":"[[${title}]]",
                    "unknown":"[[${unknown}]]"
               }
                """
        );


        private final String template;

        private RequestBodyTemplate(String template) {
            this.template = template;
        }

        String getTemplate() {
            return template;
        }
    }
}

Fifth, add a new create() method to the TodoItemRequestBuilder class. This method takes two method parameters:

  1. A value of the RequestBodyTemplate enum. This value identifies the template that specifies the structure of the request body that's send to the system under test.
  2. A map that contains the values of the template variables which are used to build the actual request body.

After we have added this method to our request builder class, we have to implement it by following these steps:

  1. Build the request body that's send to the system under test.
  2. Build the request that's send to the system under test.
  3. Send the request to the system under test and return the returned ResultActions object.

After we have implemented the create() method, the source code of the TodoItemRequestBuilder class looks as follows:

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

import java.io.IOException;
import java.util.Map;

import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.objectMapper;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

class TodoItemRequestBuilder {

    static final String TEMPLATE_VARIABLE_DESCRIPTION = "description";
    static final String TEMPLATE_VARIABLE_TITLE = "title";
    static final String TEMPLATE_VARIABLE_UNKNOWN  = "unknown";

    private final MockMvc mockMvc;
    private final TemplateEngine templateEngine;

    TodoItemRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
        this.templateEngine = new TemplateEngine();
    }

    ResultActions create(RequestBodyTemplate requestBodyTemplate, 
                         Map<String, Object> templateVariables) throws Exception {
        var requestBody = buildRequestBody(requestBodyTemplate, templateVariables);
        return mockMvc.perform(post("/todo-item")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody)
        );

    }

    private String buildRequestBody(RequestBodyTemplate template,
                                    Map<String, Object> variables) {
        var context = new Context();
        var variableKeys = variables.keySet();

        for (String key: variableKeys) {
            context.setVariable(key, variables.get(key));
        }

        return this.templateEngine.process(template.getTemplate(), context);
    }

    ResultActions create(CreateTodoItemDTO input) throws Exception {
        return mockMvc.perform(post("/todo-item")
                .contentType(MediaType.APPLICATION_JSON)
                .content(convertObjectToJsonBytes(input))
        );
    }

    private static byte[] convertObjectToJsonBytes(Object object) throws IOException {
        ObjectMapper mapper = objectMapper();
        return mapper.writeValueAsBytes(object);
    }

    enum RequestBodyTemplate {

        EMPTY_TODO_ITEM("{}"),
        TODO_ITEM(
                """
               {
                    "description":"[[${description}]]",
                    "title":"[[${title}]]"
               }
                """
        ),
        TODO_ITEM_WITH_UNKNOWN_PROPERTY(
                """
                {
                    "description":"[[${description}]]",
                    "title":"[[${title}]]",
                    "unknown":"[[${unknown}]]"
               }
                """
        );


        private final String template;

        private RequestBodyTemplate(String template) {
            this.template = template;
        }

        String getTemplate() {
            return template;
        }
    }
}

Sixth, remove the old create() method from the TodoItemRequestBuilder class. After we have removed this method from our request builder class, the source code of the TodoItemRequestBuilder class loos as follows:

import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;

import java.util.Map;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

class TodoItemRequestBuilder {

    static final String TEMPLATE_VARIABLE_DESCRIPTION = "description";
    static final String TEMPLATE_VARIABLE_TITLE = "title";
    static final String TEMPLATE_VARIABLE_UNKNOWN  = "unknown";

    private final MockMvc mockMvc;
    private final TemplateEngine templateEngine;

    TodoItemRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
        this.templateEngine = new TemplateEngine();
    }

    ResultActions create(RequestBodyTemplate requestBodyTemplate,
                         Map<String, Object> templateVariables) throws Exception {
        var requestBody = buildRequestBody(requestBodyTemplate, templateVariables);
        return mockMvc.perform(post("/todo-item")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody)
        );

    }

    private String buildRequestBody(RequestBodyTemplate template,
                                    Map<String, Object> variables) {
        var context = new Context();
        var variableKeys = variables.keySet();

        for (String key: variableKeys) {
            context.setVariable(key, variables.get(key));
        }

        return this.templateEngine.process(template.getTemplate(), context);
    }

    enum RequestBodyTemplate {

        EMPTY_TODO_ITEM("{}"),
        TODO_ITEM(
                """
               {
                    "description":"[[${description}]]",
                    "title":"[[${title}]]"
               }
                """
        ),
        TODO_ITEM_WITH_UNKNOWN_PROPERTY(
                """
                {
                    "description":"[[${description}]]",
                    "title":"[[${title}]]",
                    "unknown":"[[${unknown}]]"
               }
                """
        );


        private final String template;

        private RequestBodyTemplate(String template) {
            this.template = template;
        }

        String getTemplate() {
            return template;
        }
    }
}

We have now made the required changes to our request builder class. Next, we will find out how we can use our new request builder method.

Using Our New Request Builder Method

Let's take a look at three examples which demonstrate how we can use our new request builder class when we are writing tests for the system under test.

First, if we want to ensure that the system under test is working as expected when the request body contains an empty JSON document, we have to pass the following arguments to the create() method of the TodoItemRequestBuilder class:

  1. RequestBodyTemplate.EMPTY_TODO_ITEM ensures that the request body contains an empty JSON document.
  2. An empty Map. We don't have to add any key-value pairs to the created Map because our Thymeleaf template doesn't have any variables.

The old code that creates an "HTTP request" by using an ObjectMapper and sends it to the system under test looks as follows:

var requestBuilder = new TodoItemRequestBuilder(mockMvc); 
var input = new CreateTodoItemDTO();
requestBuilder.create(input);

The new code which uses Thymeleaf template engine looks as follows:

var requestBuilder = new TodoItemRequestBuilder(mockMvc); 
var templateVariables = Map.of()
requestBuilder.create(RequestBodyTemplate.EMPTY_TODO_ITEM, templateVariables);

Second, if we want to ensure that the system under test is working as expected when the request body contains a JSON document that has only the known properties (description and title), we have to pass the following arguments to the create() method of the TodoItemRequestBuilder class:

  1. RequestBodyTemplate.TODO_ITEM ensures that the request body contains a JSON document that has only the known properties.
  2. A Map that contains two key-value pairs. Because Our Thymeleaf template has two variables (description and title), the created Map must contain the values of these variables.

The old code that creates an "HTTP request" by using an ObjectMapper and sends it to the system under test looks as follows:

var requestBuilder = new TodoItemRequestBuilder(mockMvc); 
var input = new CreateTodoItemDTO();
input.setDescription("Describe how we can replace ObjectMapper.");
input.setTitle("Write a new blog post");
requestBuilder.create(input);

The new code which uses Thymeleaf template engine looks as follows:

var requestBuilder = new TodoItemRequestBuilder(mockMvc); 
var templateVariables =  Map.of(
    "description", "Describe how we can replace ObjectMapper.",
    "title", "Write a new blog post"
);
requestBuilder.create(RequestBodyTemplate.TODO_ITEM, templateVariables);

Third, if we want to ensure that the system under test is working as expected when the request body contains a JSON document that has all known properties (description and title) and one unknown property, we have to pass the following arguments to the create() method of the TodoItemRequestBuilder class:

  1. RequestBodyTemplate.TODO_ITEM_WITH_UNKNOWN_PROPERTY ensures that the request body contains a JSON document that has all known properties and one unknown property.
  2. A Map that contains three key-value pairs. Because Our Thymeleaf template has three variables (description, title, and unknown), the created Map must contain the values of these variables.

Because we cannot write this test if our request builder uses an ObjectMapper, we will only take a look at the new code which creates an "HTTP request" and sends it to the system under test. It looks as follows:

var requestBuilder = new TodoItemRequestBuilder(mockMvc); 
var templateVariables =  Map.of(
    "description", "Describe how we can replace ObjectMapper.",
    "title", "Write a new blog post",
    "unknown", "unknown"
);
requestBuilder.create(RequestBodyTemplate.TODO_ITEM_WITH_UNKNOWN_PROPERTY, 
    templateVariables
);

We have now created a request builder class which uses Thymeleaf template engine (instead of ObjectMapper) and we know how we can use our new request builder method. Let's summarize what we learned from this blog post.

Summary

This blog post has taught us three things:

  • It's possible to replace ObjectMapper with a template engine.
  • If we build the request body with a template engine, we get full control over the structure of the JSON document that's send to the system under test.
  • The code that uses our new request builder method looks clean. In fact, I think that it's as clean as the old code (I guess this is a matter of opinion).

P.S. You can get the example application of this blog post from Github

0 comments… add one

Leave a Reply