How to Choose Between H2 and Testcontainers for Spring Boot Integration Tests
Our integration tests passed. Every single one. Green across the board in CI. Then we deployed to production and Flyway migrations failed.
The error message was clear: a constraint we’d tested extensively in H2 behaved differently in PostgreSQL. We’d spent weeks building features, confident our test suite had us covered. But H2’s PostgreSQL compatibility mode hadn’t actually tested PostgreSQL behavior.
That’s when I started looking at Testcontainers. Here’s what I learned about why H2 gave us false confidence and how Testcontainers solved the problem.
The Problem with H2
H2 is an in-memory database. It’s fast—it starts instantly and requires no external dependencies. For simple unit tests, it works fine. But when your production database is PostgreSQL, H2 creates a dangerous gap.
H2 offers a PostgreSQL compatibility mode. You configure it like this:
spring: datasource: url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH driver-class-name: org.h2.Driver jpa: database-platform: org.hibernate.dialect.H2DialectThis looks reasonable. The MODE=PostgreSQL setting tells H2 to emulate PostgreSQL behavior. But emulation is not the same as being PostgreSQL.
What H2’s PostgreSQL Mode Doesn’t Handle
I discovered these differences the hard way:
Flyway migrations: We had a migration using PostgreSQL-specific index syntax. H2 accepted it. PostgreSQL rejected it in production.
Constraint behavior: A unique constraint on a nullable column worked one way in H2 and differently in PostgreSQL. Our tests never caught this.
JSONB operations: We stored JSON data and queried it using PostgreSQL’s JSONB operators. H2 has no equivalent, so we skipped testing those queries entirely.
Array types: PostgreSQL handles arrays natively. H2 doesn’t. Our repository methods using array containment operators were untested.
Datetime functions: EXTRACT(EPOCH FROM timestamp) behaves subtly differently. Our date range queries worked in tests but returned wrong results in production.
Each difference was a production bug waiting to happen. Our test suite gave us confidence we hadn’t earned.
What Testcontainers Does Differently
Testcontainers spins up an actual PostgreSQL database in a Docker container for your tests. Not an emulation—an actual PostgreSQL instance.
Here’s the basic setup:
@Testcontainers@SpringBootTestpublic abstract class BaseIntegrationTest {
@Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine") .withDatabaseName("testdb") .withUsername("testuser") .withPassword("testpass");}The @ServiceConnection annotation is the key. Spring Boot 3.x+ uses it to automatically configure your datasource to connect to the container. No manual port management, no hardcoded URLs.
Your tests then extend this base class:
class UserRepositoryIT extends BaseIntegrationTest {
@Autowired private UserRepository userRepository;
@Test void shouldCreateUserWithJsonbData() { User user = new User(); user.setMetadata("{\"preferences\": {\"theme\": \"dark\"}}");
User saved = userRepository.save(user);
assertThat(saved.getId()).isNotNull(); // This query uses PostgreSQL JSONB operators assertThat(userRepository.findByMetadataKey("preferences.theme", "dark")) .contains(saved); }}This test runs against real PostgreSQL. If the JSONB query syntax is wrong, the test fails—before production.
Performance: The Common Objection
When I proposed Testcontainers to my team, the first concern was performance. “Container startup will slow down our tests.”
Here’s what I found:
H2 startup: Instant (in-memory, no Docker required)
Testcontainers startup: 2-5 seconds for the initial container
But that 2-5 seconds happens once per test suite, not per test. The static modifier on the container field means it’s shared across all test classes that extend BaseIntegrationTest.
We also added @Transactional to our tests:
@SpringBootTest@Transactionalclass OrderServiceIT extends BaseIntegrationTest {
@Autowired private OrderService orderService;
@Test void shouldProcessOrder() { orderService.processOrder(testOrder); // Transaction rolls back after test // Database stays clean }}Each test runs in a transaction that rolls back afterward. The container stays running, but the data resets. This kept our test suite fast enough.
Real Numbers from Our Project
| Metric | H2 | Testcontainers |
|---|---|---|
| Initial container startup | 0s | 3.2s |
| Average test class time | 1.8s | 2.1s |
| Full suite (127 tests) | 42s | 58s |
Yes, Testcontainers added 16 seconds to our full suite. But those 16 seconds caught three bugs that would have cost hours in production debugging.
Migrating from H2 to Testcontainers
I migrated our project incrementally. Here’s the approach:
Step 1: Add Dependencies
<dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <version>1.21.2</version> <scope>test</scope></dependency><dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-testcontainers</artifactId> <scope>test</scope></dependency>Step 2: Create the Base Test Class
@Testcontainers@SpringBootTestpublic abstract class BaseIntegrationTest {
@Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
static { // Ensure Docker is available PostgreSQLContainer<?> container = postgres; container.start(); }}Step 3: Start with Critical Tests
Don’t rewrite everything. Start with tests that:
- Use PostgreSQL-specific features (JSONB, arrays, full-text search)
- Run Flyway migrations
- Test complex queries with multiple joins
Our first migration was the Flyway test:
@SpringBootTestclass FlywayMigrationIT extends BaseIntegrationTest {
@Autowired private Flyway flyway;
@Test void migrationsShouldApplyCleanly() { MigrationInfoService info = flyway.info(); assertThat(info.pending()).isNotEmpty();
flyway.migrate();
assertThat(flyway.info().current().getState()) .isEqualTo(MigrationState.SUCCESS); }}This test caught a migration issue immediately—the same issue that had failed in production before.
Step 4: Gradually Expand
We migrated test by test, starting with the ones that mattered most. Each migration followed this pattern:
- Change the test to extend
BaseIntegrationTest - Remove any H2-specific configuration
- Run the test
- Fix any issues that appear
Some tests failed initially. That was the point—they revealed differences between H2 and PostgreSQL we hadn’t known about.
When H2 Still Makes Sense
I don’t want to oversell Testcontainers. H2 has its place:
Unit tests: Tests that don’t involve database-specific features. If you’re just testing repository method names (findByEmail, countByStatus), H2 is fine.
Prototypes and MVPs: When speed matters more than accuracy. Getting something working quickly, then migrating to Testcontainers later.
Non-PostgreSQL projects: If your production database is H2 itself, or MySQL with no PostgreSQL-specific features, the gap is smaller.
No Docker available: Some CI environments don’t have Docker. Testcontainers requires Docker.
Common Mistakes I Made
Mistake 1: Not Using static on the Container
@ContainerPostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");This starts a new container for each test class. Slow and resource-intensive.
@Containerstatic PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");The static modifier shares the container across all tests.
Mistake 2: Forgetting Docker Requirements
Testcontainers needs Docker. Our CI pipeline didn’t have Docker enabled initially. The tests failed with cryptic errors about container startup.
The fix was simple—enable Docker in CI—but the debugging took time.
Mistake 3: Not Cleaning Data Between Tests
Without @Transactional, tests polluted each other’s data:
@Testvoid shouldCreateUser() { userRepository.save(new User("alice")); // User still exists for next test}
@Testvoid shouldCountUsers() { // Fails: "alice" from previous test is still there assertThat(userRepository.count()).isEqualTo(0);}Adding @Transactional fixed this automatically.
The Decision in 2026
If you’re building a Spring Boot application that uses PostgreSQL in production, Testcontainers is the right choice. The performance cost is minimal—measured in seconds per test suite. The confidence gain is substantial—your tests actually validate what will happen in production.
H2’s PostgreSQL compatibility mode is a helpful abstraction for simple cases. But abstractions leak. The moment you use a PostgreSQL-specific feature—a JSONB query, an array containment operator, a partial index—you’ve stepped outside what H2 can emulate.
Testcontainers doesn’t have this problem because it’s not emulating. It’s running the real thing.
Quick Reference
// pom.xml<dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <version>1.21.2</version> <scope>test</scope></dependency>
// BaseIntegrationTest.java@Testcontainers@SpringBootTestpublic abstract class BaseIntegrationTest { @Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");}
// Your test class@Transactionalclass MyRepositoryIT extends BaseIntegrationTest { @Autowired private MyRepository repository;
@Test void testSomething() { // Test against real PostgreSQL }}The setup is simple. The payoff is catching real issues before they reach production.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Testcontainers Official Documentation
- 👨💻 Spring Boot Testcontainers Integration
- 👨💻 H2 PostgreSQL Compatibility Mode
- 👨💻 Testcontainers PostgreSQL Module
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments