Writing Integration Tests With JUnit 5 and TestContainers

After we have finished this webinar, we:

  • Understand how we can start and stop a Docker container that runs a PostgreSQL database when we are writing integration tests for a Spring Boot application.
  • Know how we can configure our database connection.
  • Can write integration tests for a Spring Boot web application.

Get the source code from Github

Configuring Our Database Connection

My previous webinar described how we can implement a custom JUnit 5 extension that starts and stops our Docker container. However, if we are writing integration tests for a Spring Boot application, we don’t have use a custom JUnit 5 extension.

If we want to start and stop a Docker container that runs a PostgreSQL database, we have to follow these steps:

First, we have to ensure that our classpath contains both TestContainers and the PostgreSQL JDBC driver.

If you don’t know how you can add these dependencies to your classpath, you should take a look at the webinar: Introduction to TestContainers .

Second, we have to configure our database connection by making the following changes to our properties file (application-integrationTest.properties):

  1. Configure the JDBC url by using the following format: jdbc:tc:postgresql:[database_version]://[host]:[port]/[database name].
  2. Ensure that our Spring Boot application uses the JDBC driver provided by TestContainers (org.testcontainers.jdbc.ContainerDatabaseDriver).
  3. Configure the username and password of the database user. It’s important to understand that we don’t have to use a specific username and password because TestContainers creates the database (and the database user) by using the property values found from our properties file.

After we have configured our database connection, the application-integrationTest.properties file looks as follows:

 1#Database Configuration
 2spring.datasource.type=com.zaxxer.hikari.HikariDataSource
 3spring.datasource.url=jdbc:tc:postgresql:10.5://localhost/testwithspring
 4spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver
 5spring.datasource.username=username
 6spring.datasource.password=password
 7
 8#Hibernate Configuration
 9spring.jpa.database=H2
10spring.jpa.hibernate.ddl-auto=validate
11spring.jpa.show-sql=false
12spring.jpa.properties.hibernate.jdbc.time_zone=UTC
13
14#I18N
15spring.messages.basename=i18n/messages
16
17#Jackson
18spring.jackson.serialization.write-dates-as-timestamps=false
19spring.jackson.serialization.write-dates-with-zone-id=true
20
21#Liquibase Configuration
22spring.liquibase.change-log=classpath:/db/changelog/db.changelog.xml
23spring.liquibase.contexts=integration-test

After we have added the required dependencies to our classpath and configured our database connection, TestContainers starts the Docker container which runs a PostgreSQL database when the application context of our Spring Boot application is loaded and stops it when the application context is closed.

Next, we will find out how we can configure the application context of our integration tests.

Configuring the Application Context of Our Integration Tests

We can configure the application context of our integration tests by following these steps:

First, we have to create a custom TestExecutionListener that extends the DbUnitTestExecutionListener class. This TestExecutionListener ensures that the DbUnitTestExecutionListener is invoked before the WithSecurityContextTestExecutionListener. We have to do this because we have to insert the required user account data into our database before the security context is created by the WithSecurityContextTestExecutionListener object.

After we have written our custom TestExecutionListener, its source code looks as follows:

 1import com.github.springtestdbunit.DbUnitTestExecutionListener;
 2
 3public class OrderedDbUnitTestExecutionListener
 4        extends DbUnitTestExecutionListener {
 5
 6    @Override
 7    public int getOrder() {
 8        return 9999;
 9    }
10}

Second, we have to write a custom annotation that helps us to eliminate duplicate configuration when we are writing integration tests for Spring powered repositories. This annotation integrates the Spring TestContext framework with JUnit 5, enables the Spring Boot testing support, configures the loaded application context configuration class, registers our custom TestExecutionListener, configures the active Spring profile, and configures the JUnit 5 tag that identifies our integration tests.

After we have written our custom annotation, its source code looks as follows:

 1import com.testwithspring.master.config.Profiles;
 2import org.junit.jupiter.api.Tag;
 3import org.junit.jupiter.api.extension.ExtendWith;
 4import org.springframework.boot.test.context.SpringBootTest;
 5import org.springframework.core.annotation.AliasFor;
 6import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
 7import org.springframework.test.context.ActiveProfiles;
 8import org.springframework.test.context.TestExecutionListeners;
 9import org.springframework.test.context.junit.jupiter.SpringExtension;
10
11import java.lang.annotation.*;
12
13@Target(ElementType.TYPE)
14@Retention(RetentionPolicy.RUNTIME)
15@Documented
16@ExtendWith(SpringExtension.class)
17@SpringBootTest
18@TestExecutionListeners(value =  OrderedDbUnitTestExecutionListener.class,
19        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
20)
21@ActiveProfiles(Profiles.INTEGRATION_TEST)
22@Tag(TestTags.INTEGRATION_TEST)
23public @interface RepositoryIntegrationTest {
24
25    @AliasFor(
26            annotation = SpringBootTest.class
27    )
28    Class<?>[] classes() default {};
29}

Third, we have to write a custom annotation that helps us to eliminate duplicate configuration when we are writing integration tests for Spring MVC controllers. This annotation integrates the Spring TestContext framework with JUnit 5, enables the Spring Boot testing support, configures the loaded application context configuration class, enables the auto-configuration of the Spring MVC Test framework, registers the required test execution listeners, configures the active Spring profile, and configures the JUnit 5 tag that identifies our integration tests.

After we have written our custom annotation, its source code looks as follows:

 1import com.testwithspring.master.config.Profiles;
 2import org.junit.jupiter.api.Tag;
 3import org.junit.jupiter.api.extension.ExtendWith;
 4import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
 5import org.springframework.boot.test.context.SpringBootTest;
 6import org.springframework.core.annotation.AliasFor;
 7import org.springframework.security.test.context.support.WithSecurityContextTestExecutionListener;
 8import org.springframework.test.context.ActiveProfiles;
 9import org.springframework.test.context.TestExecutionListeners;
10import org.springframework.test.context.junit.jupiter.SpringExtension;
11
12import java.lang.annotation.*;
13
14@Target(ElementType.TYPE)
15@Retention(RetentionPolicy.RUNTIME)
16@Documented
17@ExtendWith(SpringExtension.class)
18@SpringBootTest
19@AutoConfigureMockMvc
20@TestExecutionListeners(value =  {
21        OrderedDbUnitTestExecutionListener.class,
22        WithSecurityContextTestExecutionListener.class
23},
24        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
25)
26@ActiveProfiles(Profiles.INTEGRATION_TEST)
27@Tag(TestTags.INTEGRATION_TEST)
28public @interface WebIntegrationTest {
29
30    @AliasFor(
31            annotation = SpringBootTest.class
32    )
33    Class<?>[] classes() default {};
34}

We are now ready to write integration tests for our Spring Boot application. Let’s move on and and find out how we can write integration tests for Spring powered repositories.

Writing Integration Tests for Spring Powered Repositories

When we want to write integration tests for a repository method that returns the information of all tasks found from our database, we have to follow these steps:

First, we have to configure the system under test by following these steps:

  1. Annotate our test class with the @RepositoryIntegrationTest annotation and specify the configuration class that configures the application context of the system under test.
  2. Insert the required test data into our database. The users.xml data set contains the user accounts of our application and the tasks.xml data set contains the tasks found from our database.
  3. Ensure that our DbUnit data sets are loaded by the ReplacementDataSetLoader class.
  4. Add a TaskRepository field to our test class and ensure that the tested repository object is injected into this field by the Spring container.

After we have configured the system under test, the source code of our test class looks as follows:

 1import com.github.springtestdbunit.annotation.DatabaseSetup;
 2import com.github.springtestdbunit.annotation.DbUnitConfiguration;
 3import com.github.springtestdbunit.dataset.ReplacementDataSetLoader;
 4import org.junit.jupiter.api.DisplayName;
 5import org.springframework.beans.factory.annotation.Autowired;
 6
 7@RepositoryIntegrationTest(classes = IntegrationTestContext.class)
 8@DatabaseSetup({
 9        "/com/testwithspring/master/users.xml",
10        "/com/testwithspring/master/tasks.xml"
11})
12@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader.class)
13@DisplayName("Find all tasks from the database when two tasks are found")
14class FindAllTasksWhenTwoTasksAreFoundTest {
15
16    @Autowired
17    private TaskRepository repository;
18}

Second, we have to write three integration tests which verify that the system under test is working as expected when two tasks are found from the database. After we have written these integration tests, the source code of our test class looks as follows:

 1import com.github.springtestdbunit.annotation.DatabaseSetup;
 2import com.github.springtestdbunit.annotation.DbUnitConfiguration;
 3import com.github.springtestdbunit.dataset.ReplacementDataSetLoader;
 4import org.junit.jupiter.api.DisplayName;
 5import org.junit.jupiter.api.Test;
 6import org.springframework.beans.factory.annotation.Autowired;
 7import java.util.List;
 8
 9import static org.assertj.core.api.Assertions.assertThat;
10
11@RepositoryIntegrationTest(classes = IntegrationTestContext.class)
12@DatabaseSetup({
13        "/com/testwithspring/master/users.xml",
14        "/com/testwithspring/master/tasks.xml"
15})
16@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader.class)
17@DisplayName("Find all tasks from the database when two tasks are found")
18class FindAllTasksWhenTwoTasksAreFoundTest {
19
20    @Autowired
21    private TaskRepository repository;
22
23    @Test
24    @DisplayName("Should return two tasks")
25    void shouldReturnTwoTasks() {
26        List<TaskListDTO> tasks = repository.findAll();
27        assertThat(tasks).hasSize(2);
28    }
29
30    @Test
31    @DisplayName("Should return the correct information of the first task")
32    void shouldReturnCorrectInformationOfFirstTask() {
33        TaskListDTO first = repository.findAll().get(0);
34
35        assertThat(first.getId())
36                .isEqualByComparingTo(Tasks.WriteExampleApp.ID);
37        assertThat(first.getStatus())
38                .isEqualTo(Tasks.WriteExampleApp.STATUS);
39        assertThat(first.getTitle())
40                .isEqualTo(Tasks.WriteExampleApp.TITLE);
41    }
42
43    @Test
44    @DisplayName("Should return the correct information of the second task")
45    void shouldReturnCorrectInformationOfSecondTask() {
46        TaskListDTO second = repository.findAll().get(1);
47
48        assertThat(second.getId())
49                .isEqualByComparingTo(Tasks.WriteLesson.ID);
50        assertThat(second.getStatus())
51                .isEqualTo(Tasks.WriteLesson.STATUS);
52        assertThat(second.getTitle())
53                .isEqualTo(Tasks.WriteLesson.TITLE);
54    }
55}

We can now write integration tests for Spring powered repositories. Next, we will find out how we can write integration tests for Spring MVC Controllers.

Writing Integration Tests for Spring MVC Controllers

When we want to write integration tests for a controller method that returns the information of one task, we have to follow these steps:

First, we have to configure the system under test by following these steps:

  1. Annotate our test class with the @WebIntegrationTest annotation and specify the configuration class that configures the application context of the system under test.
  2. Insert the required test data into our database. We will use the same data sets which we used in the previous example.
  3. Ensure that our DbUnit data sets are loaded by the ReplacementDataSetLoader class.
  4. Add a MockMvc field to our test class and ensure that the configured MockMvc object is injected into this field by the Spring container.

