Skip to content

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:

pom.xml
<!-- 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:

build.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.

BaseIntegrationTest.java
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
@Transactional
public abstract class BaseIntegrationTest {
@Container
protected static PostgreSQLContainer&lt;?&gt; postgres =
new PostgreSQLContainer&lt;&gt;("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/:

V1__Create_users_table.sql
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:

application-test.yml
spring:
flyway:
enabled: true
baseline-on-migrate: true
jpa:
hibernate:
ddl-auto: validate # Validates schema matches entities

Why 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.

UserRepositoryTest.java
@SpringBootTest
@Transactional
class UserRepositoryTest extends BaseIntegrationTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldCreateUser() {
User user = new User("[email protected]");
userRepository.save(user);
assertThat(userRepository.count()).isEqualTo(1);
}
@Test
void shouldFindUserByEmail() {
// This test runs in isolation - previous test's data is gone
User user = new User("[email protected]");
userRepository.save(user);
Optional&lt;User&gt; found = userRepository.findByEmail("[email protected]");
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:

  1. No data bleed - Each test starts with a clean database state
  2. No teardown scripts - The rollback happens automatically
  3. Fast execution - No need to restart the container or clean tables between tests
  4. Honest tests - Tests can’t pass due to leftover data from previous tests

What NOT to do:

BadExample.java
// DON'T: Manually clean up data
@AfterEach
void 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:

MultipleTestClasses.java
// Test class 1
class UserServiceTest extends BaseIntegrationTest {
@Autowired private UserService userService;
@Test
void testUserCreation() { /* ... */ }
}
// Test class 2 - uses same container instance
class 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:

CorrectContainer.java
// Correct
@Container
protected static PostgreSQLContainer&lt;?&gt; postgres = ...;
// Wrong - don't do this
@Container
protected PostgreSQLContainer&lt;?&gt; postgres = ...;

Pitfall 2: Mixing DataJpaTest with Testcontainers

Problem: @DataJpaTest replaces datasource with embedded H2 by default.

Solution: Add this annotation:

DataJpaTestFix.java
@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:

TransactionalFix.java
@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:

  1. Container reuse - Static field means one container startup for entire test suite
  2. Transaction rollback - Faster than truncating tables or dropping/recreating schemas
  3. No remote calls - Container runs locally in Docker
  4. 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:

  1. Testcontainers PostgreSQL - Real database, not a mock
  2. Static container field - Share across test classes, one startup
  3. Flyway auto-migration - Schema matches production
  4. @Transactional rollback - Automatic cleanup, no data bleed
  5. 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

  1. Replace your H2 in-memory database with this Testcontainers pattern
  2. Add Flyway migrations if you haven’t already
  3. Extend BaseIntegrationTest in your existing test classes
  4. 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