H2 feels convenient but it lies to you. It accepts SQL that real PostgreSQL rejects, silently ignores dialects it doesn't support, and produces false passes that become production incidents. Testcontainers runs the actual database engine in a Docker container — the same binary that runs in production, with the same SQL parser, the same JSONB behaviour, the same constraint enforcement, and the same query planner. When a test passes against a real PostgreSQL container, the database layer is genuinely verified.
Adding Testcontainers to your project
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.8</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.8</version>
<scope>test</scope>
</dependency>Testcontainers requires Docker to be running locally or in your CI environment. GitHub Actions, GitLab CI, and CircleCI all include Docker out of the box. The library communicates with the Docker daemon to pull images, start containers, and expose ports.
Complete working example with Spring Boot
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("orders_test")
.withUsername("test")
.withPassword("test")
.withInitScript("schema.sql");
@DynamicPropertySource
static void configureDatabase(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private OrderRepository orderRepository;
@Test
void shouldPersistOrderWithJsonbMetadata() {
// This test would silently pass with H2 but catches real Postgres JSONB issues
var order = new Order(42L, 100L, 2);
order.setMetadata("{\"source\": \"mobile\", \"promoCode\": \"SAVE10\"}");
Order saved = orderRepository.save(order);
Order found = orderRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getMetadata()).contains("mobile");
assertThat(found.getUserId()).isEqualTo(42L);
}
@Test
void shouldFindOrdersByUserWithWindowFunction() {
// PostgreSQL window functions — H2 would reject this SQL
List<OrderSummary> recent = orderRepository.findRecentOrdersWithRank(42L);
assertThat(recent).isNotEmpty();
assertThat(recent.get(0).getRank()).isEqualTo(1);
}
}The @DynamicPropertySource method is the bridge between Testcontainers and Spring Boot's configuration system. Testcontainers allocates a random host port for the container, and postgres::getJdbcUrl returns the correct JDBC URL with that port embedded. Spring's ApplicationContext initialises after this method runs, so the data source picks up the live container's URL. This is the correct pattern — never hardcode the JDBC URL in application-test.properties with a fixed port, because that approach breaks when two test processes run in parallel.
Container lifecycle options and their trade-offs
The placement of @Container — static versus instance — determines how many containers are created and when they're destroyed.
Static @Container field starts one container for all tests in the class. The container is created once before the first test method and destroyed after the last. This is fast: startup overhead is paid once. The trade-off is shared state — if one test inserts rows that aren't cleaned up, subsequent tests in the same class see that data. Use @Transactional on your tests or truncate tables in @AfterEach to keep tests independent.
Instance @Container field starts a fresh container for every @Test method. This gives you perfect isolation: each test starts with an empty database. The cost is significant — a container start per test means 2-4 seconds of overhead per test method. Reserve this pattern for tests where isolation is genuinely impossible to achieve through transaction rollback or truncation.
withReuse(true) keeps the container alive between mvn test runs instead of stopping it when the JVM exits. On first run you pay the 2-4 second startup cost. On every subsequent run — as long as you haven't changed the container configuration — Testcontainers detects the running container and connects to it directly. This is the best option for local development workflows where you're running tests repeatedly while writing code. Enable it in ~/.testcontainers.properties with testcontainers.reuse.enable=true.
Singleton pattern shares one container across all test classes in a suite. Define the container in a base class or a utility class, start it in a static initialiser, and have every test class reference it. This is the fastest option for large suites with many test classes, but it requires careful test data management because all classes share the same database state.
Beyond PostgreSQL — other Testcontainers modules
Testcontainers is not limited to relational databases. The same @Container pattern works for every dependency your service has.
Kafka and RabbitMQ — test event-driven services against real brokers. Your Kafka consumer and producer code runs against an actual Kafka container, which means partition assignment, consumer group rebalancing, and message serialisation are all genuinely exercised. An in-memory Kafka substitute cannot reproduce the broker-level behaviour that causes production incidents.
MongoDB, Redis, Elasticsearch — same story as PostgreSQL. The real engine enforces index constraints, applies actual TTL behaviour, and runs real query execution plans. An embedded alternative or a mock skips every one of these.
LocalStack — emulates AWS services including S3, SQS, DynamoDB, and SNS locally. If your service writes to S3 or publishes to SQS, LocalStack lets you test that code without an AWS account or real AWS bills, while still exercising the actual AWS SDK request/response cycle.
GenericContainer — takes any Docker image, including images you've built yourself. If your service calls a legacy internal API that has no Testcontainers module, package it as a Docker image and run it as a GenericContainer with health check configuration. Your test suite can spin up the actual legacy service instead of mocking it.
Test data management
Three approaches work well in practice, and the right choice depends on your isolation requirement.
@Sql annotations run SQL scripts before and after each test method. Use @Sql("insert-test-data.sql") to seed the database and @Sql(scripts = "cleanup.sql", executionPhase = AFTER_TEST_METHOD) to restore it. This makes test data visible and reviewable alongside the test code.
@Transactional on a test class causes Spring to roll back every test method's database changes automatically. This is the lowest-friction approach for repository tests. The caveat: it only works if your service code participates in the same Spring transaction. Code that opens its own connections or uses REQUIRES_NEW propagation won't roll back.
Manual truncation in @AfterEach is the explicit fallback. It always works regardless of transaction boundaries: jdbcTemplate.execute("TRUNCATE orders, order_items CASCADE"). It's more code but leaves no ambiguity about what state the database is in at the start of each test.
Step 1 of 5
Pull Docker image
Testcontainers pulls postgres:15-alpine from Docker Hub on first run. Subsequent runs use the cached image — near-instant.
Performance in practice
A PostgreSQL 15 Alpine container starts in 2-4 seconds on a modern developer machine. With withReuse(true) enabled, subsequent test runs connect to the already-running container in under 1 second. In CI, where reuse is typically disabled for isolation between builds, the 2-4 second startup is a one-time cost per build — not per test method. A suite with 50 repository tests and a static container field pays 3 seconds of startup plus the actual test execution time. The same suite running against H2 is marginally faster but produces results you cannot trust.
Node.js equivalent
If your team's services are written in Node.js or TypeScript, the testcontainers npm package provides the same API.
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
const container = await new PostgreSqlContainer('postgres:15').start();The container object exposes getHost(), getPort(), getDatabase(), and connection string helpers. Pass those values into your ORM configuration the same way @DynamicPropertySource works in Spring Boot. The lifecycle — start before tests, stop after — is managed with Jest's beforeAll and afterAll hooks.
⚠️ Common mistakes
- Using
withInitScriptfor schema and also running Flyway migrations on top of it. This creates two conflicting sources of schema truth. If your application uses Flyway or Liquibase for schema management, configure those to run against the test container — don't maintain a separateschema.sql. The test container should use the exact same migration scripts as production, or you are testing against a schema that production never has. - Putting
@Testcontainersand@SpringBootTeston the same class but not using@DynamicPropertySource. Without@DynamicPropertySource, Spring initialises its application context before the container starts and before the port is known. The data source URL inapplication-test.propertieseither points to a fixed port that may be taken, or it fails entirely. Always bridge container port to Spring config through@DynamicPropertySource. - Treating slow tests as a Testcontainers problem and switching back to H2. Container startup takes a few seconds per test class — that is the correct trade-off for genuine database fidelity. If your suite is genuinely slow, the fix is the singleton or
withReuse(true)pattern, not reverting to an in-memory database that silently accepts invalid SQL. Switching back to H2 eliminates the startup cost and reintroduces the false-pass problem.
🎯 Practice task
Migrate a repository test from H2 to Testcontainers — 45 minutes.
- Find an existing repository or DAO test that uses an H2 in-memory database. If you don't have one, create a simple
OrderRepositorytest class with an H2 datasource and one test that inserts and retrieves a record. - Add the Testcontainers dependencies and replace the H2 datasource with a
PostgreSQLContainer. Add@Testcontainersto the test class and add@DynamicPropertySourceto wire the container's JDBC URL into Spring's configuration. Run the tests — they should pass against the real engine. - Write a test that H2 would have falsely passed. Add a
JSONBcolumn to your schema and write a test that stores JSON and queries it using the PostgreSQL->JSON path operator. Temporarily switch back to H2 and confirm the test either fails or the schema itself fails to create. Switch back to Testcontainers and confirm the test passes correctly. - Experiment with
withReuse(true). Enabletestcontainers.reuse.enable=truein~/.testcontainers.properties. Run the test suite twice. Measure the startup time on the first run and the startup time on the second run. Observe the difference. - Add a second module. Replace one of your service's mocked dependencies — perhaps a Redis cache or a Kafka topic — with a real Testcontainers container. Write a test that exercises the interaction. Compare the confidence level of this test against the mock-based version it replaces.
Next lesson: contract testing with Pact — how to verify API compatibility between services before deployment, without running both services together.