Using Testcontainers in Spring Boot Tests For Database Integration Tests
In this blog post I'd like to demonstrate how I integrate Testcontainers in Spring Boot tests for running integration tests with a database. I'm not using Testcontainers' Spring Boot modules. How it works with them, I will show in a separate blog post. All samples can be found on GitHub.
Why Testcontainers?
Testcontainers is a library that helps to integrate infrastructure components like database in integration tests based on Docker Container. It helps to avoid writing integrated tests. These are kind of tests that will pass or fail based on the correctness of another system. With Testcontainers I have the control over these dependent systems.
Introducing the domain
The further samples shows different approach how to save some hero objects through different repository implementations in a database and how the corresponding tests could look like.
1package com.github.sparsick.testcontainerspringboot.hero.universum;
2
3import javax.persistence.Entity;
4import javax.persistence.GeneratedValue;
5import javax.persistence.Id;
6import java.util.Objects;
7
8public class Hero {
9 private Long id;
10 private String name;
11 private String city;
12 private ComicUniversum universum;
13
14 public Hero(String name, String city, ComicUniversum universum) {
15 this.name = name;
16 this.city = city;
17 this.universum = universum;
18 }
19
20 public String getName() {
21 return name;
22 }
23
24 public String getCity() {
25 return city;
26 }
27
28 public ComicUniversum getUniversum() {
29 return universum;
30 }
31}
All further repositories are parts of a Spring Boot web application. So at the end of this blog post I will demonstrate how to write a test for the whole web application including a database. Let's start with an easy sample, a repository based on JDBC.
Testing Repository Based on JDBC
Assume we have following repository implementation based on JDBC. We have two methods, one for adding a hero into the database and one for getting all heroes from the database.
1package com.github.sparsick.testcontainerspringboot.hero.universum;
2
3import org.springframework.jdbc.core.JdbcTemplate;
4import org.springframework.stereotype.Repository;
5
6import javax.sql.DataSource;
7import java.util.Collection;
8
9@Repository
10public class HeroClassicJDBCRepository {
11
12 private final JdbcTemplate jdbcTemplate;
13
14 public HeroClassicJDBCRepository(DataSource dataSource) {
15 jdbcTemplate = new JdbcTemplate(dataSource);
16 }
17
18 public void addHero(Hero hero) {
19 jdbcTemplate.update("insert into hero (city, name, universum) values (?,?,?)",
20 hero.getCity(), hero.getName(), hero.getUniversum().name());
21
22 }
23
24 public Collection<Hero> allHeros() {
25 return jdbcTemplate.query("select * From hero",
26 (resultSet, i) -> new Hero(resultSet.getString("name"),
27 resultSet.getString("city"),
28 ComicUniversum.valueOf(resultSet.getString("universum"))));
29 }
30
31}
For this repository, we can write a normal JUnit5 tests without Spring application context loading. So first at all, we have to set up the dependencies to the test libraries, in this case, JUnit5 and Testcontainers. As build tool, I use Maven. Both test libraries provide so called BOM "bill of material", that helps to avoid a version mismatch in my used dependencies. As database, I want to use MySQL. Therefore, I use the Testcontainers' module mysql
additional to the core module testcontainers
. It provides a predefined MySQL container. For simplifying the container setup specifically in JUnit5 test code, Testcontainers provides a JUnit5 module junit-jupiter
.
1 <dependencies>
2 <dependency>
3 <groupId>org.testcontainers</groupId>
4 <artifactId>testcontainers</artifactId>
5 <scope>test</scope>
6 </dependency>
7 <dependency>
8 <groupId>org.testcontainers</groupId>
9 <artifactId>junit-jupiter</artifactId>
10 <scope>test</scope>
11 </dependency>
12 <dependency>
13 <groupId>org.testcontainers</groupId>
14 <artifactId>mysql</artifactId>
15 <scope>test</scope>
16 </dependency>
17 <dependency>
18 <groupId>org.junit.jupiter</groupId>
19 <artifactId>junit-jupiter</artifactId>
20 <scope>test</scope>
21 </dependency>
22 </dependencies>
23 <dependencyManagement>
24 <dependencies>
25 <dependency>
26 <groupId>org.junit</groupId>
27 <artifactId>junit-bom</artifactId>
28 <version>${junit.jupiter.version}</version>
29 <type>pom</type>
30 <scope>import</scope>
31 </dependency>
32 <dependency>
33 <groupId>org.testcontainers</groupId>
34 <artifactId>testcontainers-bom</artifactId>
35 <version>${testcontainers.version}</version>
36 <type>pom</type>
37 <scope>import</scope>
38 </dependency>
39 </dependencies>
40 </dependencyManagement>
Now, we have everything to write the first test.
1package com.github.sparsick.testcontainerspringboot.hero.universum;
2
3import ...
4
5@Testcontainers
6class HeroClassicJDBCRepositoryIT {
7 @Container
8 private MySQLContainer database = new MySQLContainer();
9
10 private HeroClassicJDBCRepository repositoryUnderTest;
11
12 @Test
13 void testInteractionWithDatabase() {
14 ScriptUtils.runInitScript(new JdbcDatabaseDelegate(database, ""),"ddl.sql");
15 repositoryUnderTest = new HeroClassicJDBCRepository(dataSource());
16
17 repositoryUnderTest.addHero(new Hero("Batman", "Gotham City", ComicUniversum.DC_COMICS));
18
19 Collection<Hero> heroes = repositoryUnderTest.allHeros();
20
21 assertThat(heroes).hasSize(1);
22 }
23
24 @NotNull
25 private DataSource dataSource() {
26 MysqlDataSource dataSource = new MysqlDataSource();
27 dataSource.setUrl(database.getJdbcUrl());
28 dataSource.setUser(database.getUsername());
29 dataSource.setPassword(database.getPassword());
30 return dataSource;
31 }
32}
Let's have a look how the database is prepared for the test. Firstly, we annotate the test class with @Testcontainers
. Behind this annotation hides a JUnit5 extension provided by Testcontainers. It checks if Docker is installed on the machine, starts and stops the container during the test. But how Testcontainers knows which container it should start? Here, the annotation @Container
helps. It marks container that should manage by the Testcontainers extension. In this case, a MySQLContainer
provided by Testcontainers module mysql
. This class provides a MySQL Docker container and handles such things like setting up database user, recognizing when the database is ready to use etc. As soon as the database is ready to use, the database schema has to be set up. Testcontainers can also provide support here. ScriptUtils._runInitScript_(new JdbcDatabaseDelegate(database, ""),"ddl.sql");
ensure that the schema is set up like it defines in SQL script ddl.sql
.
1-- ddl.sql
2create table hero (id bigint AUTO_INCREMENT PRIMARY KEY, city varchar(255), name varchar(255), universum varchar(255)) engine=InnoDB
Now we are ready to set up our repository under test. Therefore, we need the database connection information for the DataSource
object. Under the hood, Testcontainers searches after an available port and bind the container on this free port. This port number is different on every container start via Testcontainers. Furthermore, it configures the database in container with a user and password. Therefore, we have to ask the MySQLContainer
object how the database credentials and the JDBC URL are. With this information, we can set up the repository under test (repositoryUnderTest = new HeroClassicJDBCRepository(dataSource());
) and finish the test.
If you run the test and you get the following error message:
117:18:50.990 [ducttape-1] DEBUG com.github.dockerjava.core.command.AbstrDockerCmd - Cmd: org.testcontainers.dockerclient.transport.okhttp.OkHttpDockerCmdExecFactory$1@1adc57a8
217:18:51.492 [ducttape-1] DEBUG org.testcontainers.dockerclient.DockerClientProviderStrategy - Pinging docker daemon...
317:18:51.493 [ducttape-1] DEBUG com.github.dockerjava.core.command.AbstrDockerCmd - Cmd: org.testcontainers.dockerclient.transport.okhttp.OkHttpDockerCmdExecFactory$1@3e5b3a3b
417:18:51.838 [main] DEBUG org.testcontainers.dockerclient.DockerClientProviderStrategy - UnixSocketClientProviderStrategy: failed with exception InvalidConfigurationException (ping failed). Root cause LastErrorException ([111] Verbindungsaufbau abgelehnt)
517:18:51.851 [main] DEBUG org.rnorth.tcpunixsocketproxy.ProxyPump - Listening on localhost/127.0.0.1:41039 and proxying to /var/run/docker.sock
617:18:51.996 [ducttape-0] DEBUG org.testcontainers.dockerclient.DockerClientProviderStrategy - Pinging docker daemon...
717:18:51.997 [ducttape-1] DEBUG org.testcontainers.dockerclient.DockerClientProviderStrategy - Pinging docker daemon...
817:18:51.997 [ducttape-0] DEBUG com.github.dockerjava.core.command.AbstrDockerCmd - Cmd: org.testcontainers.dockerclient.transport.okhttp.OkHttpDockerCmdExecFactory$1@5d43d23e
917:18:51.997 [ducttape-1] DEBUG com.github.dockerjava.core.command.AbstrDockerCmd - Cmd: org.testcontainers.dockerclient.transport.okhttp.OkHttpDockerCmdExecFactory$1@7abf08d2
1017:18:52.002 [tcp-unix-proxy-accept-thread] DEBUG org.rnorth.tcpunixsocketproxy.ProxyPump - Accepting incoming connection from /127.0.0.1:41998
1117:19:01.866 [main] DEBUG org.testcontainers.dockerclient.DockerClientProviderStrategy - ProxiedUnixSocketClientProviderStrategy: failed with exception InvalidConfigurationException (ping failed). Root cause TimeoutException (null)
1217:19:01.870 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy - Could not find a valid Docker environment. Please check configuration. Attempted configurations were:
1317:19:01.872 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy - EnvironmentAndSystemPropertyClientProviderStrategy: failed with exception InvalidConfigurationException (ping failed)
1417:19:01.873 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy - EnvironmentAndSystemPropertyClientProviderStrategy: failed with exception InvalidConfigurationException (ping failed)
1517:19:01.874 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy - UnixSocketClientProviderStrategy: failed with exception InvalidConfigurationException (ping failed). Root cause LastErrorException ([111] Verbindungsaufbau abgelehnt)
1617:19:01.875 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy - ProxiedUnixSocketClientProviderStrategy: failed with exception InvalidConfigurationException (ping failed). Root cause TimeoutException (null)
1717:19:01.875 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy - As no valid configuration was found, execution cannot continue
1817:19:01.900 [main] DEBUG 🐳 [mysql:5.7.22] - mysql:5.7.22 is not in image name cache, updating...
19Mai 01, 2020 5:19:01 NACHM. org.junit.jupiter.engine.execution.JupiterEngineExecutionContext close
20SEVERE: Caught exception while closing extension context: org.junit.jupiter.engine.descriptor.MethodExtensionContext@2e6a5539
21org.testcontainers.containers.ContainerLaunchException: Container startup failed
22 at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:322)
23 at org.testcontainers.containers.GenericContainer.start(GenericContainer.java:302)
24 at org.testcontainers.junit.jupiter.TestcontainersExtension$StoreAdapter.start(TestcontainersExtension.java:173)
25 at org.testcontainers.junit.jupiter.TestcontainersExtension$StoreAdapter.access$100(TestcontainersExtension.java:160)
26 at org.testcontainers.junit.jupiter.TestcontainersExtension.lambda$null$3(TestcontainersExtension.java:50)
27 at org.junit.jupiter.engine.execution.ExtensionValuesStore.lambda$getOrComputeIfAbsent$0(ExtensionValuesStore.java:81)
28 at org.junit.jupiter.engine.execution.ExtensionValuesStore$MemoizingSupplier.get(ExtensionValuesStore.java:182)
29 at org.junit.jupiter.engine.execution.ExtensionValuesStore.closeAllStoredCloseableValues(ExtensionValuesStore.java:58)
30 at org.junit.jupiter.engine.descriptor.AbstractExtensionContext.close(AbstractExtensionContext.java:73)
31 at org.junit.jupiter.engine.execution.JupiterEngineExecutionContext.close(JupiterEngineExecutionContext.java:53)
32 at org.junit.jupiter.engine.descriptor.JupiterTestDescriptor.cleanUp(JupiterTestDescriptor.java:222)
33 at org.junit.jupiter.engine.descriptor.JupiterTestDescriptor.cleanUp(JupiterTestDescriptor.java:57)
34 at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$cleanUp$9(NodeTestTask.java:151)
35 at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
36 at org.junit.platform.engine.support.hierarchical.NodeTestTask.cleanUp(NodeTestTask.java:151)
37 at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:83)
38 at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)
39 at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
40 at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:139)
41 at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
42 at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:125)
43 at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
44 at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:123)
45 at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
46 at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:122)
47 at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:80)
48 at java.base/java.util.ArrayList.forEach(ArrayList.java:1540)
49 at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.invokeAll(SameThreadHierarchicalTestExecutorService.java:38)
50 at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$5(NodeTestTask.java:139)
51 at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
52 at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$7(NodeTestTask.java:125)
53 at org.junit.platform.engine.support.hierarchical.Node.around(Node.java:135)
54 at org.junit.platform.engine.support.hierarchical.NodeTestTask.lambda$executeRecursively$8(NodeTestTask.java:123)
55 at org.junit.platform.engine.support.hierarchical.ThrowableCollector.execute(ThrowableCollector.java:73)
56 at org.junit.platform.engine.support.hierarchical.NodeTestTask.executeRecursively(NodeTestTask.java:122)
57 at org.junit.platform.engine.support.hierarchical.NodeTestTask.execute(NodeTestTask.java:80)
58 at org.junit.platform.engine.support.hierarchical.SameThreadHierarchicalTestExecutorService.submit(SameThreadHierarchicalTestExecutorService.java:32)
59 at org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutor.execute(HierarchicalTestExecutor.java:57)
60 at org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine.execute(HierarchicalTestEngine.java:51)
61 at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:229)
62 at org.junit.platform.launcher.core.DefaultLauncher.lambda$execute$6(DefaultLauncher.java:197)
63 at org.junit.platform.launcher.core.DefaultLauncher.withInterceptedStreams(DefaultLauncher.java:211)
64 at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:191)
65 at org.junit.platform.launcher.core.DefaultLauncher.execute(DefaultLauncher.java:128)
66 at com.intellij.junit5.JUnit5IdeaTestRunner.startRunnerWithArgs(JUnit5IdeaTestRunner.java:69)
67 at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
68 at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230)
69 at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58)
70Caused by: org.testcontainers.containers.ContainerFetchException: Can't get Docker image: RemoteDockerImage(imageNameFuture=java.util.concurrent.CompletableFuture@539d019[Completed normally], imagePullPolicy=DefaultPullPolicy(), dockerClient=LazyDockerClient.INSTANCE)
71 at org.testcontainers.containers.GenericContainer.getDockerImageName(GenericContainer.java:1265)
72 at org.testcontainers.containers.GenericContainer.logger(GenericContainer.java:600)
73 at org.testcontainers.containers.GenericContainer.doStart(GenericContainer.java:311)
74 ... 47 more
75Caused by: java.lang.IllegalStateException: Previous attempts to find a Docker environment failed. Will not retry. Please see logs and check configuration
76 at org.testcontainers.dockerclient.DockerClientProviderStrategy.getFirstValidStrategy(DockerClientProviderStrategy.java:78)
77 at org.testcontainers.DockerClientFactory.client(DockerClientFactory.java:115)
78 at org.testcontainers.LazyDockerClient.getDockerClient(LazyDockerClient.java:14)
79 at org.testcontainers.LazyDockerClient.inspectImageCmd(LazyDockerClient.java:12)
80 at org.testcontainers.images.LocalImagesCache.refreshCache(LocalImagesCache.java:42)
81 at org.testcontainers.images.AbstractImagePullPolicy.shouldPull(AbstractImagePullPolicy.java:24)
82 at org.testcontainers.images.RemoteDockerImage.resolve(RemoteDockerImage.java:62)
83 at org.testcontainers.images.RemoteDockerImage.resolve(RemoteDockerImage.java:25)
84 at org.testcontainers.utility.LazyFuture.getResolvedValue(LazyFuture.java:20)
85 at org.testcontainers.utility.LazyFuture.get(LazyFuture.java:27)
86 at org.testcontainers.containers.GenericContainer.getDockerImageName(GenericContainer.java:1263)
87 ... 49 more
88
89
90
91org.testcontainers.containers.ContainerLaunchException: Container startup failed
This error message means that the Docker daemon is not running. After ensuring that Docker daemon is running, the test run is successful.
There are very many debug messages in the console output. The logging output in tests can be configured by a logback.xml
file in src/test/resources
:
1<?xml version="1.0" encoding="UTF-8" ?>
2<configuration>
3 <include resource="org/springframework/boot/logging/logback/base.xml"/>
4 <root level="info">
5 <appender-ref ref="CONSOLE" />
6 </root>
7</configuration>
Spring Boot documentation about logging recommends to use logback-spring.xml
as configuration file. But normal JUnit5 tests don't recognize it, only @SpringBootTest
annotated tests. logback.xml
is used by both kind of tests.
Testing Repository based on JPA Entity Manager
Now, we want to implement a repository based on JPA with a classic entity manager. Assume, we have following implementation with three methods, adding heroes to the database, finding heroes by search criteria and getting all heroes from the database. The entity manager is configured by Spring's application context (@PersistenceContext
is responsible for that).
1package com.github.sparsick.testcontainerspringboot.hero.universum;
2
3import ...
4
5@Repository
6public class HeroClassicJpaRepository {
7
8 @PersistenceContext
9 private EntityManager em;
10
11 @Transactional
12 public void addHero(Hero hero) {
13 em.persist(hero);
14 }
15
16 public Collection<Hero> allHeros() {
17 return em.createQuery("Select hero FROM Hero hero", Hero.class).getResultList();
18 }
19
20 public Collection<Hero> findHerosBySearchCriteria(String searchCriteria) {
21 return em.createQuery("SELECT hero FROM Hero hero " +
22 "where hero.city LIKE :searchCriteria OR " +
23 "hero.name LIKE :searchCriteria OR " +
24 "hero.universum = :searchCriteria",
25 Hero.class)
26 .setParameter("searchCriteria", searchCriteria).getResultList();
27 }
28
29}
As JPA implementation, we choose Hibernate and MySQL as database provider. We have to configure which dialect should Hibernate use.
1# src/main/resources/application.properties
2spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
In application.properties
you also configure the database connection etc.
For setting up the entity manager in a test correctly, we have to run the test with an application context, so that entity manager is configured correctly by Spring.
Spring Boot brings some test support classes. Therefore, we have to add a further test dependency to the project.
1 <dependency>
2 <groupId>org.springframework.boot</groupId>
3 <artifactId>spring-boot-starter-test</artifactId>
4 <scope>test</scope>
5 </dependency>
This starter also includes JUnit Jupiter dependency and dependencies from other test library, so you can remove these dependencies from your dependency declaration if you want to.
Now, we have everything for writing the test.
1package com.github.sparsick.testcontainerspringboot.hero.universum;
2
3import ...
4
5@SpringBootTest
6@Testcontainers
7class HeroClassicJpaRepositoryIT {
8 @Container
9 private static MySQLContainer database = new MySQLContainer();
10
11 @Autowired
12 private HeroClassicJpaRepository repositoryUnderTest;
13
14 @Test
15 void findHeroByCriteria(){
16 repositoryUnderTest.addHero(new Hero("Batman", "Gotham City", ComicUniversum.DC_COMICS));
17
18 Collection<Hero> heros = repositoryUnderTest.findHerosBySearchCriteria("Batman");
19
20 assertThat(heros).contains(new Hero("Batman", "Gotham City", ComicUniversum.DC_COMICS));
21 }
22
23 @DynamicPropertySource
24 static void databaseProperties(DynamicPropertyRegistry registry) {
25 registry.add("spring.datasource.url", database::getJdbcUrl);
26 registry.add("spring.datasource.username", database::getUsername);
27 registry.add("spring.datasource.password", database::getPassword);
28 }
29}
The test class is annotated with some annotations. The first one is @SpringBootTest
thereby the Spring application context is started during the test. The last one is @Testcontainers
. This annotation we already know from the last test. It is a JUnit5 extension that manage starting and stopping the docker container during the test. Like we see at above JDBC test, we annotate database container private static MySQLContainer database = new MySQLContainer();
with @Container
. It marks that this container should be managed by Testcontainers. Here is a little difference to above JDBC set up. Here, MySQLContainer database
is static
and in the JDBC set up it is a normal class field. Here, it has to be static because the container has to start before the application context starts, so that we have a change to pass the database connection configuration to the application context. For this, static method databaseProperties
is responsible. Here, it is important that this method is annotated by @DynamicPropertySource
. It overrides the application context configuration during the start phase. In our case, we want to override the database connection configuration with the database information that we get from database container object managed by Testcontainers. The last step is to set up the database schema in the database. Here JPA can help. It can create a database schema automatically. You have to configure it with
1# src/test/resources/application.properties
2spring.jpa.hibernate.ddl-auto=update
Now, we can inject the repository into the test (@Autowired private HeroClassicJpaRepository repositoryUnderTest
). This repository is configured by Spring and ready to test.
Testing Repository based on Spring Data JPA
Today, it is common in a Spring Boot application to use JPA in combination with Spring Data, so we rewrite our repository to use Spring Data JPA instead of plain JPA. The result is an interface that extends Spring Data's CrudRepository
, so we have all basic operation like save, delete, update find by id etc. . For searching by criteria functionality, we have to define a method with @Query
annotation that have a JPA query.
1package com.github.sparsick.testcontainerspringboot.hero.universum;
2
3import org.springframework.data.jpa.repository.Query;
4import org.springframework.data.repository.CrudRepository;
5import org.springframework.data.repository.query.Param;
6
7import java.util.List;
8
9public interface HeroSpringDataJpaRepository extends CrudRepository<Hero, Long> {
10
11 @Query("SELECT hero FROM Hero hero where hero.city LIKE :searchCriteria OR hero.name LIKE :searchCriteria OR hero.universum = :searchCriteria")
12 List<Hero> findHerosBySearchCriteria(@Param("searchCriteria") String searchCriteria);
13}
As mentioned above in classic JPA sample so also here, we have to configure which SQL dialect our chosen JPA implementation Hibernate should use and how the database schema should set up.
The same with the test configuration, again we need a test with a Spring application context to configure the repository correctly for the test. But here we don't need to start the whole application context with @SpringBootTest
. Instead, we use @DataJpaTest
. This annotation starts an application context only with beans that are needed for the persistence layer.
1package com.github.sparsick.testcontainerspringboot.hero.universum;
2
3import ...
4
5@DataJpaTest
6@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
7@Testcontainers
8class HeroSpringDataJpaRepositoryIT {
9 @Container
10 private static MySQLContainer database = new MySQLContainer();
11
12 @Autowired
13 private HeroSpringDataJpaRepository repositoryUnderTest;
14
15 @Test
16 void findHerosBySearchCriteria() {
17 repositoryUnderTest.save(new Hero("Batman", "Gotham City", ComicUniversum.DC_COMICS));
18
19 Collection<Hero> heros = repositoryUnderTest.findHerosBySearchCriteria("Batman");
20
21 assertThat(heros).hasSize(1).contains(new Hero("Batman", "Gotham City", ComicUniversum.DC_COMICS));
22 }
23
24 @DynamicPropertySource
25 static void databaseProperties(DynamicPropertyRegistry registry) {
26 registry.add("spring.datasource.url",database::getJdbcUrl);
27 registry.add("spring.datasource.username", database::getUsername);
28 registry.add("spring.datasource.password", database::getPassword);
29 }
30}
@DataJpaTest
starts an in-memory database as default. But we want that a containerized database is used, provided by Testcontainers. Therefore, we have to add the annotation @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
. This disables starting an in-memory database. The remaining test configuration is the same as the configuration in above test for the plain JPA example.
Testing Repositories but Reusing a database
With the increasing number of tests, it becomes more and more important that each test takes quite a long time, because each time a new database is started and initialized. One idea is to reuse the database in each test. Here the Single Container Pattern can help. A database is started and initialized once before all tests start running. For that, each test that need a database has to extend an abstract class, that is responsible for starting and initializing a database once before all tests run.
1package com.github.sparsick.testcontainerspringboot.hero.universum;
2
3import ...
4
5
6public abstract class DatabaseBaseTest {
7 static final MySQLContainer DATABASE = new MySQLContainer();
8
9 static {
10 DATABASE.start();
11 }
12
13 @DynamicPropertySource
14 static void databaseProperties(DynamicPropertyRegistry registry) {
15 registry.add("spring.datasource.url", DATABASE::getJdbcUrl);
16 registry.add("spring.datasource.username", DATABASE::getUsername);
17 registry.add("spring.datasource.password", DATABASE::getPassword);
18 }
19}
In this abstract class we configure the database that is started once for all tests that extend this abstract class and the application context with that database. Please note, that we don't use Testcontainers' annotations here, because this annotation takes care that the container is started and stopped after each test. But this we would avoid. Therefore, we start the database by ourselves. For stopping database we don't need to take care. For this Testcontainers' side-car container ryuk takes care.
Now, each test class, that need a database, extends this abstract class. The only thing, that we have to configure, is how the application context should be initialized. That means, when you need the whole application context then use @SpringBootTest
. When you need only persistence layer then use @DataJpaTest
with @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
.
1@DataJpaTest
2@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
3class HeroSpringDataJpaRepositoryReuseDatabaseIT extends DatabaseBaseTest {
4
5 @Autowired
6 private HeroSpringDataJpaRepository repositoryUnderTest;
7
8 @Test
9 void findHerosBySearchCriteria() {
10 repositoryUnderTest.save(new Hero("Batman", "Gotham City", ComicUniversum.DC_COMICS));
11
12 Collection<Hero> heros = repositoryUnderTest.findHerosBySearchCriteria("Batman");
13
14 assertThat(heros).contains(new Hero("Batman", "Gotham City", ComicUniversum.DC_COMICS));
15 }
16}
Testing the whole Web Application including Database
Now we want to test our whole application, from controller to database. The controller implementation looks like this:
1@RestController
2public class HeroRestController {
3
4 private final HeroSpringDataJpaRepository heroRepository;
5
6 public HeroRestController(HeroSpringDataJpaRepository heroRepository) {
7 this.heroRepository = heroRepository;
8 }
9
10 @GetMapping("heros")
11 public Iterable<Hero> allHeros(String searchCriteria) {
12 if (searchCriteria == null || searchCriteria.equals("")) {
13 return heroRepository.findAll();
14
15 }
16 return heroRepository.findHerosBySearchCriteria(searchCriteria);
17 }
18
19 @PostMapping("hero")
20 public void hero(@RequestBody Hero hero) {
21 heroRepository.save(hero);
22 }
23}
The test class that test the whole way from database to controller looks like that
1@SpringBootTest
2@AutoConfigureMockMvc
3@Testcontainers
4class HeroRestControllerIT {
5
6 @Container
7 private static MySQLContainer database = new MySQLContainer();
8
9 @Autowired
10 private MockMvc mockMvc;
11
12 @Autowired
13 private HeroSpringDataJpaRepository heroRepository;
14
15 @Test
16 void allHeros() throws Exception {
17 heroRepository.save(new Hero("Batman", "Gotham City", ComicUniversum.DC_COMICS));
18 heroRepository.save(new Hero("Superman", "Metropolis", ComicUniversum.DC_COMICS));
19
20 mockMvc.perform(get("/heros"))
21 .andExpect(status().isOk())
22 .andExpect(jsonPath("$[*].name", containsInAnyOrder("Batman", "Superman")));
23 }
24
25 @DynamicPropertySource
26 static void databaseProperties(DynamicPropertyRegistry registry) {
27 registry.add("spring.datasource.url", database::getJdbcUrl);
28 registry.add("spring.datasource.username", database::getUsername);
29 registry.add("spring.datasource.password", database::getPassword);
30 }
31}
The test set up for the database and the application is known by the test from the above sections. One thing is different. We add MockMVC support with @AutoConfigureMockMvc
. This helps to write tests through the HTTP layer.
Of course, you can also use the single container pattern in which the abstract class DatabaseBaseTest
is extended.
Conclusion and Overview
This blog post shows how we can write tests for some persistence layer implementations in Spring Boot with Testcontainers. We also see how to reuse database instance for several tests and how to write test for the whole web application from controller tor database. All code snippet can be found on GitHub. In a further blog post I will show how to write test with Testcontainers Spring Boot modules.
Do you have other ideas for writing tests for persistence layer? Please let me know and write a comment.