Database Testing in JUnit 6: H2 vs Real DB vs Containers

Every Java application talks to a database. Choosing the right testing strategy for that interaction determines how much confidence your test suite actually gives you. This guide compares the three main approaches β€” H2 in-memory, real database, and Testcontainers β€” with honest trade-offs, code examples for each, and a practical guide to combining them for maximum coverage at minimum cost.

The Three Approaches at a Glance

H2 In-MemoryReal Database (shared)Testcontainers
SpeedFastest (<1s startup)No startup costMedium (3–5s startup)
RealismLow β€” H2 β‰  PostgreSQLHigh β€” same as productionHighest β€” exact same engine
IsolationPerfect β€” fresh DB per runPoor β€” shared statePerfect β€” fresh container
CI friendlyYesRequires shared DB serverYes (Docker required)
Catches dialect bugsNoYesYes
Setup complexityMinimalMedium (infra required)Low (just Docker)

Option 1: H2 In-Memory Database

H2 is an embedded Java database that starts in milliseconds. Spring Boot’s @DataJpaTest uses H2 by default if it’s on the classpath.

<!-- H2 in-memory: auto-detected by @DataJpaTest -->
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.beans.factory.annotation.Autowired;

// @DataJpaTest uses H2 by default β€” no configuration needed
@DataJpaTest
@DisplayName("ProductRepository β€” H2 in-memory tests")
class ProductRepositoryH2Test {

    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private ProductRepository productRepository;

    @Test
    @DisplayName("Saving a product persists it and assigns an ID")
    void savingProductPersistsAndAssignsId() {
        Product product = new Product(null, "Laptop", 999.00, "Electronics");
        Product saved = entityManager.persistAndFlush(product);

        assertNotNull(saved.getId(), "Auto-generated ID must be assigned");
        assertEquals("Laptop", saved.getName());
    }

    @Test
    @DisplayName("Custom query findByCategory returns matching products")
    void findByCategoryReturnsMatchingProducts() {
        entityManager.persistAndFlush(new Product(null, "Laptop",     999.0, "Electronics"));
        entityManager.persistAndFlush(new Product(null, "Headphones",  79.0, "Electronics"));
        entityManager.persistAndFlush(new Product(null, "Notebook",     5.9, "Stationery"));

        List<Product> electronics = productRepository.findByCategory("Electronics");

        assertEquals(2, electronics.size());
    }
}

When to use H2: JPQL queries, basic CRUD verification, entity mapping validation, and anything that does not depend on database-specific SQL features or constraints.

When NOT to use H2: Native SQL queries, PostgreSQL-specific types (JSONB, arrays, UUID), CTEs, window functions, specific index behaviour, or ON CONFLICT (upsert) syntax.

Option 2: Real Database (Shared Dev/CI Database)

// application-test.properties (active in test profile)
// spring.datasource.url=jdbc:postgresql://ci-db-server:5432/test_db
// spring.datasource.username=ci_user
// spring.datasource.password=ci_pass

@SpringBootTest
@ActiveProfiles("test")  // activates application-test.properties
@Transactional           // rolls back each test β€” keeps the shared DB clean
@DisplayName("OrderRepository β€” shared database integration tests")
class OrderRepositoryRealDbTest {

    @Autowired
    private OrderRepository orderRepository;

    @Test
    @DisplayName("Finding orders by customer email returns correct results")
    void findByEmailReturnsCorrectOrders() {
        // Arrange: data inserted by @BeforeEach or Flyway test seed scripts
        List<Order> orders = orderRepository.findByEmail("[email protected]");
        assertFalse(orders.isEmpty());
    }
}
// Note: @Transactional ensures each test rolls back β€” critical for shared DB isolation

When to use shared real DB: Verifying against a pre-populated dataset that cannot be easily replicated, or when Docker is not available in CI.

Problems to watch for: Test order dependency (one test’s data affects another), missed rollbacks corrupting the database, and CI parallelism conflicts when multiple builds share the same database.

Option 3: Testcontainers (Recommended)

import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // don't replace with H2
@DisplayName("ProductRepository β€” Testcontainers PostgreSQL tests")
class ProductRepositoryContainerTest {

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

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url",      postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired private ProductRepository productRepository;
    @Autowired private TestEntityManager  entityManager;

