[Spring Boot] Configure TestContainers in your test code this way

Jay Kim
4 min readJun 7, 2023

--

Embedded H2 database is an excellent stepping stone for writing test code against repository module when starting a new project. In Spring Boot, with @DataJpaTest, I don’t have to worry about the connection information and Data Definition Language (DDL) because it will scan your entities and create tables for you.

It is all good until you want to integrate with the database solution that you want. Let’s say it is postgres. At this point, I imagine that you might want to run test against postgres rather than H2 for your unit tests or integration tests.

TestContainers offers a very nice solution where you could run a throw away database as a Docker container when running tests. I have used it before for testing against RabbitMQ (link).

In this article, let’s find out how to make use of TestContainers for Spring Boot test and possibly how to make configuration cleaner for test code. I will use postgres as a sample.

Prior to Spring Boot 3.1

Dependency

First of all, we need to add the following dependencies for test.

dependencies {
// other spring boot dependencies
testImplementation 'org.testcontainers:postgresql'
testImplementation 'org.testcontainers:junit-jupiter'
}

Configuring TestContainers for postgres

Then, we have to run a postgres container and configure datasource properties to the running container.

package io.jay.app;

import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
public class PostgresTestContainerInitializer {

@Container
static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer("postgres:15")
.withDatabaseName("unit-test")
.withUsername("test")
.withPassword("test");

@DynamicPropertySource
static void initialize(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgreSQLContainer::getJdbcUrl);
registry.add("spring.datasource.username", postgreSQLContainer::getUsername);
registry.add("spring.datasource.password", postgreSQLContainer::getPassword);
}
}

Lastly, the test class could extend the initializer class we implemented above. You could have all TestContainers setup and datasource configuration in the test file but then the test code will be hard to read once you start injecting some beans or test doubles.

Writing Test Code

package io.jay.app;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class MainApplicationTests extends PostgresTestContainerInitializer {

@Test
void contextLoads() {

}
}

However, I don’t like seeing the extends for this test class. Let’s see if there is an another way of achieving the same behavior.

Refactoring

There is an alternative way for setting up a test container and configuring application properties. Let’s first create a new class like below:

package io.jay.app;

import org.springframework.boot.test.util.TestPropertyValues;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
import org.testcontainers.containers.PostgreSQLContainer;

public class TestContainerInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext> {

PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer("postgres:15")
.withDatabaseName("unit-test")
.withUsername("test")
.withPassword("test");

@Override
public void initialize(ConfigurableApplicationContext ctx) {
postgreSQLContainer.start();
TestPropertyValues.of(
"spring.datasource.url=" + postgreSQLContainer.getJdbcUrl(),
"spring.datasource.username=" + postgreSQLContainer.getUsername(),
"spring.datasource.password=" + postgreSQLContainer.getPassword()
).applyTo(ctx.getEnvironment());
}
}

Then, create a custom annotation. I named it ContainerTest but if you have a better clearer name then please go ahead with it.

package io.jay.app;

import org.springframework.test.context.ContextConfiguration;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ContextConfiguration(initializers = TestContainerInitializer.class)
public @interface ContainerTest {
}

Finally, annotate the test class with the custom annotation we implemented above. Maybe this way is more declarative and it is easier to tell that this test code is running against a test container.

package io.jay.app;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
@ContainerTest
class MainApplicationTests {

@Test
void contextLoads() {

}
}

It is all up to your preference so do what works best for you.

From Spring Boot 3.1 and above

Dependency

From Spring Boot 3.1, it becomes easier to connect your test code with TestContainers. Make sure to add spring-boot-testcontainers dependency.

dependencies {
// other spring boot dependencies
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:postgresql'
testImplementation 'org.testcontainers:junit-jupiter'
}

Test Configuration

Before we had to modify application properties in order to connect to the postgres instance launched by TestContainers. Fortunately, this process became simpler with @ServiceConnection annotation.

package io.jay.app.initializer;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;

@TestConfiguration
public class TestContainerConfiguration {

@Bean
@ServiceConnection
public PostgreSQLContainer<?> postgreSQLContainer() {
return new PostgreSQLContainer<>("postgres:15");
}
}

Test Code

Lastly, the test configuration class implemented above can be imported using @Import.

package io.jay.app;

import io.jay.app.initializer.TestContainerConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;


@SpringBootTest
@Import(TestContainerConfiguration.class)
class MainApplicationTests {

@Test
void contextLoads() {

}
}

However, @ServiceConnection does not work well with slice tests such as @DataJpaTest yet. Looks like there will be a fix soon. Refer to the github issue here.

Tip for @DataJpaTest

Make sure to add @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE).

package io.jay.app;

import io.jay.app.initializer.ContainerTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.ActiveProfiles;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

@DataJpaTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ContainerTest
public class TeamRepositoryTests {

@Autowired
TeamRepository teamRepository;

@Autowired
MemberRepository memberRepository;


@Test
void test_teamAndMembers() {
var team = new TeamEntity("Labs");

var firstMember = MemberEntity.builder()
.name("Jay")
.email("jay@labs.com")
.team(team)
.build();
var secondMember = MemberEntity.builder()
.name("Joel")
.email("joel@labs.com")
.team(team)
.build();
var thirdMember = MemberEntity.builder()
.name("Junhyunny")
.email("junhyunny@labs.com")
.team(team)
.build();
var fourthMember = MemberEntity.builder()
.name("Steve")
.email("steve@labs.com")
.team(team)
.build();

team.setMembers(List.of(firstMember, secondMember, thirdMember, fourthMember));


var saved = teamRepository.save(team);


var fetchedTeam = teamRepository.findById(saved.getId()).get();


assertEquals("Labs", fetchedTeam.getName());
assertEquals(4, fetchedTeam.getMembers().size());
assertEquals("Jay", fetchedTeam.getMembers().get(0).getName());
assertEquals("Joel", fetchedTeam.getMembers().get(1).getName());
assertEquals("Junhyunny", fetchedTeam.getMembers().get(2).getName());
assertEquals("Steve", fetchedTeam.getMembers().get(3).getName());
}
}

Hopefully, we can utilize @ServiceConnection for slice tests soon!

--

--