Spring From the Trenches: Cleaning Up Our Test Code With HTTP Request Builders

The Spring MVC Test framework helps us to write clean unit and integration tests for our Spring MVC controllers. I a big fan of Spring MVC Test framework, and I like to think that I write clean tests.

However, a few months ago my colleague mentioned that my tests seem to have a lot of duplicate code. I was a bit annoyed by his remark (damn ego), but I had to admit that he was right.

This blog post describes how we solved our problem.

The Problem

The problem was that every test method had its own copy of the code that creates the HTTP request and sends it to tested controller method. Let's take a look at a few unit and integration tests that demonstrate this problem.

There are three things I find to point out:

  • I use the term "HTTP request" in this blog post because it makes things a bit easier to understand. However, you must know that the MockMvc class doesn't send real HTTP requests. All tests are run in a mock environment provided by Spring MVC Test framework.
  • These examples are taken from the example application of my Test With Spring course, and they test a REST API method that adds new tasks to the database. Also, I removed all imports and most test methods because they are not relevant for this blog post.
  • You can get the example application from Github.

First, the TaskCrudControllerTest class contains two unit tests for the create() method. Its source code looks as follows (the duplicate code is highlighted):

@RunWith(HierarchicalContextRunner.class)
@Category(UnitTest.class)
public class TaskCrudControllerTest {

    private TaskCrudService crudService;
    private MockMvc mockMvc;

    @Before
    public void configureSystemUnderTest() {
        crudService = mock(TaskCrudService.class);

        mockMvc = MockMvcBuilders.standaloneSetup(new TaskCrudController(crudService))
                .setControllerAdvice(new TaskErrorHandler())
                .setLocaleResolver(fixedLocaleResolver())
                .setMessageConverters(objectMapperHttpMessageConverter())
                .build();
    }

    public class Create {

        private TaskFormDTO input;

        public class WhenAllFieldsAreValid {

            @Before
            public void configureTestCases() {
                input = createValidInput();
                returnCreatedTask();
            }

            private TaskFormDTO createValidInput() {
                TaskFormDTO input = new TaskFormDTO();
                input.setTitle("title");
                input.setDescription("description");
                return input;
            }

            private void returnCreatedTask() {
                //This returns the created task. Omitted because of clarity.
            }

            @Test
            public void shouldReturnHttpStatusCodeCreated() throws Exception {
                mockMvc.perform(post("/api/task")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(WebTestUtil.convertObjectToJsonBytes(input))
                )
                        .andExpect(status().isCreated());
            }

            @Test
            public void shouldReturnCreatedTaskWithJson() throws Exception {
                mockMvc.perform(post("/api/task")
                        .contentType(MediaType.APPLICATION_JSON_UTF8)
                        .content(WebTestUtil.convertObjectToJsonBytes(input))
                )
                        .andExpect(
                                content().contentType(MediaType.APPLICATION_JSON_UTF8)
                        );
            }

        }
    }
}

Second, the CreateTaskAsUserWhenValidationIsSuccessful class contains two integration tests for the create() method. Its source code looks as follows (the duplicate code is highlighted):

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {IntegrationTestContext.class})
@WebAppConfiguration
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class,
        ServletTestExecutionListener.class,
        WithSecurityContextTestExecutionListener.class
})
@DatabaseSetup({
        "/com/testwithspring/intermediate/users.xml",
        "/com/testwithspring/intermediate/no-tasks-and-tags.xml"
})
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader.class)
@Category(IntegrationTest.class)
@ActiveProfiles(Profiles.INTEGRATION_TEST)
public class CreateTaskAsUserWhenValidationIsSuccessful {

    @Autowired
    IdColumnReset idColumnReset;

    @Autowired
    private WebApplicationContext webAppContext;

    private MockMvc mockMvc;

    private TaskFormDTO input;

    @Before
    public void configureSystemUnderTest() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
                .apply(springSecurity())
                .build();

        idColumnReset.resetIdColumns("tasks");

        input = createValidInput();
    }

    private TaskFormDTO createValidInput() {
        TaskFormDTO input = new TaskFormDTO();
        input.setDescription(Tasks.WriteExampleApp.DESCRIPTION);
        input.setTitle(Tasks.WriteExampleApp.TITLE);
        return input;
    }

    @Test
    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
    public void shouldReturnHttpStatusCodeCreated() throws Exception {
        mockMvc.perform(post("/api/task")
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(WebTestUtil.convertObjectToJsonBytes(input))
                .with(csrf())
        )
                .andExpect(status().isCreated());
    }

    @Test
    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
    public void shouldReturnCreatedTaskAsJson() throws Exception {
        mockMvc.perform(post("/api/task")
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(WebTestUtil.convertObjectToJsonBytes(input))
                .with(csrf())
        )
                .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8));
    }
}

