Skip to main content

Test Database Setup

Overview

This guide covers database setup strategies for testing in the Ink platform, including H2 in-memory databases, TestContainers with PostgreSQL, test data management, and database state cleanup strategies.

Target Audience: All developers
Prerequisites: Understanding of Integration Testing Guide
Estimated Time: 40-50 minutes

Prerequisites

  • Completed Integration Testing Guide
  • JPA and Hibernate knowledge
  • SQL and database concepts
  • Docker installed (for TestContainers)

Test Database Architecture

Installation Steps

1. H2 Database Configuration

<!-- filepath: /Users/jetstart/dev/jetrev/ink/pom.xml -->
<!-- ...existing code... -->
<dependencies>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- ...existing code... -->

2. Test Database Properties

# filepath: /Users/jetstart/dev/jetrev/ink/src/test/resources/application-test.yml
spring:
datasource:
# H2 configuration
url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH
driver-class-name: org.h2.Driver
username: sa
password:

jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
format_sql: true
show_sql: false
show-sql: false

liquibase:
enabled: true
change-log: classpath:db/changelog/db.changelog-master.xml
drop-first: false

sql:
init:
mode: never

# H2 Console (useful for debugging)
h2:
console:
enabled: true
path: /h2-console

logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
liquibase: INFO

3. TestContainers PostgreSQL Configuration

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/config/PostgresTestContainer.java
@TestConfiguration
public class PostgresTestContainer {

private static final String POSTGRES_IMAGE = "postgres:15-alpine";

@Container
static PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>(POSTGRES_IMAGE)
.withDatabaseName("ink_test")
.withUsername("test_user")
.withPassword("test_password")
.withInitScript("db/init-test-schema.sql")
.withReuse(true);

static {
postgresContainer.start();
}

@DynamicPropertySource
static void registerPgProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgresContainer::getJdbcUrl);
registry.add("spring.datasource.username", postgresContainer::getUsername);
registry.add("spring.datasource.password", postgresContainer::getPassword);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
}

Configuration

H2 vs TestContainers Comparison

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/base/H2TestBase.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.AUTO_CONFIGURED)
@ActiveProfiles("test")
public abstract class H2TestBase {

@Autowired
protected TestEntityManager entityManager;

/**
* Pros:
* - Fast startup (no Docker required)
* - Lightweight
* - Good for simple entity tests
*
* Cons:
* - Not 100% PostgreSQL compatible
* - May hide PostgreSQL-specific issues
* - Limited support for advanced features
*/
}

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/base/PostgresTestBase.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(PostgresTestContainer.class)
@ActiveProfiles("test")
public abstract class PostgresTestBase {

@Autowired
protected TestEntityManager entityManager;

/**
* Pros:
* - 100% PostgreSQL compatibility
* - Tests against real database
* - Catches PostgreSQL-specific issues
* - Supports all PostgreSQL features
*
* Cons:
* - Slower startup (Docker container)
* - Requires Docker running
* - More resource intensive
*/
}

When to Use Each Strategy

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/decision/TestDatabaseDecisionGuide.java
/**
* Database Testing Strategy Decision Guide
*
* Use H2 In-Memory When:
* ✓ Testing simple CRUD operations
* ✓ Testing entity mappings
* ✓ Running on CI with limited resources
* ✓ Fast feedback loop is critical
* ✓ No PostgreSQL-specific features used
*
* Use TestContainers PostgreSQL When:
* ✓ Testing complex queries
* ✓ Using PostgreSQL-specific features (JSONB, arrays, etc.)
* ✓ Testing stored procedures or functions
* ✓ Integration tests requiring full DB compatibility
* ✓ Testing database migrations
* ✓ End-to-end tests
*
* Use Shared Test Database When:
* ✓ Manual testing scenarios
* ✓ Debugging complex issues
* ✓ Performance testing
* ✗ NOT for automated tests (state pollution)
*/

Repository Tests with H2

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/repository/UserRepositoryH2Test.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.AUTO_CONFIGURED)
@ActiveProfiles("test")
class UserRepositoryH2Test {

@Autowired
private UserRepository userRepository;

@Autowired
private TestEntityManager entityManager;

@BeforeEach
void setUp() {
// H2 starts fresh for each test
}

@Test
@DisplayName("Should save and retrieve user with H2")
void shouldSaveAndRetrieveUser() {
// Given
User user = User.builder()
.username("h2user")
.email("h2@example.com")
.password("password")
.roles(Set.of("USER"))
.build();

// When
User saved = userRepository.save(user);
entityManager.flush();
entityManager.clear(); // Clear persistence context

// Then
Optional<User> found = userRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getUsername()).isEqualTo("h2user");
}

@Test
@DisplayName("Should handle custom queries with H2")
void shouldHandleCustomQueries() {
// Given
User user1 = createUser("user1", "user1@example.com", Set.of("USER"));
User user2 = createUser("user2", "user2@example.com", Set.of("ADMIN"));

userRepository.save(user1);
userRepository.save(user2);
entityManager.flush();

// When
List<User> admins = userRepository.findByRolesContaining("ADMIN");

// Then
assertThat(admins).hasSize(1);
assertThat(admins.get(0).getUsername()).isEqualTo("user2");
}

private User createUser(String username, String email, Set<String> roles) {
return User.builder()
.username(username)
.email(email)
.password("password")
.roles(roles)
.build();
}
}

Repository Tests with TestContainers

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/repository/UserRepositoryPostgresTest.java
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class UserRepositoryPostgresTest {

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withDatabaseName("test")
.withUsername("test")
.withPassword("test");

@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);
}

@Autowired
private UserRepository userRepository;

@Autowired
private TestEntityManager entityManager;

@Test
@DisplayName("Should test PostgreSQL-specific features")
void shouldTestPostgresFeatures() {
// Given - using PostgreSQL array type
User user = User.builder()
.username("postgresuser")
.email("postgres@example.com")
.password("password")
.roles(Set.of("USER", "ADMIN", "MODERATOR"))
.metadata(Map.of("key1", "value1", "key2", "value2")) // JSONB
.build();

// When
User saved = userRepository.save(user);
entityManager.flush();
entityManager.clear();

// Then
User found = userRepository.findById(saved.getId()).orElseThrow();
assertThat(found.getRoles()).containsExactlyInAnyOrder("USER", "ADMIN", "MODERATOR");
assertThat(found.getMetadata()).containsEntry("key1", "value1");
}

@Test
@DisplayName("Should test PostgreSQL full-text search")
@Sql("/sql/setup-full-text-search.sql")
void shouldTestFullTextSearch() {
// Given
userRepository.save(createUser("john", "john@example.com"));
userRepository.save(createUser("jane", "jane@example.com"));
userRepository.save(createUser("johnny", "johnny@example.com"));
entityManager.flush();

// When - PostgreSQL full-text search
List<User> results = userRepository.searchByUsername("john");

// Then
assertThat(results).hasSize(2);
assertThat(results)
.extracting(User::getUsername)
.containsExactlyInAnyOrder("john", "johnny");
}

private User createUser(String username, String email) {
return User.builder()
.username(username)
.email(email)
.password("password")
.roles(Set.of("USER"))
.build();
}
}

Usage Examples

Database Migration Testing

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/migration/LiquibaseMigrationTest.java
@SpringBootTest
@Testcontainers
class LiquibaseMigrationTest {

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

@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.liquibase.enabled", () -> "true");
}

@Autowired
private DataSource dataSource;

@Test
@DisplayName("Should apply all Liquibase migrations successfully")
void shouldApplyAllMigrations() throws Exception {
// Given - Liquibase runs on startup

// When - Query database metadata
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData metaData = conn.getMetaData();

// Then - Verify expected tables exist
assertTableExists(metaData, "users");
assertTableExists(metaData, "roles");
assertTableExists(metaData, "permissions");
assertTableExists(metaData, "databasechangelog");
assertTableExists(metaData, "databasechangeloglock");
}
}

@Test
@DisplayName("Should verify all constraints are created")
void shouldVerifyConstraints() throws Exception {
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData metaData = conn.getMetaData();

// Verify foreign keys
try (ResultSet rs = metaData.getImportedKeys(null, null, "users")) {
assertThat(rs.next()).isTrue();
}

// Verify indexes
try (ResultSet rs = metaData.getIndexInfo(null, null, "users", false, false)) {
List<String> indexes = new ArrayList<>();
while (rs.next()) {
indexes.add(rs.getString("INDEX_NAME"));
}
assertThat(indexes).contains("idx_users_username", "idx_users_email");
}
}
}

private void assertTableExists(DatabaseMetaData metaData, String tableName) throws Exception {
try (ResultSet rs = metaData.getTables(null, null, tableName, new String[]{"TABLE"})) {
assertThat(rs.next())
.as("Table %s should exist", tableName)
.isTrue();
}
}
}

Test Data Seeding

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/data/TestDataSeeder.java
@Component
@Profile("test")
public class TestDataSeeder {

private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;

public TestDataSeeder(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}

public void seedUsers(int count) {
for (int i = 0; i < count; i++) {
User user = User.builder()
.username("user" + i)
.email("user" + i + "@example.com")
.password(passwordEncoder.encode("Password123!"))
.firstName("User")
.lastName(String.valueOf(i))
.roles(Set.of("USER"))
.active(true)
.createdAt(Instant.now())
.build();

userRepository.save(user);
}
}

public User seedAdmin() {
User admin = User.builder()
.username("admin")
.email("admin@example.com")
.password(passwordEncoder.encode("Admin123!"))
.firstName("Admin")
.lastName("User")
.roles(Set.of("ADMIN", "USER"))
.active(true)
.createdAt(Instant.now())
.build();

return userRepository.save(admin);
}

public void clearAllData() {
userRepository.deleteAll();
}
}

// Usage in tests
@SpringBootTest
@ActiveProfiles("test")
class UserServiceTestWithSeededData {

@Autowired
private TestDataSeeder seeder;

@Autowired
private UserService userService;

@BeforeEach
void setUp() {
seeder.clearAllData();
seeder.seedUsers(10);
seeder.seedAdmin();
}

@Test
void testWithSeededData() {
// Data is already seeded
Page<UserDto> users = userService.getAllUsers(PageRequest.of(0, 10));
assertThat(users.getTotalElements()).isEqualTo(11); // 10 users + 1 admin
}
}

SQL Script Data Loading

-- filepath: /Users/jetstart/dev/jetrev/ink/src/test/resources/data/test-users.sql
INSERT INTO users (id, username, email, password, first_name, last_name, active, created_at)
VALUES
(1, 'testuser1', 'test1@example.com', 'encodedPassword', 'Test', 'User1', true, NOW()),
(2, 'testuser2', 'test2@example.com', 'encodedPassword', 'Test', 'User2', true, NOW()),
(3, 'admin', 'admin@example.com', 'encodedPassword', 'Admin', 'User', true, NOW());

INSERT INTO user_roles (user_id, role)
VALUES
(1, 'USER'),
(2, 'USER'),
(3, 'ADMIN'),
(3, 'USER');
// Using @Sql annotation
@Test
@Sql("/data/test-users.sql")
@DisplayName("Should load test data from SQL script")
void shouldLoadDataFromScript() {
List<User> users = userRepository.findAll();
assertThat(users).hasSize(3);
}

@Test
@Sql(scripts = "/data/test-users.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
@Sql(scripts = "/data/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
@DisplayName("Should load and cleanup data")
void shouldLoadAndCleanupData() {
// Test with loaded data
}

Verification

Database State Cleanup Strategies

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/cleanup/DatabaseCleanupStrategies.java

// Strategy 1: @Transactional with rollback (recommended for unit tests)
@DataJpaTest
class TransactionalRollbackTest {

@Autowired
private UserRepository userRepository;

@Test
@Transactional
@DisplayName("Changes are rolled back after test")
void testWithRollback() {
User user = createUser("rollbacktest");
userRepository.save(user);

// Changes will be rolled back after test
}
}

// Strategy 2: @DirtiesContext (heavy, use sparingly)
@SpringBootTest
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class DirtiesContextTest {

@Autowired
private UserRepository userRepository;

@Test
void testWithCleanContext() {
// Fresh context for each test
// Expensive - reloads entire application context
}
}

// Strategy 3: Manual cleanup in @AfterEach
@SpringBootTest
class ManualCleanupTest {

@Autowired
private UserRepository userRepository;

@AfterEach
void cleanup() {
userRepository.deleteAll();
// Or more granular cleanup
}
}

// Strategy 4: @Sql cleanup scripts
@SpringBootTest
class SqlCleanupTest {

@Test
@Sql(scripts = "/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
void testWithSqlCleanup() {
// Test code
// cleanup.sql runs after
}
}

// Strategy 5: Test-scoped transaction manager
@TestConfiguration
class TestTransactionConfig {

@Bean
@Primary
public PlatformTransactionManager testTransactionManager(EntityManagerFactory emf) {
JpaTransactionManager transactionManager = new JpaTransactionManager();
transactionManager.setEntityManagerFactory(emf);
transactionManager.setRollbackOnCommitFailure(true);
return transactionManager;
}
}

Verifying Database State

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/verification/DatabaseStateVerification.java
@SpringBootTest
@Testcontainers
class DatabaseStateVerificationTest {

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

@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);
}

@Autowired
private DataSource dataSource;

@Autowired
private UserRepository userRepository;

@Test
@DisplayName("Should verify row count after operation")
void shouldVerifyRowCount() throws Exception {
// Given
User user = createUser("counttest");
userRepository.save(user);

// When
long count = getTableRowCount("users");

// Then
assertThat(count).isEqualTo(1);
}

@Test
@DisplayName("Should verify specific column values")
void shouldVerifyColumnValues() throws Exception {
// Given
User user = User.builder()
.username("verifytest")
.email("verify@example.com")
.password("password")
.active(true)
.build();
User saved = userRepository.save(user);

// When - Direct SQL query
String sql = "SELECT active FROM users WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {

stmt.setLong(1, saved.getId());
try (ResultSet rs = stmt.executeQuery()) {
assertThat(rs.next()).isTrue();
assertThat(rs.getBoolean("active")).isTrue();
}
}
}

private long getTableRowCount(String tableName) throws Exception {
String sql = "SELECT COUNT(*) FROM " + tableName;
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {

rs.next();
return rs.getLong(1);
}
}

private User createUser(String username) {
return User.builder()
.username(username)
.email(username + "@example.com")
.password("password")
.roles(Set.of("USER"))
.build();
}
}

Troubleshooting

H2 Compatibility Issues

// Problem: PostgreSQL-specific types not supported in H2
@Entity
public class User {
// PostgreSQL JSONB not supported in H2
@Column(columnDefinition = "jsonb")
private Map<String, Object> metadata;
}

// Solution: Use conditional column definitions
@Entity
public class User {
@Column(columnDefinition = "jsonb")
@JdbcTypeCode(SqlTypes.JSON) // Use standard type code
private Map<String, Object> metadata;
}

// Or use profile-specific entities for testing
@Profile("!test")
@Column(columnDefinition = "jsonb")
private Map<String, Object> metadata;

@Profile("test")
@Column(columnDefinition = "varchar(4000)")
@Convert(converter = JsonConverter.class)
private Map<String, Object> metadata;

TestContainers Slow Startup

# Enable container reuse
export TESTCONTAINERS_REUSE_ENABLE=true

# Or in .testcontainers.properties
testcontainers.reuse.enable=true
// Reuse containers across test classes
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")
.withReuse(true); // Enable reuse

Database State Pollution

// Problem: Tests affecting each other
@Test
void test1() {
userRepository.save(createUser("user1"));
// Doesn't clean up
}

@Test
void test2() {
long count = userRepository.count();
assertThat(count).isEqualTo(0); // Fails if test1 ran first
}

// Solution: Proper cleanup
@BeforeEach
void setUp() {
userRepository.deleteAll();
}

// Or use @Transactional with rollback
@Test
@Transactional
void test1() {
userRepository.save(createUser("user1"));
// Automatically rolled back
}

Code Examples

Custom Test Database Configuration

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/config/TestDatabaseConfig.java
@TestConfiguration
public class TestDatabaseConfig {

@Bean
@Primary
public DataSource testDataSource() {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:h2:mem:testdb");
config.setUsername("sa");
config.setPassword("");
config.setMaximumPoolSize(5);
config.setMinimumIdle(1);
config.setConnectionTimeout(30000);

return new HikariDataSource(config);
}

@Bean
public DataSourceInitializer dataSourceInitializer(DataSource dataSource) {
ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
populator.addScript(new ClassPathResource("schema-test.sql"));
populator.addScript(new ClassPathResource("data-test.sql"));

DataSourceInitializer initializer = new DataSourceInitializer();
initializer.setDataSource(dataSource);
initializer.setDatabasePopulator(populator);

return initializer;
}
}

Database Fixture Builder

// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/fixture/DatabaseFixture.java
@Component
@Profile("test")
public class DatabaseFixture {

private final EntityManager entityManager;

public DatabaseFixture(EntityManager entityManager) {
this.entityManager = entityManager;
}

@Transactional
public User persistUser(String username) {
User user = User.builder()
.username(username)
.email(username + "@example.com")
.password("password")
.roles(Set.of("USER"))
.createdAt(Instant.now())
.build();

entityManager.persist(user);
entityManager.flush();

return user;
}

@Transactional
public List<User> persistUsers(int count) {
List<User> users = new ArrayList<>();
for (int i = 0; i < count; i++) {
users.add(persistUser("user" + i));
}
return users;
}

@Transactional
public void clearAllTables() {
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();
entityManager.createNativeQuery("TRUNCATE TABLE users").executeUpdate();
entityManager.createNativeQuery("TRUNCATE TABLE roles").executeUpdate();
entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
entityManager.flush();
}
}

Best Practices

  1. Choose Right Strategy: Use H2 for simple tests, TestContainers for complex scenarios
  2. Clean State: Always start with clean database state
  3. Isolate Tests: Each test should be independent
  4. Use Transactions: Leverage @Transactional for automatic rollback
  5. Minimize @DirtiesContext: It's expensive, use sparingly
  6. Reuse Containers: Enable TestContainers reuse for faster tests
  7. Test Migrations: Verify Liquibase/Flyway scripts work correctly
  8. Seed Data Wisely: Only seed what you need for the test
  9. Document Assumptions: Clearly document database state requirements
  10. Performance Matters: Keep tests fast by using appropriate strategies

Performance Optimization

Parallel Test Execution

<!-- filepath: /Users/jetstart/dev/jetrev/ink/pom.xml -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<parallel>classes</parallel>
<threadCount>4</threadCount>
<perCoreThreadCount>true</perCoreThreadCount>
<reuseForks>true</reuseForks>
</configuration>
</plugin>

Database Connection Pooling

# filepath: /Users/jetstart/dev/jetrev/ink/src/test/resources/application-test.yml
spring:
datasource:
hikari:
maximum-pool-size: 5
minimum-idle: 1
connection-timeout: 10000
idle-timeout: 30000

Additional Resources


Next Steps: Proceed to Database Schema Documentation to understand the complete database structure.