Spring Boot Testcontainers integration testing: what to test with real PostgreSQL, Kafka, and Redis

TL;DR: Testcontainers changed Spring Boot integration testing by replacing in-memory databases like H2 with real Docker containers. But starting a container is only part of the work. Seeding it with realistic data is still difficult. This post explores when to use Testcontainers, when to use pure trace replay, and how BitDive combines both to give you real databases auto-seeded with production data.
The end of the H2 era
For years, the standard advice for Spring Boot database testing was to use an in-memory H2 database. It was fast and required no setup.
But H2 is not PostgreSQL. It doesn't support the same JSONB dialects, window functions, or indexing behaviors. When your @DataJpaTest passed in H2, it only proved that your JPQL was syntactically valid for H2. It did not prove your code would work against production PostgreSQL.
Testcontainers solved this. By starting disposable Docker containers during tests, your integration tests run against the same infrastructure as production.
When you need Testcontainers
You should use real database or message broker containers for tests that evaluate infrastructure constraints or schema drift.
1. Complex SQL and dialect-specific features
If your repository uses native queries, JSONB operations, or advanced JPA criteria building, mocking the repository or using H2 is risky. Testcontainers ensures your query executes against a real query planner.
2. Database migrations (Flyway or Liquibase)
An integration test that boots a PostgreSQL container and runs migrations before the test executes is the only way to prove your application will start up successfully in the next deployment.
3. Transaction isolation and concurrency
If you are testing serializable isolation or pessimistic locking behavior, a real database is required. Mocks and replays cannot simulate lock contention.
The Testcontainers data bottleneck
If Testcontainers is effective, why don't teams use it for every test?
The answer is the data problem.
A fresh PostgreSQL container is empty. To test an OrderService, you must first write a lot of builder code or SQL scripts to insert a User, a Tenant, a Product, and an AccountBalance.
@Test
void testOrderProcessing() {
// Significant setup is required to get the database
// into a state where the test won't crash.
User u = userRepository.save(new User("Alice"));
Product p = productRepository.save(new Product("Laptop", 1000));
Inventory i = inventoryRepository.save(new Inventory(p.getId(), 5));
OrderResult result = orderService.process(u.getId(), p.getId());
assertThat(result.isSuccess()).isTrue();
}
This makes tests slow to write and hard to maintain. When the User schema adds a mandatory field, many test setup scripts break.
Hybrid trace-based testing
You have two choices for Spring Boot integration testing:
Option A: Pure replay
You don't need a database container. BitDive intercepts the JDBC calls and replays the exact ResultSet captured from a real environment. This is fast and requires zero setup, but it doesn't test the schema itself.
Option B: Testcontainers mode with automated seeding What if you want to use a real PostgreSQL container but don't want to write the setup data?
Through the Autonomous Verification Layer, BitDive solves the Testcontainers data bottleneck.
When you capture a trace of your application, BitDive records the state of the data involved. When you run the resulting JUnit test in testcontainers mode:
- BitDive starts the Testcontainer.
- BitDive extracts the required rows from the captured trace and seeds the container.
- Your test runs against a real database, populated with real data, with no manual setup scripts.
Example: BitDive testcontainers mode
@Test
@BitDiveReplay(scenarioId = "checkout-v1", mode = ReplayMode.TESTCONTAINERS)
void testOrderProcessing_WithRealDB() {
// 1. BitDive starts PostgreSQL container.
// 2. BitDive seeds the tables based on the recorded production trace.
// 3. BitDive stubs the outbound external HTTP calls.
orderService.process(new OrderRequest());
}
Summary
- Use Testcontainers when you need to verify migrations, complex SQL dialects, and locking behavior.
- Use trace-based testing to capture data states from running environments and automatically inject them into your test containers.
By combining Testcontainers with real runtime context, you get high fidelity integration testing without the maintenance cost of manual test data.