As we can see, even though our tests are relatively simple, each test:

  • Configures the used HTTP request method and the target URL.
  • Sets the content type of the HTTP request.
  • Sets the request body of the HTTP request.

Also, our integration tests ensure that the HTTP request contains a valid CSRF token. In other words, our tests are quite easy to read, but the duplicate code causes two other problems:

  • Our tests are hard to write because writing the same code again and again is boring and takes "a lot of time". Maybe that's why so many people tend to write repetitive code by using copy and paste programming.
  • Our tests are hard to maintain because if we make changes to the tested controller method, we have to make the same changes to every test method that tests the changed controller method.

Let's find out how we can solve these problems.

HTTP Request Builders to the Rescue

If we want to eliminate duplicate code from our test suite, we have to create an HTTP request builder that creates HTTP requests and sends them to the tested controller methods. We can create our HTTP request builder by following these steps:

First, we have to create our HTTP request builder class by following these steps:

  1. Create a public and final class called: TaskHttpRequestBuilder.
  2. Add a MockMvc field to the created class.
  3. Write a constructor that gets a MockMvc object as a constructor argument and sets a reference to this object to MockMvc field.
When I name my HTTP request builder classes, I like to append the text: 'HttpRequestBuilder' to the name of the processed "entity". Also, if our HTTP request builder is used only test classes that are in same package as our HTTP request builder class, we should set its visibility to package protected.

After we have created our HTTP request builder class, its source code looks as follows:

import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

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

public final class TaskHttpRequestBuilder {

    private final MockMvc mockMvc;

    public TaskHttpRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
    }
}

Second, we have to write the method that creates an HTTP request and sends it to the tested controller method. This method is called createTask(), and it takes a TaskFormDTO object as a method parameter and returns a ResultActions object.

We can implement this method by following these steps:

  1. Send a POST request to the path: '/api/task'.
  2. Set the content-type of the HTTP request to: 'application/json;charset=UTF-8'.
  3. Transform the TaskFormDTO object into JSON bytes and add the created JSON bytes to the request body.
  4. Ensure that the HTTP request has a valid CSRF token. We need to do this because our application uses the CSRF protection provided by Spring Security, and we have to add a valid CSRF token to the HTTP request when we write integration tests for our controller method.

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

import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

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

public class TaskHttpRequestBuilder {

    private final MockMvc mockMvc;

    public TaskHttpRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
    }

    public ResultActions createTask(TaskFormDTO input) throws Exception {
        return mockMvc.perform(post("/api/task")
                .contentType(MediaType.APPLICATION_JSON_UTF8)
                .content(WebTestUtil.convertObjectToJsonBytes(input))
                .with(csrf())
        );
    }
}

Next, we will modify our unit and integration tests to use our new HTTP request builder class.

Modifying Our Unit and Integration Tests

If we want to use our new HTTP request builder class, we have to replace the MockMvc fields found from our test classes with TaskHttpRequestBuilder fields and ensure that our test methods use our HTTP request builder class when they send HTTP requests to the tested controller method.

After we have made the required changes to our unit test class, its source code looks as follows:

@RunWith(HierarchicalContextRunner.class)
@Category(UnitTest.class)
public class TaskCrudControllerTest {

    private TaskCrudService crudService;
    private TaskHttpRequestBuilder requestBuilder;

    @Before
    public void configureSystemUnderTest() {
        crudService = mock(TaskCrudService.class);

        MockMvc mockMvc = MockMvcBuilders.standaloneSetup(new TaskCrudController(crudService))
                .setControllerAdvice(new TaskErrorHandler())
                .setLocaleResolver(fixedLocaleResolver())
                .setMessageConverters(objectMapperHttpMessageConverter())
                .build();
        requestBuilder = new TaskHttpRequestBuilder(mockMvc);
    }

    public class Create {

        private TaskFormDTO input;

        public class WhenAllFieldsAreValid {

            @Before
            public void configureTestCases() {
                input = createValidInput();
                returnCreatedTask();
            }

            private TaskFormDTO createValidInput() {
                TaskFormDTO input = new TaskFormDTO();
                input.setTitle("title");
                input.setDescription("description");
                return input;
            }

            private void returnCreatedTask() {
                //This returns the created task. Omitted because of clarity.
            }

            @Test
            public void shouldReturnHttpStatusCodeCreated() throws Exception {
                requestBuilder.createTask(input)
                        .andExpect(status().isCreated());
            }

            @Test
            public void shouldReturnCreatedTaskWithJson() throws Exception {
                requestBuilder.createTask(input)
                        .andExpect(
                                content().contentType(MediaType.APPLICATION_JSON_UTF8)
                        );
            }

        }
    }
}

