[Spring Boot] Configure TestContainers in your test code this way
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!