In modern software development, database schema management is just as critical as writing clean application code. When teams adopt continuous delivery (CD) and microservices, ensuring that schema migrations are tested alongside the application becomes essential. Two popular tools—Flyway and Liquibase—help manage database versioning and migrations. Combined with Testcontainers, you can run production-like integration tests that validate schema changes before hitting staging or production.
In this tutorial, we’ll explore how to use Flyway, Liquibase, and Testcontainers together in Java projects, covering practical examples, best practices, and advanced scenarios.
Why Test Database Migrations?
- Prevent Runtime Failures – Catch migration issues early before production deployments.
- Consistency Across Environments – Guarantee schema changes apply consistently in dev, test, and prod.
- CI/CD Integration – Validate migrations in pipelines with ephemeral containerized databases.
- Support for Legacy Systems – Smoothly evolve schemas in projects with long histories.
Setting Up Testcontainers for Database Testing
To start, add the required dependencies in Maven:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.19.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>9.22.2</version>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.25.1</version>
</dependency>
For Gradle:
testImplementation "org.testcontainers:postgresql:1.19.3"
testImplementation "org.flywaydb:flyway-core:9.22.2"
testImplementation "org.liquibase:liquibase-core:4.25.1"
Example 1: Flyway with Testcontainers
Flyway organizes migrations in src/main/resources/db/migration
as incremental SQL files:
V1__create_users_table.sql
V2__add_email_column.sql
JUnit 5 Integration
import org.flywaydb.core.Flyway;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class FlywayMigrationTest {
@Test
void testFlywayMigrations() throws Exception {
try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")) {
postgres.start();
Flyway flyway = Flyway.configure()
.dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())
.load();
flyway.migrate();
try (Connection conn = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
ResultSet rs = conn.createStatement().executeQuery("SELECT * FROM users");
assertTrue(rs.next());
}
}
}
}
Here, Testcontainers provisions a fresh PostgreSQL instance, Flyway applies migrations, and tests verify schema correctness.
Example 2: Liquibase with Testcontainers
Liquibase uses changelogs (XML, YAML, JSON, or SQL). Example db.changelog.xml
:
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
<changeSet id="1" author="team">
<createTable tableName="products">
<column name="id" type="BIGINT" autoIncrement="true" primaryKey="true"/>
<column name="name" type="VARCHAR(255)"/>
</createTable>
</changeSet>
</databaseChangeLog>
JUnit 5 Integration
import liquibase.Liquibase;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.resource.ClassLoaderResourceAccessor;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import static org.junit.jupiter.api.Assertions.assertTrue;
public class LiquibaseMigrationTest {
@Test
void testLiquibaseMigrations() throws Exception {
try (PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine")) {
postgres.start();
try (Connection conn = DriverManager.getConnection(
postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
liquibase.database.Database database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(new JdbcConnection(conn));
Liquibase liquibase = new Liquibase("db/changelog/db.changelog.xml",
new ClassLoaderResourceAccessor(), database);
liquibase.update("test");
ResultSet rs = conn.createStatement().executeQuery("SELECT * FROM products");
assertTrue(rs.next());
}
}
}
}
This ensures Liquibase migrations are validated end-to-end using Testcontainers.
Advanced Testing Scenarios
- Parallel Test Execution – Use separate schema names or databases to run migration tests concurrently.
- Rollback Testing – Validate
flyway.undo
or Liquibase rollback scripts in Testcontainers. - Multi-Database Testing – Spin up MySQL, PostgreSQL, and Oracle containers to test cross-database support.
- CI/CD Pipelines – Integrate with GitHub Actions, Jenkins, or GitLab CI using ephemeral containers.
Best Practices
- Keep migrations idempotent – Ensure reruns won’t break schemas.
- Test forward and backward compatibility – Validate rollbacks and incremental migrations.
- Use real data samples – Seed Testcontainers with realistic data.
- Pin container versions – Avoid unexpected failures due to upstream image changes.
- Automate in CI/CD – Run migrations in every pipeline run for maximum reliability.
Version Tracker
- JUnit 4 → JUnit 5 – Migration to Jupiter annotations simplified integration with Testcontainers.
- Mockito Updates – Static and final mocking improved complex test cases.
- Testcontainers Ecosystem – Expanded modules for Kafka, RabbitMQ, LocalStack, and database testing.
Conclusion & Key Takeaways
By combining Flyway/Liquibase with Testcontainers, Java developers can ensure:
- Schema migrations are tested like any other feature.
- Integration tests validate both schema and data integrity.
- CI/CD pipelines become more resilient and production-like.
This approach reduces downtime, ensures smooth deployments, and builds confidence in database-driven applications.
FAQ
1. What is the main difference between Flyway and Liquibase?
Flyway uses simple SQL migrations, while Liquibase supports XML/YAML changelogs and more complex operations.
2. Can I use Flyway and Liquibase together?
Technically yes, but teams usually pick one tool to avoid duplication.
3. How do Testcontainers help in database testing?
They provision ephemeral, production-like databases for reliable integration tests.
4. Is rollback testing supported?
Yes, both Flyway and Liquibase support rollback, but it must be explicitly configured.
5. Can I run these tests in parallel?
Yes, by using unique schema names or dedicated containers.
6. How do I integrate this with CI/CD?
Run Testcontainers-based migration tests in GitHub Actions, Jenkins, or GitLab CI.
7. Are Flyway migrations faster than Liquibase?
Flyway is simpler and typically faster; Liquibase offers more advanced features.
8. Can I use Docker Compose instead of Testcontainers?
Yes, but Testcontainers provide better integration with JUnit lifecycle.
9. Do I need PostgreSQL only?
No, you can test MySQL, Oracle, and MongoDB as well.
10. What’s the biggest advantage of this setup?
Confidence—every migration is tested before hitting production.