After we have made the required changes to our integration test class, its source code looks as follows:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {IntegrationTestContext.class})
@WebAppConfiguration
@TestExecutionListeners({
        DependencyInjectionTestExecutionListener.class,
        TransactionalTestExecutionListener.class,
        DbUnitTestExecutionListener.class,
        ServletTestExecutionListener.class,
        WithSecurityContextTestExecutionListener.class
})
@DatabaseSetup({
        "/com/testwithspring/intermediate/users.xml",
        "/com/testwithspring/intermediate/no-tasks-and-tags.xml"
})
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader.class)
@Category(IntegrationTest.class)
@ActiveProfiles(Profiles.INTEGRATION_TEST)
public class CreateTaskAsUserWhenValidationIsSuccessful {

    @Autowired
    IdColumnReset idColumnReset;

    @Autowired
    private WebApplicationContext webAppContext;

    private TaskFormDTO input;

    private TaskHttpRequestBuilder requestBuilder;

    @Before
    public void configureSystemUnderTest() {
        idColumnReset.resetIdColumns("tasks");

        input = createValidInput();

        MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
                .apply(springSecurity())
                .build();
        requestBuilder = new TaskHttpRequestBuilder(mockMvc);
    }

    private TaskFormDTO createValidInput() {
        TaskFormDTO input = new TaskFormDTO();
        input.setDescription(Tasks.WriteExampleApp.DESCRIPTION);
        input.setTitle(Tasks.WriteExampleApp.TITLE);
        return input;
    }

    @Test
    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
    public void shouldReturnHttpStatusCodeCreated() throws Exception {
        requestBuilder.createTask(input)
                .andExpect(status().isCreated());
    }

    @Test
    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
    public void shouldReturnCreatedTaskAsJson() throws Exception {
        requestBuilder.createTask(input)
                .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8));
    }
}

As we can see, our test classes no longer have duplicate code. Let's evaluate the pros and cons of this technique.

The Pros and Cons of HTTP Request Builders

HTTP request builders help us to put the HTTP request creation logic to one place. This means that:

  • Our tests are easier and faster to write because we don't have to write the same code over and over again.
  • Our tests are easier to maintain. If we make changes to the tested controller method, we have to make these changes only to our HTTP request builder class.

That being said, this technique has two cons:

First, our tests aren't as easy to read as before. The problem is that if we want to find out what kind of an HTTP request is send to the tested controller method, we have to read the source code of our HTTP request builder class. This causes a mental context switch that can be quite expensive.

Second, we might have to use configuration that is not required by our unit tests because both unit and integration tests use the same method of our HTTP request builder class. This can be a bit confusing. That's why I think that we should pay special attention for documenting our HTTP request builder classes.

However, it is also possible that the unnecessary configuration breaks our unit tests. If this is the case, we naturally cannot use the same method in our unit and integration tests.

If we cannot use HTTP request builders for both unit and integration tests, we can still minimize the amount of duplicate code by following these rules:

  • We should use HTTP request builders when we write our integration tests.
  • When we write unit tests for our controllers, we should create factory methods that do the same thing as our request builder classes and put these factory methods to our unit test classes.

Let's summarize what we learned from this blog post.

Summary

This blog post has taught us five things:

  • We can eliminate duplicate code from our test suite by using HTTP request builder classes.
  • If we use HTTP request builders, our tests are easier to write and maintain because the HTTP request creation logic is found from one place.
  • HTTP request builders make our tests slightly harder to read because the HTTP request creation logic is not found from our tests methods.
  • If our unit and integration tests use the same HTTP request builder, we might have to use configuration that is not required by our unit tests. This can be confusing, or it can break our unit tests.
  • We should use HTTP request builders, but we should also understand the drawbacks of this technique, and use it only when doing so makes sense.
4 comments… add one
  • Kostadin Golev May 2, 2017 @ 15:03

    Been a while since last post that was not a weekly :)

    IMO the biggest drawback is the lost readability. I am a big fan of readability in tests and believe some duplication is OK as long as code is readable. But when it is a lot of duplicated code for each API call in every test the sheer amount of added code makes it to harder to read. A lot of it is details you do not care about, as they are the same for each request.

    Still, why choose readability or maintenance, when you can have both?

    Instead of:
    requestBuilder.createTask(input);

    make method named post and pass URL as a parameter:
    requestBuilder.post("/api/task", input);

    you can have others named get(...) and put(...) accordingly

    Just a bit more to write, but also more readable.

    You know have a more generic RequestBuilder you can reuse more easily.

  • Luis Valdés Feb 12, 2019 @ 16:55

    Next time, could you please include your imports?

    • Petri Feb 12, 2019 @ 17:57

      Hi,

      Sure. In fact, nowadays I add imports to my blog posts but for some reason this one doesn't have them. I will update this blog post (and add the missing imports) later this week when I have time to do so.

      Thank you for reporting this problem.

Leave a Reply