In-memory databases like H2 are fast and convenient but they hide real-world bugs — dialect differences, missing features, and different constraint handling mean tests pass on H2 but fail against your actual PostgreSQL or MySQL database. Testcontainers solves this by spinning up a real database (or any other service) in a Docker container for your tests. This guide shows you how to integrate Testcontainers with JUnit 6 for truly reliable database integration tests.
Prerequisites
- Docker installed and running on the test machine (or CI agent)
- Java 11+, JUnit 6, Maven or Gradle
- Internet access on first run (to pull Docker images)
Dependencies
<!-- Testcontainers BOM: manages all module versions -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.20.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Core Testcontainers + JUnit 5/6 integration -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- PostgreSQL module — swap for mysql, mariadb, mongodb, etc. -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<!-- PostgreSQL JDBC driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
Basic: Starting a PostgreSQL Container Per Test Class
import org.junit.jupiter.api.*;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.*;
import static org.junit.jupiter.api.Assertions.*;
// @Testcontainers: activates Testcontainers JUnit 6 extension
// It starts @Container fields before the tests and stops them after
@Testcontainers
@DisplayName("User Repository — PostgreSQL integration tests")
class UserRepositoryPostgresTest {
// @Container: Testcontainers manages this container's lifecycle
// 'static' = shared across ALL tests in this class (faster — starts once)
// non-static = restarted before each test (slower but fully isolated)
@Container
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass")
.withInitScript("db/init-schema.sql"); // runs SQL on container start
private Connection connection;
private UserRepository userRepository;
@BeforeEach
void setUp() throws Exception {
// Connect to the container using its dynamic port
connection = DriverManager.getConnection(
postgres.getJdbcUrl(), // e.g. jdbc:postgresql://localhost:54321/testdb
postgres.getUsername(),
postgres.getPassword()
);
userRepository = new UserRepository(connection);
}
@AfterEach
void tearDown() throws Exception {
// Clean up test data between tests
try (Statement stmt = connection.createStatement()) {
stmt.execute("DELETE FROM users");
}
connection.close();
}
@Test
@DisplayName("Saving a user persists it to PostgreSQL")
void savingUserPersistsToPostgres() throws Exception {
User user = new User(null, "[email protected]", "Alice");
User saved = userRepository.save(user);
assertNotNull(saved.getId(), "Saved user must have a generated ID");
assertEquals("[email protected]", saved.getEmail());
}
@Test
@DisplayName("Finding a user by email returns the correct record")
void findByEmailReturnsCorrectUser() throws Exception {
userRepository.save(new User(null, "[email protected]", "Bob"));
Optional<User> found = userRepository.findByEmail("[email protected]");
assertTrue(found.isPresent());
assertEquals("Bob", found.get().getName());
}
@Test
@DisplayName("Unique email constraint prevents duplicate users")
void uniqueEmailConstraintPreventsduplicates() {
userRepository.save(new User(null, "[email protected]", "Carol"));
// Second save with same email should throw a constraint violation
assertThrows(DataIntegrityViolationException.class,
() -> userRepository.save(new User(null, "[email protected]", "Carol2")),
"Duplicate email must be rejected by the database constraint"
);
}
}
Spring Boot Integration: @DynamicPropertySource
Connect Testcontainers to your Spring Boot @DataJpaTest or @SpringBootTest by providing the container’s dynamic JDBC URL to Spring’s environment:
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
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;
@DataJpaTest
@Testcontainers
@DisplayName("OrderRepository — real PostgreSQL via Testcontainers")
class OrderRepositoryTestcontainersTest {
@Container
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("orders-test")
.withUsername("orders")
.withPassword("orders-pass");
// @DynamicPropertySource: bridges container's dynamic port to Spring's datasource config
// Called by Spring BEFORE the ApplicationContext is created
@DynamicPropertySource
static void configureSpringDatasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
// Tell Spring/Hibernate to use PostgreSQL dialect
registry.add("spring.jpa.database-platform",
() -> "org.hibernate.dialect.PostgreSQLDialect");
}
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestEntityManager entityManager;
@Test
@DisplayName("Custom JPQL query returns correct results against real PostgreSQL")
void customQueryReturnsCorrectResults() {
entityManager.persistAndFlush(new Order(null, "[email protected]", 50.00, OrderStatus.PENDING));
entityManager.persistAndFlush(new Order(null, "[email protected]", 100.00, OrderStatus.COMPLETED));
List<Order> pendingOrders = orderRepository.findByStatus(OrderStatus.PENDING);
assertEquals(1, pendingOrders.size());
assertEquals(50.00, pendingOrders.get(0).getTotal(), 0.001);
}
}
Reusable Container: Singleton Pattern for Faster Tests
Starting a new Docker container for every test class is slow. Share one container across all tests using the singleton pattern:
/**
* AbstractPostgresTest: base class that shares ONE PostgreSQL container
* across all test classes that extend it.
* Container starts once for the entire test suite run.
*/
public abstract class AbstractPostgresTest {
// static final: JVM-level singleton — shared across all subclasses
static final PostgreSQLContainer<?> POSTGRES;
static {
// Start container once when the class is first loaded
POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("shared-test-db")
.withUsername("test")
.withPassword("test");
POSTGRES.start();
}
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}
// Any test class extends AbstractPostgresTest to reuse the shared container
@DataJpaTest
class UserRepositoryTest extends AbstractPostgresTest {
@Test
void findByEmailWorks() { /* uses POSTGRES container */ }
}
@DataJpaTest
class OrderRepositoryTest extends AbstractPostgresTest {
@Test
void findByStatusWorks() { /* reuses the same POSTGRES container */ }
}
Testcontainers for Other Services
| Service | Container Class | Image Example |
|---|---|---|
| PostgreSQL | PostgreSQLContainer | postgres:16-alpine |
| MySQL | MySQLContainer | mysql:8.0 |
| MongoDB | MongoDBContainer | mongo:7.0 |
| Redis | GenericContainer | redis:7-alpine |
| Kafka | KafkaContainer | confluentinc/cp-kafka:7.6.0 |
| Elasticsearch | ElasticsearchContainer | elasticsearch:8.13.0 |
| Any image | GenericContainer | Any Docker Hub image |
Frequently Asked Questions (FAQs)
Q1: Does Testcontainers work in CI/CD pipelines?
Yes. Testcontainers works in any CI environment that has Docker available — GitHub Actions, GitLab CI, Jenkins, CircleCI, and others. GitHub Actions runners have Docker installed by default. For Jenkins, ensure the build agent has Docker and the Jenkins user has Docker socket access. Some cloud CI services offer Docker-in-Docker (DinD) which also works with Testcontainers. See Running JUnit 6 Tests in CI/CD Pipelines for CI-specific configuration.
Q2: How slow is Testcontainers compared to H2?
A first container pull takes 30–60 seconds. After that, Docker caches the image and subsequent starts take 2–5 seconds per container. Using the singleton pattern (one container per test suite) means you pay this cost once per build — the same as H2’s warm-up. For a typical project, the overhead is 5–10 seconds per suite run, which is a very worthwhile trade-off for the reliability gained.
Q3: How do I load SQL schema and data into the Testcontainers database?
Use .withInitScript("db/schema.sql") on the container to run a SQL file at startup. For Spring Boot projects with Flyway or Liquibase, Spring’s migration tools run automatically when the context starts — no manual schema loading needed. For complex test data, use @BeforeEach with the TestEntityManager or plain JDBC insert statements.
Q4: Can I use Testcontainers without Spring Boot?
Yes. Testcontainers is a standalone library with no Spring dependency. You can use it in any Java project by managing the JDBC connection manually — as shown in the basic example at the top of this guide. The @Container and @Testcontainers annotations work with any JUnit 6 test class, regardless of whether Spring is present.
Q5: How do I test database migrations with Testcontainers?
Create a test that starts the Testcontainers database, runs your Flyway or Liquibase migrations explicitly, and then verifies the expected schema state. This catches migration errors before they reach production. With Spring Boot, @DataJpaTest automatically runs Flyway/Liquibase migrations when a real datasource is configured, making migration testing straightforward.
See Also
- JUnit 6 with Spring Boot: Unit, Slice, and Integration Testing
- Database Testing in JUnit 6: H2 vs Real DB vs Containers
- Running JUnit 6 Tests in CI/CD Pipelines
- JUnit 6 Extensions Model: Build Custom Extensions
- JUnit 6 Tutorial: Complete Series Index
Conclusion
Testcontainers eliminates the biggest source of false confidence in database integration tests: the assumption that H2 behaves like your production database. With a few lines of configuration, you get a real PostgreSQL, MySQL, or MongoDB instance running in Docker, managed automatically by JUnit 6’s lifecycle. Use the singleton pattern for speed, @DynamicPropertySource for Spring Boot integration, and Testcontainers in your CI pipeline from day one.
Next: Database Testing in JUnit 6: H2 vs Real DB vs Containers — a head-to-head comparison of all three approaches to help you choose the right strategy for each scenario.