Spring Boot 8 min read

Testcontainers: Why Your Integration Tests Have Been Lying to You

Stop using H2 for tests. Learn how Testcontainers eliminates the 'works on my machine' problem by running real databases in Docker during tests.

MR

Moshiour Rahman

Advertisement

The Problem Nobody Talks About

You’ve written beautiful integration tests. They pass locally. They pass in CI. You deploy to production and… the query fails.

Why? Because you tested against H2, but production runs PostgreSQL.

-- Works in H2
SELECT * FROM users WHERE name LIKE '%john%'

-- Fails in PostgreSQL (case-sensitive by default)
-- Returns nothing when data is "John"

I’ve seen this exact bug ship to production three times in my career. Each time, the team had “100% test coverage.”

This is the dirty secret of integration testing: your tests are only as good as the environment they run against.

The Real Cost of Fake Databases

The H2 Illusion

H2 behaves differently than PostgreSQL in subtle ways: case sensitivity, connection handling, type coercion. Tests pass locally, production fails.

Enter Testcontainers: Test Against the Real Thing

Testcontainers spins up actual Docker containers during your tests. Your PostgreSQL test runs against real PostgreSQL. Your Redis test hits real Redis. Your Kafka test uses actual Kafka.

Testcontainers Approach

Benefits: identical to production, isolated per test, no manual setup, works in CI without pre-configured database servers.

Setup: Spring Boot 3 + Testcontainers

Dependencies

<dependencies>
    <!-- Spring Boot -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <!-- Testing -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

The Modern Way: @ServiceConnection (Spring Boot 3.1+)

Spring Boot 3.1 introduced @ServiceConnection - it automatically configures your datasource from the container. No manual property wiring needed.

@SpringBootTest
@Testcontainers
class UserRepositoryTest {

    @Container
    @ServiceConnection  // Magic! Auto-configures spring.datasource.*
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldFindUserByCaseInsensitiveSearch() {
        // This test runs against REAL PostgreSQL
        userRepository.save(new User("John Doe", "john@example.com"));

        // This would pass in H2 but fail in PostgreSQL without proper handling
        List<User> users = userRepository.findByNameContainingIgnoreCase("john");

        assertThat(users).hasSize(1);
    }
}

Real-World Pattern: Testing PostgreSQL-Specific Features

Here’s where Testcontainers shines - testing features that don’t exist in H2.

Testing JSONB Queries

PostgreSQL’s JSONB is powerful, but H2 doesn’t support it. With Testcontainers, test the real thing:

@Entity
@Table(name = "products")
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Column(columnDefinition = "jsonb")
    private String metadata;  // {"color": "red", "size": "L", "tags": ["sale", "new"]}
}
public interface ProductRepository extends JpaRepository<Product, Long> {

    // PostgreSQL-specific JSONB query
    @Query(value = """
        SELECT * FROM products
        WHERE metadata->>'color' = :color
        """, nativeQuery = true)
    List<Product> findByColor(@Param("color") String color);

    // Query JSONB array
    @Query(value = """
        SELECT * FROM products
        WHERE metadata->'tags' ? :tag
        """, nativeQuery = true)
    List<Product> findByTag(@Param("tag") String tag);
}
@SpringBootTest
@Testcontainers
class ProductRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private ProductRepository productRepository;

    @Test
    void shouldQueryJsonbColor() {
        Product product = new Product();
        product.setName("T-Shirt");
        product.setMetadata("""
            {"color": "red", "size": "L", "tags": ["sale", "new"]}
            """);
        productRepository.save(product);

        List<Product> redProducts = productRepository.findByColor("red");

        assertThat(redProducts).hasSize(1);
        assertThat(redProducts.get(0).getName()).isEqualTo("T-Shirt");
    }

    @Test
    void shouldQueryJsonbArrayContains() {
        // ... setup
        List<Product> saleItems = productRepository.findByTag("sale");
        assertThat(saleItems).hasSize(1);
    }
}

This test would be impossible with H2. You’d either skip it or mock it - both leave production bugs undiscovered.

Pattern: Shared Container for Speed

Starting a container per test class is slow (~2-5 seconds per container). For large test suites, share containers:

/**
 * Singleton containers - started once, reused across all tests.
 *
 * Trade-off: Tests must handle data cleanup themselves.
 * Benefit: Suite runs in 30 seconds instead of 5 minutes.
 */
public abstract class AbstractIntegrationTest {

    static final PostgreSQLContainer<?> POSTGRES;
    static final GenericContainer<?> REDIS;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
            .withReuse(true);  // Reuse across test runs (requires ~/.testcontainers.properties)

        REDIS = new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379)
            .withReuse(true);

        // Start all containers in parallel
        Startables.deepStart(POSTGRES, REDIS).join();
    }

    @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);
        registry.add("spring.data.redis.host", REDIS::getHost);
        registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379));
    }
}
// All tests extend this - containers shared
class UserServiceTest extends AbstractIntegrationTest {
    @AfterEach
    void cleanup() {
        userRepository.deleteAll();  // Clean your own data
    }
}

class OrderServiceTest extends AbstractIntegrationTest {
    @AfterEach
    void cleanup() {
        orderRepository.deleteAll();
    }
}

Choosing between container per test vs shared singleton impacts both speed and isolation:

Container Lifecycle Strategies

Recommendation: Use container per class for <20 tests, shared singleton for larger suites. In CI, always use shared (time = money).

Testing Multiple Services: The Real Architecture

Modern apps don’t just use a database. They use databases, caches, message queues, and external services.

@SpringBootTest
@Testcontainers
class OrderProcessingIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Container
    @ServiceConnection
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);

    @Container
    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.5.0")
    );

    @Autowired
    private OrderService orderService;

    @Autowired
    private KafkaTemplate<String, OrderEvent> kafkaTemplate;

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Test
    void shouldProcessOrderEndToEnd() {
        // 1. Create order (stored in PostgreSQL)
        Order order = orderService.createOrder(new CreateOrderRequest(
            "customer-123",
            List.of(new OrderItem("PROD-1", 2))
        ));

        // 2. Verify event published to Kafka
        ConsumerRecord<String, OrderEvent> event = KafkaTestUtils.getSingleRecord(
            consumer, "order-events"
        );
        assertThat(event.value().orderId()).isEqualTo(order.getId());

        // 3. Verify cache populated in Redis
        String cachedOrder = redisTemplate.opsForValue().get("order:" + order.getId());
        assertThat(cachedOrder).isNotNull();
    }
}

Testing Database Migrations

One of the most underrated uses of Testcontainers: verifying your Flyway/Liquibase migrations work against real databases.

@Testcontainers
class DatabaseMigrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Test
    void shouldApplyAllMigrationsSuccessfully() {
        Flyway flyway = Flyway.configure()
            .dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())
            .locations("classpath:db/migration")
            .load();

        // This will fail if any migration has PostgreSQL-specific syntax errors
        flyway.migrate();

        MigrationInfo[] applied = flyway.info().applied();
        assertThat(applied).hasSizeGreaterThan(0);

        // Verify specific migration outcomes
        try (Connection conn = DriverManager.getConnection(
                postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {

            // Check that indexes exist
            ResultSet rs = conn.getMetaData().getIndexInfo(null, null, "users", false, false);
            // ... verify expected indexes
        }
    }
}

Wait Strategies: Don’t Race Your Container

Containers take time to become “ready.” The default wait strategy might not be enough.

// Bad: Container started but database not ready
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
// Default waits for port, but PostgreSQL might still be initializing

// Good: Wait for specific log message
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*\\n", 2));

// Better: Wait for actual database connectivity
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .waitingFor(Wait.forSuccessfulCommand("pg_isready -U test"));

Choose the right wait strategy for each service:

Wait Strategies

Service recommendations: PostgreSQL uses forLogMessage, Redis uses forListeningPort, Elasticsearch uses forHttp.

CI/CD: Making It Work in GitHub Actions

name: Integration Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'

      # Docker is pre-installed on GitHub runners
      # Testcontainers will use it automatically

      - name: Run tests
        run: ./mvnw verify
        env:
          # Speed up container pulls
          TESTCONTAINERS_RYUK_DISABLED: true  # GitHub cleans up containers anyway

The Debugging Toolkit

See What’s Happening Inside

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("postgres")));

Keep Container Running After Test Failure

// In test class
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine") {
    @Override
    public void stop() {
        // Don't stop - keep running for debugging
        // Remember to manually stop later!
    }
};

Connect to Container Manually

@Test
void debugTest() {
    System.out.println("JDBC URL: " + postgres.getJdbcUrl());
    System.out.println("Username: " + postgres.getUsername());
    System.out.println("Password: " + postgres.getPassword());

    // Now connect with DBeaver or psql while test is paused
    Thread.sleep(300000); // 5 minutes to debug
}

When NOT to Use Testcontainers

Testcontainers isn’t always the answer:

ScenarioBetter Alternative
Unit testsPlain mocks - no containers needed
Simple CRUD tests@DataJpaTest with H2 (if no DB-specific features)
Contract testsWireMock or Spring Cloud Contract
Performance testsDedicated test environment
Local developmentDocker Compose (persistent data)

The Mental Model

Where does Testcontainers fit in your testing strategy?

Testing Pyramid + Testcontainers

Rule: Use Testcontainers for the integration layer only. Don’t spin up PostgreSQL to test a utility function.

Complete Example: Repository Test

@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TestEntityManager entityManager;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }

    @Test
    void shouldFindUsersByEmailDomain() {
        // Given
        userRepository.saveAll(List.of(
            new User("Alice", "alice@company.com"),
            new User("Bob", "bob@company.com"),
            new User("Charlie", "charlie@gmail.com")
        ));
        entityManager.flush();
        entityManager.clear();

        // When
        List<User> companyUsers = userRepository.findByEmailDomain("company.com");

        // Then
        assertThat(companyUsers)
            .hasSize(2)
            .extracting(User::getName)
            .containsExactlyInAnyOrder("Alice", "Bob");
    }

    @Test
    void shouldHandleNullEmailGracefully() {
        User userWithNullEmail = new User("NoEmail", null);
        userRepository.save(userWithNullEmail);

        // This query behavior might differ between H2 and PostgreSQL
        List<User> result = userRepository.findByEmailDomain("company.com");

        assertThat(result).isEmpty();
    }
}

Code Sample

Full working example: github.com/Moshiour027/techyowls-io-blog-public/testcontainers-guide

Summary

Before TestcontainersAfter Testcontainers
”Works on my machine”Works everywhere
H2 quirks hide bugsReal database behavior
Manual DB setup in CIZero infrastructure setup
Skip DB-specific testsTest everything
False confidenceReal confidence

The bottom line: If your production uses PostgreSQL, your tests should too. Testcontainers makes this trivially easy.

Stop letting your tests lie to you.

Advertisement

MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.