H2 in-memory databases lie. Your tests pass but production breaks because H2 SQL differs from PostgreSQL. Testcontainers fixes this by running real databases in Docker during your tests.
JOptimize Team
The conversation about H2 for Spring Boot testing has been settled. H2 is a different database from PostgreSQL. It accepts SQL that PostgreSQL rejects, handles JSON differently, has different constraint behavior, and doesn't support PostgreSQL-specific features like JSONB, array_agg, or ON CONFLICT DO UPDATE. Tests that pass with H2 fail in production — not because of bugs in your test logic, but because H2 was lying about what your database would actually do.
Testcontainers is the solution. It runs real Docker containers for your dependencies — PostgreSQL, Redis, Kafka, Elasticsearch, whatever you use — during your test suite. The containers start before the tests, stop after. Your tests run against the same database engine as production.
Here are real examples of tests that pass with H2 but fail in production with PostgreSQL:
-- Works in H2, fails in PostgreSQL (case sensitivity) SELECT * FROM ORDERS WHERE STATUS = 'pending'; -- PostgreSQL: column "STATUS" does not exist (identifiers are lowercase by default) -- Works in H2, fails in PostgreSQL (JSON operations) SELECT metadata->'key' FROM products; -- PostgreSQL: requires JSONB column type and specific JSON operators -- Works in H2, triggers constraint violation in PostgreSQL -- Deferred constraints, foreign key check timing, transaction isolation
With Testcontainers, your tests run PostgreSQL. The same database engine, the same SQL dialect, the same constraint behavior. Tests that fail in production fail in your test suite first — which is exactly what you want.
Spring Boot 3.1 introduced native Testcontainers support that eliminates most boilerplate:
<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>kafka</artifactId> <scope>test</scope> </dependency>
// Spring Boot 3.1+ — zero-config Testcontainers @SpringBootTest @Testcontainers public class OrderRepositoryTest { @Container @ServiceConnection // Automatically configures spring.datasource.* static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine"); @Autowired private OrderRepository orderRepo; @Test void savesOrderAndRetrievesById() { Order order = Order.builder() .customerId(1L) .total(new BigDecimal("99.99")) .status(OrderStatus.PENDING) .build(); Order saved = orderRepo.save(order); assertThat(orderRepo.findById(saved.getId())) .isPresent() .get() .extracting(Order::getTotal) .isEqualTo(new BigDecimal("99.99")); } }
@ServiceConnection is the Spring Boot 3.1 magic. It reads the container's connection details and automatically configures spring.datasource.url, spring.datasource.username, and spring.datasource.password. No manual property overrides needed.
@SpringBootTest @Testcontainers public class OrderEventTest { @Container @ServiceConnection static KafkaContainer kafka = new KafkaContainer( DockerImageName.parse("confluentinc/cp-kafka:7.6.0") ); @Autowired private OrderService orderService; @Autowired private KafkaConsumerCapture eventCapture; // Test helper to capture events @Test void placingOrderPublishesKafkaEvent() throws Exception { PlaceOrderRequest req = new PlaceOrderRequest(1L, List.of( new OrderItem(1L, 2, new BigDecimal("49.99")) )); orderService.placeOrder(req); // Wait for Kafka consumer to receive the event Awaitility.await() .atMost(10, SECONDS) .until(() -> eventCapture.hasReceivedEventOfType("OrderPlaced")); OrderPlacedEvent event = eventCapture.getLastEvent(OrderPlacedEvent.class); assertThat(event.customerId()).isEqualTo(1L); } }
Kafka integration testing is notoriously hard without Testcontainers. With it, you test the full producer/consumer cycle with a real Kafka broker.
Starting a PostgreSQL container for every test class is slow. Testcontainers supports container reuse — one container shared across the entire test suite:
// Create a shared test configuration @TestConfiguration(proxyBeanMethods = false) public class TestInfrastructureConfig { @Bean @ServiceConnection PostgreSQLContainer<?> postgresContainer() { return new PostgreSQLContainer<>("postgres:16-alpine") .withReuse(true); // Container survives test class boundary } @Bean @ServiceConnection RedisContainer redisContainer() { return new RedisContainer("redis:7-alpine") .withReuse(true); } @Bean @ServiceConnection KafkaContainer kafkaContainer() { return new KafkaContainer("confluentinc/cp-kafka:7.6.0") .withReuse(true); } } // Import the shared config in all integration tests @SpringBootTest @Import(TestInfrastructureConfig.class) public class OrderServiceTest { ... } @SpringBootTest @Import(TestInfrastructureConfig.class) public class InventoryServiceTest { ... }
With withReuse(true), Testcontainers reuses existing containers across JVM runs if the container configuration hasn't changed. The first test run starts the container; subsequent runs (in the same IDE session or CI step) reuse it. This cuts test suite startup time from 30+ seconds to near-zero.
Testcontainers is especially valuable for testing Flyway or Liquibase migrations:
@SpringBootTest @Testcontainers public class DatabaseMigrationTest { @Container @ServiceConnection static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine"); @Autowired private Flyway flyway; @Autowired private JdbcTemplate jdbcTemplate; @Test void allMigrationsApplySuccessfully() { // Flyway runs automatically on startup — if we get here, it worked MigrationInfo[] migrations = flyway.info().applied(); assertThat(migrations).isNotEmpty(); assertThat(Arrays.stream(migrations).allMatch(m -> m.getState() == MigrationState.SUCCESS) ).isTrue(); } @Test void schemaMatchesExpectedStructure() { // Test that specific columns exist with correct types var count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_name = 'orders' AND column_name = 'customer_id' " + "AND data_type = 'bigint'", Integer.class ); assertThat(count).isEqualTo(1); } }
Testcontainers needs Docker available in CI. This works out of the box with GitHub Actions, GitLab CI, and Jenkins:
# .github/workflows/test.yml name: Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest # Docker is available on GitHub Actions steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 with: java-version: '21' distribution: 'temurin' cache: maven - name: Run integration tests run: mvn test -P integration-test # Docker starts automatically via Testcontainers # No docker-compose, no manual setup
The key insight: Testcontainers pulls Docker images on the first run and caches them. Subsequent CI runs use the cached image. No infrastructure setup required — the test code is the infrastructure definition.
static fields so they're shared across test methods in the class@Transactional (rolls back after each test) or truncate tables in @AfterEachlatest image tags — pin Docker image versions in tests just like you pin library versions; postgres:latest will eventually break your tests when a major version shipsTestcontainers with Spring Boot 3's @ServiceConnection gives you real integration tests with minimal setup: @Container + @ServiceConnection on a PostgreSQLContainer and the test connects to a real PostgreSQL. withReuse(true) shares containers across tests for fast iteration. CI requires no special setup — Docker is sufficient. The investment pays off immediately: every test that would have passed with H2 but failed in production becomes a test that fails where it should.
Testcontainers ensures correctness. JOptimize ensures performance — finding N+1 queries and missing indexes in the same codebase that your Testcontainers tests validate.
Test correctly, then perform correctly.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.