Unit testing with JUnit 5

JUnit 5 tutorial, part 2: Unit testing Spring MVC with JUnit 5

Unit test a Spring MVC service, controller, and repository with JUnit 5, Mockito, MockMvc, and DBUnit

1 2 3 4 Page 3
Page 3 of 4

Listing 5. The Spring web controller test class (WidgetRestControllerTest.java)


package com.geekcap.javaworld.spring5mvcexample.web;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.geekcap.javaworld.spring5mvcexample.model.Widget;
import com.geekcap.javaworld.spring5mvcexample.service.WidgetService;
import com.google.common.collect.Lists;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.springframework.test.web.servlet.MockMvc;

import java.util.Optional;

import static org.mockito.Mockito.doReturn;
import static org.mockito.ArgumentMatchers.any;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

import static org.hamcrest.Matchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class WidgetRestControllerTest {

    @MockBean
    private WidgetService service;

    @Autowired
    private MockMvc mockMvc;

    @Test
    @DisplayName("GET /widgets success")
    void testGetWidgetsSuccess() throws Exception {
        // Setup our mocked service
        Widget widget1 = new Widget(1l, "Widget Name", "Description", 1);
        Widget widget2 = new Widget(2l, "Widget 2 Name", "Description 2", 4);
        doReturn(Lists.newArrayList(widget1, widget2)).when(service).findAll();

        // Execute the GET request
        mockMvc.perform(get("/rest/widgets"))
                // Validate the response code and content type
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))

                // Validate headers
                .andExpect(header().string(HttpHeaders.LOCATION, "/rest/widgets"))

                // Validate the returned fields
                .andExpect(jsonPath("$", hasSize(2)))
                .andExpect(jsonPath("$[0].id", is(1)))
                .andExpect(jsonPath("$[0].name", is("Widget Name")))
                .andExpect(jsonPath("$[0].description", is("Description")))
                .andExpect(jsonPath("$[0].version", is(1)))
                .andExpect(jsonPath("$[1].id", is(2)))
                .andExpect(jsonPath("$[1].name", is("Widget 2 Name")))
                .andExpect(jsonPath("$[1].description", is("Description 2")))
                .andExpect(jsonPath("$[1].version", is(4)));
    }

    @Test
    @DisplayName("GET /rest/widget/1")
    void testGetWidgetById() throws Exception {
        // Setup our mocked service
        Widget widget = new Widget(1l, "Widget Name", "Description", 1);
        doReturn(Optional.of(widget)).when(service).findById(1l);

        // Execute the GET request
        mockMvc.perform(get("/rest/widget/{id}", 1L))
                // Validate the response code and content type
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))

                // Validate headers
                .andExpect(header().string(HttpHeaders.LOCATION, "/rest/widget/1"))
                .andExpect(header().string(HttpHeaders.ETAG, "\"1\""))

                // Validate the returned fields
                .andExpect(jsonPath("$.id", is(1)))
                .andExpect(jsonPath("$.name", is("Widget Name")))
                .andExpect(jsonPath("$.description", is("Description")))
                .andExpect(jsonPath("$.version", is(1)));
    }

    @Test
    @DisplayName("GET /rest/widget/1 - Not Found")
    void testGetWidgetByIdNotFound() throws Exception {
        // Setup our mocked service
        doReturn(Optional.empty()).when(service).findById(1l);

        // Execute the GET request
        mockMvc.perform(get("/rest/widget/{id}", 1L))
                // Validate the response code
                .andExpect(status().isNotFound());
    }

    @Test
    @DisplayName("POST /rest/widget")
    void testCreateWidget() throws Exception {
        // Setup our mocked service
        Widget widgetToPost = new Widget("New Widget", "This is my widget");
        Widget widgetToReturn = new Widget(1L, "New Widget", "This is my widget", 1);
        doReturn(widgetToReturn).when(service).save(any());

        // Execute the POST request
        mockMvc.perform(post("/rest/widget")
                .contentType(MediaType.APPLICATION_JSON)
                .content(asJsonString(widgetToPost)))

                // Validate the response code and content type
                .andExpect(status().isCreated())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))

                // Validate headers
                .andExpect(header().string(HttpHeaders.LOCATION, "/rest/widget/1"))
                .andExpect(header().string(HttpHeaders.ETAG, "\"1\""))

                // Validate the returned fields
                .andExpect(jsonPath("$.id", is(1)))
                .andExpect(jsonPath("$.name", is("New Widget")))
                .andExpect(jsonPath("$.description", is("This is my widget")))
                .andExpect(jsonPath("$.version", is(1)));
    }

    @Test
    @DisplayName("PUT /rest/widget/1")
    void testUpdateWidget() throws Exception {
        // Setup our mocked service
        Widget widgetToPut = new Widget("New Widget", "This is my widget");
        Widget widgetToReturnFindBy = new Widget(1L, "New Widget", "This is my widget", 2);
        Widget widgetToReturnSave = new Widget(1L, "New Widget", "This is my widget", 3);
        doReturn(Optional.of(widgetToReturnFindBy)).when(service).findById(1L);
        doReturn(widgetToReturnSave).when(service).save(any());

        // Execute the POST request
        mockMvc.perform(put("/rest/widget/{id}", 1l)
                .contentType(MediaType.APPLICATION_JSON)
                .header(HttpHeaders.IF_MATCH, 2)
                .content(asJsonString(widgetToPut)))

                // Validate the response code and content type
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))

                // Validate headers
                .andExpect(header().string(HttpHeaders.LOCATION, "/rest/widget/1"))
                .andExpect(header().string(HttpHeaders.ETAG, "\"3\""))

                // Validate the returned fields
                .andExpect(jsonPath("$.id", is(1)))
                .andExpect(jsonPath("$.name", is("New Widget")))
                .andExpect(jsonPath("$.description", is("This is my widget")))
                .andExpect(jsonPath("$.version", is(3)));
    }

    @Test
    @DisplayName("PUT /rest/widget/1 - Conflict")
    void testUpdateWidgetConflict() throws Exception {
        // Setup our mocked service
        Widget widgetToPut = new Widget("New Widget", "This is my widget", 1);
        Widget widgetToReturn = new Widget(1L, "New Widget", "This is my widget", 2);
        doReturn(Optional.of(widgetToReturn)).when(service).findById(1L);
        doReturn(widgetToReturn).when(service).save(any());

        // Execute the POST request
        mockMvc.perform(put("/rest/widget/{id}", 1l)
                .contentType(MediaType.APPLICATION_JSON)
                .header(HttpHeaders.IF_MATCH, 3)
                .content(asJsonString(widgetToPut)))

                // Validate the response code and content type
                .andExpect(status().isConflict());
    }

    @Test
    @DisplayName("PUT /rest/widget/1 - Not Found")
    void testUpdateWidgetNotFound() throws Exception {
        // Setup our mocked service
        Widget widgetToPut = new Widget("New Widget", "This is my widget");
        doReturn(Optional.empty()).when(service).findById(1L);

        // Execute the POST request
        mockMvc.perform(put("/rest/widget/{id}", 1l)
                .contentType(MediaType.APPLICATION_JSON)
                .header(HttpHeaders.IF_MATCH, 3)
                .content(asJsonString(widgetToPut)))

                // Validate the response code and content type
                .andExpect(status().isNotFound());
    }

    static String asJsonString(final Object obj) {
        try {
            return new ObjectMapper().writeValueAsString(obj);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

Testing the web controller with MockMvc

Our next step is to test the web controller. For this, we can use a testing utility that Spring provides to simulate the web request inside of a Spring application: MockMvc. This utility allows us to "perform" a web request to a URI, optionally specifying headers and a body, and then perform validations against the response, including the HTTP status code, headers, and the content of the body. To use MockMvc, we need to do just two things:

  1. Annotate our test class with the @AutoConfigureMockMvc annotation.
  2. Autowire an instance of MockMvc into our test class.

The @AutoConfigureMockMvc annotation tells Spring to create an instance of MockMvc that is associated with the application context, so that it can deliver requests to the controllers handling them. Once we've created the MockMvc instance, we can autowire it into our test class just like we would any other Spring bean, using the @Autowired annotation.

Test cases

The WidgetRestControllerTest creates a mock implementation of the WidgetService and configures it in each test case. Let's review the test cases.

Test case 1

The first test method, testGetWidgetsSuccess(), tests the successful retrieval of all widgets. It begins by creating two Widgets and configures the mock WidgetService to return them when its findAll() method is called. The MockMvc::perform method executes a request against our web application and returns a ResultActions object against which we can execute assertions about the response. We construct a RequestBuilder (whose implementations I have statically imported from the org.springframework.test.web.servlet.request.MockMvcRequestBuilders class) and pass it to the perform() method. In this case, we pass a get() request builder, which represents an HTTP GET request, and pass it the URI that we want to GET: /rest/widgets. The perform() method executes the request and returns a ResultActions with the results of the execution. We then execute a series of andExpect() calls to validate the results:

  • andExpect(status().isOk(): the status() method retrieves the HTTP status code and the isOk() method validates that it is 200 OK
  • andExpect(content().contentType(MediaType.APPLICATION_JSON)): the content() method retrieves the response body as a ContentResultMatchers, which we use to validate that the content type of the response is MediaType.APPLICATION_JSON
  • andExpect(header().string(HttpHeaders.LOCATION, "/rest/widgets")): the header() method retrieves the response headers and we use it to validate that the location header is "/rest/widgets". The header() method returns a HeaderResultMatchers instance, which exposes a set of methods that can be used to validate headers. For example, you can check that a header exists or does not exist, you can check a header's specific value as a String, long, or date, and you can pass in one or more Hamcrest matches (see Part 1 for an introduction to Hamcrest).

After validating the HTTP response code, content type, and headers, we validate the body contents of the response. The jsonPath() method accepts an expression and a Hamcrest matcher. The expression is a JsonPath, which provides an elaborate syntax for retrieving specific JSON nodes and values. The first expression, $, returns the root element of the JSON document. In this case, we retrieve the root element of the JSON document, which is an array of Widgets. We then pass the Hamcrest matcher: hasSize(2), which checks that the number of elements in the array is 2. Next, we retrieve the first element in the array using the JSON Path expression: $[0], retrieve each of its fields using the dot notation, and finally pass the Hamcrest matcher is() to perform an exact match against our expected values. We then perform the same tests against the second element in the array using the JSON Path expression: $[1].

All of this is to say that we validate that we received a JSON array with two elements, and we validate the widget fields match those in the Widgets that we returned from the mock WidgetService.

Test case 2

The second test method, testGetWidgetById() performs a GET request against "/rest/widget/{id}", binding 1 to the {id}, and validates that we receive a 200 OK HTTP response code, the expected location, and eTag headers, and the expected body contents. Note that the controller returns the eTag as a String, so we need to compare it against "1" instead of just 1.

Test case 3

The third test method, testGetWidgetByIdNotFound(), configures the mock WidgetService to return an Optional.empty() when its findById() method is called and then validates the HTTP response code returned is a 404 Not Found.

The testCreateWidget() method tests the creation of a Widget. It creates a Widget to POST to the "/rest/widget" URI and a Widget that will be returned when the WidgetService::save method is called. We configure the mock WidgetService to return the sample Widget when its save method is called with any() value. Recall that we need to pass any() because the WidgetService will modify the version number during the save, so the Widget will be different from the one we pass to the save() method.

In this test case, we perform a post() request. The post() method accepts a URI to which to perform the POST, but then we chain additional configuration on the POST:

  • We set the content-type to MediaType.APPLICATION_JSON
  • We set the content to a JSON representation of the Widget. The test class includes an asJsonString() method that uses the Jackson ObjectMapper to convert the Widget object into a JSON string.

After performing the request, we validate that we received a "201 Created" HTTP response code, an APPLICATION_JSON content type, the expected location and eTag headers, and the expected JSON body values.

The testUpdateWidget() performs a PUT request to "/rest/widget/1" to update a Widget. The WidgetRestController performs a lookup for the specified Widget and a subsequent save() call, so we need to configure the mock WidgetService to handle both method calls. It uses the put() request builder, configures the content-type and body, as we did in the previous test case, but it also includes the If-Match HTTP header. Recall when building PUT RESTful services, we should check that the current eTag (or version in our case) matches the If-Match header before performing the update; if it does not match, then it should return a "409 Conflict" HTTP response code. The remainder of the test case validates the HTTP response code, headers, and body.

The testUpdateWidgetConflict() method validates that an update of a stale Widget results in a conflict. It configures the WidgetService to return a Widget with a version number of 2, when its findById() method is invoked, and then it PUTs a Widget with a version of 1. Finally, it performs the PUT request and validates that it receives a "409 Conflict" HTTP response code.

Test case 4

The last test method, testUpdateWidgetNotFound(), configures the mock WidgetService to return an Optional.empty() when its findById() method is called, performs a PUT request, and validates that we receive a "404 Not Found" HTTP response code.

You can write many more test cases to validate your controllers, but this set of common test cases should get you started.

Unit testing a Spring repository

The last class we're going to test is the WidgetRepository class. When using Spring Data, as we do in this example, the implementation of a repository is automatically generated at runtime, but there are times when you want to test it. For example, if you have custom query methods, either by naming convention or using the @Query annotation, you want to make sure that they return what you expect them to. The challenge, however, is that the implementation runs against a real database, so we need a strategy for setting up a test database, populating it before a test runs, and cleaning it up afterward.

Let's start with the WidgetRepository interface, shown in Listing 6.

Lising 6. The Spring repository interface (WidgetRepository.java)


package com.geekcap.javaworld.spring5mvcexample.repository;

import com.geekcap.javaworld.spring5mvcexample.model.Widget;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;

import java.util.List;

public interface WidgetRepository extends CrudRepository<Widget, Long> {
    @Query(value = "SELECT w FROM Widget w WHERE w.name LIKE ?1")
    List<Widget> findWidgetsWithNameLike(String name);
}

WidgetRepository is an interface that extends CrudRepository, so it will inherit all of its methods:


<S extends T> S save(S var1);
<S extends T> Iterable<S> saveAll(Iterable<S> var1);
Optional<T> findById(ID var1);
boolean existsById(ID var1);
Iterable<T> findAll();
Iterable<T> findAllById(Iterable var1);
long count();
void deleteById(ID var1);
void delete(T var1);
void deleteAll(Iterable<? extends T> var1);
void deleteAll();

Additionally, I added a single method, findWidgetsWithNameLike, that uses the @Query annotation to find Widgets with a name that matches a specified LIKE expression. The method is not interesting, but it allows us to test a custom query.

To set up and tear down a database, we're going to leverage a third-party library called DBUnit. DBUnit allows us to do set up a database and specify a YAML file with records to insert into the database before tests run. We can also configure DBUnit to wipe and rebuild the database before every test runs, or to clean up the database after a test runs. In short, it's a very handy tool.

Integrating DBUnit with JUnit 5 and Spring

To use DBUnit with JUnit 5 and Spring, we need to add the following dependencies to our POM file:


     <dependency>
      <groupId>org.dbunit</groupId>
      <artifactId>dbunit</artifactId>
      <version>2.7.0</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>com.github.database-rider</groupId>
      <artifactId>rider-core</artifactId>
      <version>1.10.0</version>
    </dependency>
    <dependency>
      <groupId>com.github.database-rider</groupId>
      <artifactId>rider-junit5</artifactId>
      <version>1.10.0</version>
    </dependency>
    <dependency>
      <groupId>com.github.springtestdbunit</groupId>
      <artifactId>spring-test-dbunit</artifactId>
      <version>1.3.0</version>
      <scope>test</scope>
    </dependency>

Rather than set up and run a full database to test our queries, we will create a configuration class with a test profile that uses a fast in-memory database; in this case, H2. To do this, we create a configuration class in our test classes (under /src/test/java) and annotate it with a profile name that we'll use only for our test cases. Listing 7 shows the source code for the WidgetRepositoryTestConfiguration class.

Listing 7. The Spring repository test configuration (WidgetRepositoryTestConfiguration.java)


package com.geekcap.javaworld.spring5mvcexample.repository;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.datasource.DriverManagerDataSource;

import javax.sql.DataSource;

@Configuration
@Profile("test")
public class WidgetRepositoryTestConfiguration {

    @Primary
    @Bean
    public DataSource dataSource() {
        // Setup a test data source
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setDriverClassName("org.h2.Driver");
        dataSource.setUrl("jdbc:h2:mem:db;DB_CLOSE_DELAY=-1");
        dataSource.setUsername("sa");
        dataSource.setPassword("");
        return dataSource;
    }
}

This class is annotated with Spring's @Configuration, so Spring will know that it is responsible for creating and configuring Spring beans. Spring will find the annotation during its CLASSPATH scan and create a bean of type DataSource. This DataSource will be autowired into the automatically generated repository instance. The important thing to notice about this configuration is that it is annotated with @Profile("test"), so it will only be active when our test class references the "test" profile in its @ActiveProfiles annotation. When running in production, the service will not use this profile, nor will it use this test database.

The Spring repository test class

Listing 8 shows the source code for the WidgetRepositoryTest class.

1 2 3 4 Page 3
Page 3 of 4