After we have configured the system under test, the source code of our test class looks as follows:

 1import com.github.springtestdbunit.annotation.DatabaseSetup;
 2import com.github.springtestdbunit.annotation.DbUnitConfiguration;
 3import com.github.springtestdbunit.dataset.ReplacementDataSetLoader;
 4import org.junit.jupiter.api.DisplayName;
 5import org.springframework.beans.factory.annotation.Autowired;
 6import org.springframework.test.web.servlet.MockMvc;
 7
 8@WebIntegrationTest(classes = IntegrationTestContext.class)
 9@DatabaseSetup({
10        "/com/testwithspring/master/users.xml",
11        "/com/testwithspring/master/tasks.xml"
12})
13@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader.class)
14@DisplayName("Find task as a user when the requested task is found")
15public class FindTaskAsUserWhenTaskIsFoundTest {
16
17    @Autowired
18    private MockMvc mockMvc;
19}

Second, we have to write five integration tests which ensure that the system under test is working as expected when a “normal” user gets the information of an existing task. After we have written these integration tests, the source code of our test class looks as follows:

  1import com.github.springtestdbunit.annotation.DatabaseSetup;
  2import com.github.springtestdbunit.annotation.DbUnitConfiguration;
  3import com.github.springtestdbunit.dataset.ReplacementDataSetLoader;
  4import org.junit.jupiter.api.DisplayName;
  5import org.junit.jupiter.api.Test;
  6import org.springframework.beans.factory.annotation.Autowired;
  7import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
  8import org.springframework.http.MediaType;
  9import org.springframework.security.test.context.support.WithUserDetails;
 10import org.springframework.test.web.servlet.MockMvc;
 11import org.springframework.test.web.servlet.ResultActions;
 12
 13import static org.hamcrest.Matchers.hasSize;
 14import static org.hamcrest.Matchers.is;
 15import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
 16import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
 17import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 18import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 19
 20@WebIntegrationTest(classes = IntegrationTestContext.class)
 21@AutoConfigureMockMvc
 22@DatabaseSetup({
 23        "/com/testwithspring/master/users.xml",
 24        "/com/testwithspring/master/tasks.xml"
 25})
 26@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader.class)
 27@DisplayName("Find task as a user when the requested task is found")
 28public class FindTaskAsUserWhenTaskIsFoundTest {
 29
 30    @Autowired
 31    private MockMvc mockMvc;
 32
 33    @Test
 34    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
 35    @DisplayName("Should return the HTTP status code OK ")
 36    void shouldReturnHttpStatusCodeOk() throws Exception {
 37        findTask()
 38                .andExpect(status().isOk());
 39    }
 40
 41    @Test
 42    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
 43    @DisplayName("Should return the found task as JSON")
 44    void shouldReturnFoundTaskAsJson() throws Exception {
 45        findTask()
 46                .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8));
 47    }
 48
 49    @Test
 50    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
 51    @DisplayName("Should return the information of the found task")
 52    void shouldReturnInformationOfFoundTask() throws Exception {
 53        findTask()
 54                .andExpect(jsonPath(
 55                        WebTestConstants.JsonPathProperty.Task.Assignee.ID,
 56                        is(Tasks.WriteExampleApp.Assignee.ID.intValue()))
 57                )
 58                .andExpect(jsonPath(
 59                        WebTestConstants.JsonPathProperty.Task.Assignee.NAME,
 60                        is(Tasks.WriteExampleApp.Assignee.NAME))
 61                )
 62                .andExpect(jsonPath(
 63                        WebTestConstants.JsonPathProperty.Task.Closer.ID,
 64                        is(Tasks.WriteExampleApp.Closer.ID.intValue()))
 65                )
 66                .andExpect(jsonPath(
 67                        WebTestConstants.JsonPathProperty.Task.Closer.NAME,
 68                        is(Tasks.WriteExampleApp.Closer.NAME))
 69                )
 70                .andExpect(jsonPath(
 71                        WebTestConstants.JsonPathProperty.Task.CREATION_TIME,
 72                        is(Tasks.WriteExampleApp.CREATION_TIME_STRING))
 73                )
 74                .andExpect(jsonPath(
 75                        WebTestConstants.JsonPathProperty.Task.Creator.ID,
 76                        is(Tasks.WriteExampleApp.Creator.ID.intValue()))
 77                )
 78                .andExpect(jsonPath(
 79                        WebTestConstants.JsonPathProperty.Task.Creator.NAME,
 80                        is(Tasks.WriteExampleApp.Creator.NAME))
 81                )
 82                .andExpect(jsonPath(
 83                        WebTestConstants.JsonPathProperty.Task.ID,
 84                        is(Tasks.WriteExampleApp.ID.intValue()))
 85                )
 86                .andExpect(jsonPath(
 87                        WebTestConstants.JsonPathProperty.Task.MODIFICATION_TIME,
 88                        is(Tasks.WriteExampleApp.MODIFICATION_TIME_STRING))
 89                )
 90                .andExpect(jsonPath(
 91                        WebTestConstants.JsonPathProperty.Task.Modifier.ID,
 92                        is(Tasks.WriteExampleApp.Modifier.ID.intValue()))
 93                )
 94                .andExpect(jsonPath(
 95                        WebTestConstants.JsonPathProperty.Task.Modifier.NAME,
 96                        is(Tasks.WriteExampleApp.Modifier.NAME))
 97                )
 98                .andExpect(jsonPath(
 99                        WebTestConstants.JsonPathProperty.Task.TITLE,
100                        is(Tasks.WriteExampleApp.TITLE))
101                )
102                .andExpect(jsonPath(
103                        WebTestConstants.JsonPathProperty.Task.DESCRIPTION,
104                        is(Tasks.WriteExampleApp.DESCRIPTION))
105                )
106                .andExpect(jsonPath(
107                        WebTestConstants.JsonPathProperty.Task.STATUS,
108                        is(Tasks.WriteExampleApp.STATUS.toString()))
109                )
110                .andExpect(jsonPath(
111                        WebTestConstants.JsonPathProperty.Task.RESOLUTION,
112                        is(Tasks.WriteExampleApp.RESOLUTION.toString()))
113                );
114    }
115
116    @Test
117    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
118    @DisplayName("Should return a task that has one tag")
119    void shouldReturnTaskThatHasOneTag() throws Exception {
120        findTask()
121                .andExpect(jsonPath(
122                        WebTestConstants.JsonPathProperty.Task.TAGS,
123                        hasSize(1))
124                );
125    }
126
127    @Test
128    @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
129    @DisplayName("Should return the information of the correct tag")
130    void shouldReturnInformationOfCorrectTag() throws Exception {
131        findTask()
132                .andExpect(jsonPath("$.tags[0].id",
133                        is(Tasks.WriteExampleApp.Tags.Example.ID.intValue()))
134                )
135                .andExpect(jsonPath("$.tags[0].name",
136                        is(Tasks.WriteExampleApp.Tags.Example.NAME))
137                );
138    }
139
140    private ResultActions findTask() throws Exception {
141        return mockMvc.perform(get("/api/task/{taskId}", Tasks.WriteExampleApp.ID));
142    }
143}

We can now write integration tests for Spring Boot applications by using TestContainers and JUnit 5. Let’s summarize what we learned from this webinar.

Summary

This webinar has taught us four things:

  • If we want to start and stop a Docker that runs a PostgreSQL database, we have to ensure that our classpath contains both TestContainers and PostgreSQL JDBC driver.
  • The value of the spring.datasource.url property must contain a special JDBC url which configures the started database.
  • The value of the spring.datasource.driver-class-name property must contain the fully qualified class name of the JDBC driver provided by TestContainers (org.testcontainers.jdbc.ContainerDatabaseDriver).
  • After we have integrated TestContainers with the Spring TestContext framework, TestContainers starts the Docker container when the application context of our application is loaded and stops it when the application context is closed.

Get the source code from Github