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-Memory | Real Database (shared) | Testcontainers | |
|---|---|---|---|
| Speed | Fastest (<1s startup) | No startup cost | Medium (3β5s startup) |
| Realism | Low β H2 β PostgreSQL | High β same as production | Highest β exact same engine |
| Isolation | Perfect β fresh DB per run | Poor β shared state | Perfect β fresh container |
| CI friendly | Yes | Requires shared DB server | Yes (Docker required) |
| Catches dialect bugs | No | Yes | Yes |
| Setup complexity | Minimal | Medium (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
| Scenario | Best choice |
|---|---|
| JPQL queries, basic CRUD, entity mapping | H2 (fast, simple) |
| PostgreSQL-specific SQL (JSONB, arrays, CTEs) | Testcontainers |
| Migration testing (Flyway/Liquibase) | Testcontainers |
| Constraint violation testing | Testcontainers |
| Developer machine, no Docker available | H2 or shared real DB |
| CI pipeline with Docker support | Testcontainers |
| Pre-populated dataset verification | Shared 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
- JUnit 6 with Testcontainers: Real Database Integration Testing
- JUnit 6 with Spring Boot: Unit, Slice, and Integration Testing
- Test Data Management Strategies in JUnit 6 Projects
- Running JUnit 6 Tests in CI/CD Pipelines
- JUnit 6 Tutorial: Complete Series Index
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.