    @Test
    @DisplayName("PostgreSQL JSONB metadata field stores and retrieves correctly")
    void jsonbMetadataFieldWorksCorrectly() {
        // This test ONLY works against real PostgreSQL, not H2
        Product product = new Product(null, "Laptop", 999.0, "Electronics");
        product.setMetadata("{"color":"silver","weight":"2.1kg"}");

        Product saved = entityManager.persistAndFlush(product);
        entityManager.clear();

        Product found = productRepository.findById(saved.getId()).orElseThrow();
        assertTrue(found.getMetadata().contains("silver"));
    }

    @Test
    @DisplayName("PostgreSQL UNIQUE constraint raises correct exception on duplicate")
    void uniqueConstraintRaisesDataIntegrityViolation() {
        entityManager.persistAndFlush(new Product(null, "Laptop", 999.0, "Electronics"));

        assertThrows(DataIntegrityViolationException.class, () -> {
            entityManager.persistAndFlush(new Product(null, "Laptop", 999.0, "Electronics"));
        });
    }
}

Choosing the Right Strategy

ScenarioBest choice
JPQL queries, basic CRUD, entity mappingH2 (fast, simple)
PostgreSQL-specific SQL (JSONB, arrays, CTEs)Testcontainers
Migration testing (Flyway/Liquibase)Testcontainers
Constraint violation testingTestcontainers
Developer machine, no Docker availableH2 or shared real DB
CI pipeline with Docker supportTestcontainers
Pre-populated dataset verificationShared real DB with @Transactional

Frequently Asked Questions (FAQs)

Q1: Can I use both H2 and Testcontainers in the same project?

Yes, and this is the recommended layered approach. Use @DataJpaTest with H2 for fast, simple CRUD and JPQL tests that run on every commit. Use Testcontainers for tests that require real PostgreSQL behaviour β€” native queries, constraints, migrations. Tag them appropriately (@Tag("fast") for H2, @Tag("integration") for Testcontainers) and filter in CI to run each group at the right stage.

Q2: How do I prevent @DataJpaTest from using H2 when I want Testcontainers?

Add @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) to your test class. This tells Spring not to replace your configured datasource with an embedded database. Then use @DynamicPropertySource to provide the Testcontainers JDBC URL as shown in the example above.

Q3: Should I use @Transactional in database integration tests?

With @DataJpaTest, each test is wrapped in a transaction that rolls back automatically β€” this is safe and correct. With @SpringBootTest, auto-rollback still applies if you add @Transactional to the test class. However, be cautious: if you are testing lazy loading, transaction boundaries, or events that fire on commit, a test-level transaction rollback may mask those bugs. In such cases, manage cleanup manually in @AfterEach instead.

Q4: How do I test database performance with JUnit 6?

For basic query performance bounds, use assertTimeout(Duration.ofMillis(200), () -> repository.findAll()). For serious performance testing with percentiles and warm-up runs, use JMH (Java Microbenchmark Harness) or the dedicated performance testing patterns covered in Performance Testing Using JUnit 6. Never rely on single-run timing from a cold JVM for performance assertions.

Q5: What is the best way to populate test data for database tests?

The cleanest approach is using the TestEntityManager (for JPA tests) or JDBC directly (for non-Spring tests) in @BeforeEach, combined with @Transactional rollback for automatic cleanup. For larger datasets, SQL scripts via @Sql annotation (@Sql(scripts="/data/seed-users.sql")) load data cleanly before the test and offer rollback scripts via @Sql(executionPhase = AFTER_TEST_METHOD).

See Also

Conclusion

There is no single right answer β€” the best database testing strategy depends on what you are testing, how fast it needs to run, and what infrastructure you have. Use H2 for speed and simplicity on basic queries. Reach for Testcontainers when you need to test against the real database engine and its specific behaviour. Use a shared real database only when the other two options genuinely cannot serve your needs. Layer all three strategically and your database test suite will be both fast and trustworthy.

Next: Testing Microservices with JUnit 6 β€” integration, contract, and end-to-end testing strategies for distributed systems.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.