Skip to content

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:

application-test.yml
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.H2Dialect

This 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:

BaseIntegrationTest.java
@Testcontainers
@SpringBootTest
public 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:

UserRepositoryIT.java
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:

Transactional test example
@SpringBootTest
@Transactional
class 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

MetricH2Testcontainers
Initial container startup0s3.2s
Average test class time1.8s2.1s
Full suite (127 tests)42s58s

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

pom.xml
<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

BaseIntegrationTest.java
@Testcontainers
@SpringBootTest
public 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:

FlywayMigrationIT.java
@SpringBootTest
class 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:

  1. Change the test to extend BaseIntegrationTest
  2. Remove any H2-specific configuration
  3. Run the test
  4. 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

WRONG: Container recreated per test
@Container
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");

This starts a new container for each test class. Slow and resource-intensive.

CORRECT: Shared across test classes
@Container
static 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:

Problem: Data persists between tests
@Test
void shouldCreateUser() {
userRepository.save(new User("alice"));
// User still exists for next test
}
@Test
void 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

Complete Testcontainers setup
// pom.xml
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.21.2</version>
<scope>test</scope>
</dependency>
// BaseIntegrationTest.java
@Testcontainers
@SpringBootTest
public abstract class BaseIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
}
// Your test class
@Transactional
class 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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments