How to Set Up Spring Boot Testcontainers with Flyway
Your integration tests pass locally but fail in CI. The database schema is out of sync. Tests interfere with each other. Sound familiar?
I tried using H2 in-memory databases for testing. It seemed like a good idea at first. But H2 doesn’t behave like PostgreSQL. I got production bugs that my tests should have caught.
The worst part? Tests that only pass because of leftover data from previous tests. I wasted hours debugging “works on my machine” issues.
I needed a better approach. Here’s what I found.
The Solution: Real Database Testing
I switched to Testcontainers with Flyway. Now I test against a real PostgreSQL database. The schema matches production exactly. And tests clean up automatically.
Here’s how it works:
+-------------+ +----------------+ +-------------------+| Test Class | --> | @Transactional | --> | PostgreSQL |+-------------+ +----------------+ | Container | +-------------------+ | v +-------------------+ | Flyway Migrations | +-------------------+ | v +-------------------+ | Run Test | +-------------------+ | v +-------------------+ | Auto Rollback | +-------------------+The key insight: transactional tests with rollback keep the suite honest and fast. No teardown scripts. No data bleed between tests. Flyway on top means your schema is always in sync with production.
Setting Up the Dependencies
First, add the required dependencies to your project.
For Maven:
<!-- Testcontainers for PostgreSQL --><dependency> <groupId>org.testcontainers</groupId> <artifactId>postgresql</artifactId> <version>1.21.2</version> <scope>test</scope></dependency>
<!-- Testcontainers JUnit 5 integration --><dependency> <groupId>org.testcontainers</groupId> <artifactId>junit-jupiter</artifactId> <version>1.21.2</version> <scope>test</scope></dependency>
<!-- Flyway for database migrations --><dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-core</artifactId></dependency>
<!-- PostgreSQL JDBC driver --><dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId></dependency>For Gradle:
testImplementation "org.testcontainers:postgresql:1.21.2"testImplementation "org.testcontainers:junit-jupiter:1.21.2"implementation "org.flywaydb:flyway-core"runtimeOnly "org.postgresql:postgresql"Important: Use the same PostgreSQL version in tests as in production. This avoids subtle differences in behavior.
The Base Integration Test Pattern
I created a base class that all my integration tests extend. This class sets up the PostgreSQL container and configures Spring to use it.
import org.junit.jupiter.api.Test;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.test.context.DynamicPropertyRegistry;import org.springframework.test.context.DynamicPropertySource;import org.springframework.transaction.annotation.Transactional;import org.testcontainers.containers.PostgreSQLContainer;import org.testcontainers.junit.jupiter.Container;import org.testcontainers.junit.jupiter.Testcontainers;
@SpringBootTest@Testcontainers@Transactionalpublic abstract class BaseIntegrationTest {
@Container protected static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine") .withDatabaseName("testdb") .withUsername("testuser") .withPassword("testpass");
@DynamicPropertySource static void configureDatabase(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); registry.add("spring.datasource.username", postgres::getUsername); registry.add("spring.datasource.password", postgres::getPassword); }}Let me explain each part:
Static Container Field
The @Container annotation on a static field means the container starts once before all tests. It stops after all tests complete. This makes tests much faster.
If you use a non-static field, the container restarts for each test method. That’s slow.
Dynamic Property Source
The @DynamicPropertySource method tells Spring to use the Testcontainers PostgreSQL instance. This overrides any datasource configuration in application.yml.
Transactional at Class Level
The @Transactional annotation makes every test method run in a transaction. The transaction automatically rolls back after each test. No cleanup code needed.
SpringBootTest
The @SpringBootTest annotation loads the full application context. All beans and configurations get tested.
Flyway Integration
Spring Boot auto-configures Flyway when flyway-core is on the classpath. Flyway runs migrations automatically on startup. Your test database schema matches production exactly.
Create your migration files in src/main/resources/db/migration/:
CREATE TABLE users ( id SERIAL PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX idx_users_email ON users(email);Add this configuration to force Flyway to run before Hibernate validation:
spring: flyway: enabled: true baseline-on-migrate: true jpa: hibernate: ddl-auto: validate # Validates schema matches entitiesWhy this matters: If your migration drifts from your JPA entities, tests will fail immediately. You catch schema mismatches in CI, not in production.
The Transactional Rollback Magic
Here’s where the real power shows. Each test runs in isolation.
@SpringBootTest@Transactionalclass UserRepositoryTest extends BaseIntegrationTest {
@Autowired private UserRepository userRepository;
@Test void shouldCreateUser() { userRepository.save(user);
assertThat(userRepository.count()).isEqualTo(1); }
@Test void shouldFindUserByEmail() { // This test runs in isolation - previous test's data is gone userRepository.save(user);
assertThat(found).isPresent(); }
@Test void countShouldStartFromZero() { // Database is clean for each test assertThat(userRepository.count()).isEqualTo(0); }}Notice the third test. It asserts the database count is zero. This proves the rollback works. The data from the first two tests doesn’t leak into this test.
Key benefits:
- No data bleed - Each test starts with a clean database state
- No teardown scripts - The rollback happens automatically
- Fast execution - No need to restart the container or clean tables between tests
- Honest tests - Tests can’t pass due to leftover data from previous tests
What NOT to do:
// DON'T: Manually clean up data@AfterEachvoid cleanup() { userRepository.deleteAll(); // Unnecessary with @Transactional rollback}Multiple Test Classes Sharing One Container
When multiple test classes extend BaseIntegrationTest, they share the same container instance:
// Test class 1class UserServiceTest extends BaseIntegrationTest { @Autowired private UserService userService;
@Test void testUserCreation() { /* ... */ }}
// Test class 2 - uses same container instanceclass OrderServiceTest extends BaseIntegrationTest { @Autowired private OrderService orderService;
@Test void testOrderCreation() { /* ... */ }}Spring caches the application context between test classes. Flyway migrations run once, not per test class. Tests execute in seconds, not minutes.
Common Pitfalls
Pitfall 1: Non-static Container Field
Problem: Container restarts for each test method. Tests become very slow.
Solution: Use static field with @Container:
// Correct@Containerprotected static PostgreSQLContainer<?> postgres = ...;
// Wrong - don't do this@Containerprotected PostgreSQLContainer<?> postgres = ...;Pitfall 2: Mixing DataJpaTest with Testcontainers
Problem: @DataJpaTest replaces datasource with embedded H2 by default.
Solution: Add this annotation:
@DataJpaTest@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)class MyRepositoryTest extends BaseIntegrationTest { // ...}Pitfall 3: Forgetting Transactional
Problem: Tests pollute each other with data. Flaky tests appear.
Solution: Add @Transactional at class level:
@SpringBootTest@Testcontainers@Transactional // Don't forget this!class MyTest extends BaseIntegrationTest { // ...}Pitfall 4: Running Migrations BeforeEach
Problem: Migrations run before each test, slowing down tests significantly.
Solution: Let Flyway auto-configure on Spring context startup. Don’t run migrations manually in @BeforeEach.
Performance
This pattern is fast because:
- Container reuse - Static field means one container startup for entire test suite
- Transaction rollback - Faster than truncating tables or dropping/recreating schemas
- No remote calls - Container runs locally in Docker
- Spring context caching - ApplicationContext reused across test classes
Typical performance:
- Container startup: 2-5 seconds (one-time cost)
- Per-test execution: 50-200ms
- Full test suite (50 tests): 10-15 seconds
Compare that to rebuilding the database schema before each test. That takes 30+ seconds per test class.
Summary
The winning combination:
- Testcontainers PostgreSQL - Real database, not a mock
- Static container field - Share across test classes, one startup
- Flyway auto-migration - Schema matches production
- @Transactional rollback - Automatic cleanup, no data bleed
- Base test class - Reusable pattern across your test suite
Result: Integration tests that are:
- Fast enough to run on every commit
- Honest enough to catch real production issues
- Maintainable with zero cleanup code
- Synchronized with your actual database schema
Next Steps
- Replace your H2 in-memory database with this Testcontainers pattern
- Add Flyway migrations if you haven’t already
- Extend
BaseIntegrationTestin your existing test classes - Watch your integration tests become honest and fast
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments