Liquibase Testing Strategies: Validate Changelogs Before They Hit Production
Liquibase changelogs applied without testing are a production incident waiting to happen. This post covers every layer of the Liquibase testing stack: updateSQL dry-runs, Testcontainers-backed integration tests, rollback verification, and precondition testing to guard against environmental assumptions.
Key Takeaways
Dry-run before you apply. liquibase updateSQL generates the SQL that would be applied without touching the database. Running this in CI catches syntax errors and dialect mismatches before any database is touched.
Test rollbacks, not just forward migrations. Every changeset that modifies schema should have a rollback block, and your test suite should verify that rollback returns the database to its prior state exactly.
Preconditions prevent partial apply disasters. A changeset that assumes a column exists will fail mid-migration if that column was already renamed by another team's branch. Preconditions make those assumptions explicit and testable.
The Hidden Risk in Liquibase Changelogs
Liquibase is the most flexible database migration tool in the Java ecosystem. That flexibility is also its biggest risk surface. A Flyway migration is a single SQL file. A Liquibase changelog can mix XML, YAML, JSON, and SQL changesets, include other changelogs, and apply conditionally based on preconditions. With that power comes complexity — and complexity fails in ways that surprise you.
The most common production failures with Liquibase:
- A changeset runs in development (small dataset, permissive settings) but deadlocks in production (millions of rows, strict isolation levels).
- A rollback block is missing, so a failed deployment leaves the schema in a half-applied state with no path back.
- A precondition references a column name that was recently renamed, so the precondition silently passes and the changeset runs against the wrong schema.
- A changelog includes another changelog using a relative path that resolves differently in CI than on a developer's machine.
A systematic testing strategy catches each of these failure modes before they reach production.
Changelog Structure and Conventions
Liquibase changelogs are organized in a master file that includes child changelogs by path or glob:
<!-- db/changelog/db.changelog-master.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<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-4.27.xsd">
<include file="db/changelog/changes/001-create-users.xml"/>
<include file="db/changelog/changes/002-add-email-index.xml"/>
<include file="db/changelog/changes/003-create-orders.xml"/>
</databaseChangeLog>Each changeset within a changelog file has a unique id and author pair that Liquibase uses to track which changesets have been applied:
<!-- db/changelog/changes/001-create-users.xml -->
<databaseChangeLog ...>
<changeSet id="001" author="alice">
<createTable tableName="users">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="email" type="VARCHAR(255)">
<constraints nullable="false" unique="true"/>
</column>
<column name="created_at" type="TIMESTAMP WITH TIME ZONE"
defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
<rollback>
<dropTable tableName="users"/>
</rollback>
</changeSet>
</databaseChangeLog>Layer 1: Dry-Run Validation with updateSQL
The fastest sanity check costs zero database startup time. liquibase updateSQL generates the SQL Liquibase would execute without applying it:
liquibase \
--changeLogFile=db/changelog/db.changelog-master.xml \
--url=jdbc:postgresql://localhost:5432/testdb \
--username=testuser \
--password=testpass \
updateSQL > migration-preview.sql
# Review the output
<span class="hljs-built_in">cat migration-preview.sqlFor CI, pipe the output through a basic validation script:
#!/bin/bash
<span class="hljs-comment"># validate-migration-sql.sh
OUTPUT=$(liquibase \
--changeLogFile=db/changelog/db.changelog-master.xml \
--url=jdbc:h2:mem:testdb \
--username=sa \
--password= \
updateSQL 2>&1)
EXIT_CODE=$?
<span class="hljs-keyword">if [ <span class="hljs-variable">$EXIT_CODE -ne 0 ]; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"ERROR: Liquibase updateSQL failed"
<span class="hljs-built_in">echo <span class="hljs-string">"$OUTPUT"
<span class="hljs-built_in">exit 1
<span class="hljs-keyword">fi
<span class="hljs-comment"># Check for suspicious patterns
<span class="hljs-keyword">if <span class="hljs-built_in">echo <span class="hljs-string">"$OUTPUT" <span class="hljs-pipe">| grep -qi <span class="hljs-string">"DROP TABLE\|DROP COLUMN"; <span class="hljs-keyword">then
<span class="hljs-built_in">echo <span class="hljs-string">"WARNING: Destructive operations detected in migration:"
<span class="hljs-built_in">echo <span class="hljs-string">"$OUTPUT" <span class="hljs-pipe">| grep -i <span class="hljs-string">"DROP TABLE\|DROP COLUMN"
<span class="hljs-comment"># Uncomment to make destructive operations a hard failure:
<span class="hljs-comment"># exit 1
<span class="hljs-keyword">fi
<span class="hljs-built_in">echo <span class="hljs-string">"Migration SQL validation passed."This catches XML parse errors, missing column type definitions, and unsupported Liquibase change types before any database is involved.
Layer 2: Changelog Parsing Tests
Liquibase's Java API lets you parse and validate changelogs in unit tests without a database connection:
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
<version>4.27.0</version>
</dependency>@Test
void changelogParsesWithoutErrors() throws Exception {
ResourceAccessor resourceAccessor = new ClassLoaderResourceAccessor();
DatabaseChangeLog changelog = new ChangeLogParserFactory()
.getParser("db/changelog/db.changelog-master.xml", resourceAccessor)
.parse("db/changelog/db.changelog-master.xml",
new ChangeLogParameters(),
resourceAccessor);
assertThat(changelog).isNotNull();
assertThat(changelog.getChangeSets()).isNotEmpty();
}
@Test
void everyChangeSetHasAnAuthor() throws Exception {
// ... parse changelog as above
for (ChangeSet changeSet : changelog.getChangeSets()) {
assertThat(changeSet.getAuthor())
.withFailMessage("ChangeSet %s has no author", changeSet.getId())
.isNotBlank();
}
}
@Test
void noChangeSetIdIsDuplicated() throws Exception {
// ... parse changelog
List<String> ids = changelog.getChangeSets().stream()
.map(cs -> cs.getId() + ":" + cs.getAuthor())
.collect(Collectors.toList());
Set<String> unique = new HashSet<>(ids);
assertThat(unique).hasSameSizeAs(ids);
}These tests run in milliseconds and catch the structural errors that would otherwise surface only when Liquibase tries to build its execution plan.
Layer 3: Integration Tests with Testcontainers
For full integration testing against a real database, Testcontainers is the right tool. The @QuarkusTest or Spring Boot @SpringBootTest approach both work, but here's a framework-independent pattern:
@Testcontainers
class LiquibaseIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("testuser")
.withPassword("testpass");
private static Liquibase liquibase;
private static Connection connection;
@BeforeAll
static void setUpLiquibase() throws Exception {
connection = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword()
);
Database database = DatabaseFactory.getInstance()
.findCorrectDatabaseImplementation(new JdbcConnection(connection));
liquibase = new Liquibase(
"db/changelog/db.changelog-master.xml",
new ClassLoaderResourceAccessor(),
database
);
}
@AfterAll
static void tearDown() throws Exception {
if (connection != null) connection.close();
}
@BeforeEach
void applyMigrations() throws Exception {
liquibase.dropAll();
liquibase.update(new Contexts(), new LabelExpression());
}
@Test
void allMigrationsApplySuccessfully() throws Exception {
// If we got here, migrations applied without exception
DatabaseSnapshot snapshot = SnapshotGeneratorFactory.getInstance()
.createSnapshot(
liquibase.getDatabase().getDefaultSchema(),
liquibase.getDatabase(),
new SnapshotControl(liquibase.getDatabase())
);
assertThat(snapshot.get(Table.class)).isNotEmpty();
}
@Test
void usersTableHasCorrectStructure() throws Exception {
try (PreparedStatement ps = connection.prepareStatement(
"SELECT column_name, data_type, is_nullable " +
"FROM information_schema.columns " +
"WHERE table_name = 'users' " +
"ORDER BY ordinal_position")) {
ResultSet rs = ps.executeQuery();
Map<String, String[]> columns = new LinkedHashMap<>();
while (rs.next()) {
columns.put(rs.getString("column_name"),
new String[]{rs.getString("data_type"), rs.getString("is_nullable")});
}
assertThat(columns).containsKeys("id", "email", "created_at");
assertThat(columns.get("email")[1]).isEqualTo("NO"); // NOT NULL
}
}
}For Spring Boot, the cleanest pattern uses @DataJpaTest with replace = AutoConfigureTestDatabase.Replace.NONE:
@SpringBootTest
@Testcontainers
class SpringLiquibaseIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-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.change-log", () -> "classpath:db/changelog/db.changelog-master.xml");
registry.add("spring.liquibase.enabled", () -> "true");
}
@Autowired
private JdbcTemplate jdbcTemplate;
@Test
void databaseSchemaIsCorrectAfterMigration() {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'users'",
Integer.class
);
assertThat(count).isEqualTo(1);
}
}Layer 4: Rollback Testing
Every changeset that modifies production schema should have a tested rollback. Liquibase provides a rollback block within each changeset:
<changeSet id="003" author="bob">
<addColumn tableName="users">
<column name="subscription_tier" type="VARCHAR(20)">
<constraints nullable="false" defaultValue="free"/>
</column>
</addColumn>
<rollback>
<dropColumn tableName="users" columnName="subscription_tier"/>
</rollback>
</changeSet>For changes that Liquibase can auto-generate rollback SQL for (like createTable → dropTable), you don't need an explicit rollback block. But for data changes, you always do:
<changeSet id="004" author="carol">
<sql>
UPDATE users SET subscription_tier = 'legacy'
WHERE created_at < '2024-01-01';
</sql>
<rollback>
<sql>
UPDATE users SET subscription_tier = 'free'
WHERE created_at < '2024-01-01';
</sql>
</rollback>
</changeSet>Test both forward and rollback in a single test:
@Test
void rollbackRestoresSchemaToBaselineState() throws Exception {
// Apply all migrations
liquibase.update(new Contexts(), new LabelExpression());
// Verify the column exists after forward migration
assertColumnExists("subscription_tier");
// Roll back the last changeset
liquibase.rollback(1, new Contexts(), new LabelExpression());
// Verify the column is gone after rollback
assertColumnNotExists("subscription_tier");
}
@Test
void rollbackToTagRestoresSchemaCorrectly() throws Exception {
// Tag the database at a known point
liquibase.tag("v1.0");
// Apply more migrations
liquibase.update(new Contexts(), new LabelExpression());
// Roll back to the tagged state
liquibase.rollback("v1.0", new Contexts(), new LabelExpression());
// Verify schema matches the v1.0 state
assertColumnNotExists("subscription_tier");
assertTableExists("users");
}Layer 5: Precondition Testing
Preconditions are conditions that must be true before a changeset runs. They guard against environmental assumptions that could cause a changeset to fail or silently corrupt data.
<changeSet id="005" author="dave">
<!-- Only run this if the column doesn't already exist -->
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="users" columnName="phone_number"/>
</not>
</preConditions>
<addColumn tableName="users">
<column name="phone_number" type="VARCHAR(20)"/>
</addColumn>
<rollback>
<dropColumn tableName="users" columnName="phone_number"/>
</rollback>
</changeSet>The onFail attribute controls behavior when the precondition fails:
HALT— stop all processing (default)MARK_RAN— mark the changeset as executed without running it (useful for idempotent changesets)CONTINUE— skip this changeset and continueWARN— log a warning and continue
Test precondition behavior explicitly:
@Test
void changesetIsSkippedWhenPreconditionFails() throws Exception {
// Manually create the column that the precondition checks for
try (Statement stmt = connection.createStatement()) {
stmt.execute("ALTER TABLE users ADD COLUMN phone_number VARCHAR(20)");
}
// Apply migrations — changeset 005 should be MARK_RAN due to precondition
liquibase.update(new Contexts(), new LabelExpression());
// Verify changeset is in DATABASECHANGELOG with execType = MARK_RAN
try (PreparedStatement ps = connection.prepareStatement(
"SELECT exectype FROM databasechangelog WHERE id = '005'")) {
ResultSet rs = ps.executeQuery();
assertThat(rs.next()).isTrue();
assertThat(rs.getString("exectype")).isEqualTo("MARK_RAN");
}
}Validating the DATABASECHANGELOG Table
Liquibase tracks applied changesets in DATABASECHANGELOG. Testing its state is a useful sanity check:
@Test
void allExpectedChangesetsAreTracked() throws Exception {
liquibase.update(new Contexts(), new LabelExpression());
List<String> appliedIds = new ArrayList<>();
try (PreparedStatement ps = connection.prepareStatement(
"SELECT id FROM databasechangelog ORDER BY dateexecuted")) {
ResultSet rs = ps.executeQuery();
while (rs.next()) {
appliedIds.add(rs.getString("id"));
}
}
assertThat(appliedIds).containsExactly("001", "002", "003", "004", "005");
}
@Test
void noChangesetHasFailedExecType() throws Exception {
liquibase.update(new Contexts(), new LabelExpression());
try (PreparedStatement ps = connection.prepareStatement(
"SELECT id, exectype FROM databasechangelog WHERE exectype = 'FAILED'")) {
ResultSet rs = ps.executeQuery();
assertThat(rs.next())
.withFailMessage("Found changesets with FAILED exectype")
.isFalse();
}
}CI Integration
A complete GitHub Actions workflow for Liquibase changelog testing:
name: Liquibase Changelog Tests
on:
push:
paths:
- 'src/main/resources/db/changelog/**'
- 'src/test/**'
pull_request:
jobs:
changelog-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Dry-run changelog validation
run: |
mvn liquibase:updateSQL \
-Dliquibase.url=jdbc:postgresql://localhost:5432/testdb \
-Dliquibase.username=testuser \
-Dliquibase.password=testpass \
-Dliquibase.changeLogFile=db/changelog/db.changelog-master.xml
- name: Run changelog unit tests
run: ./mvnw test -Dtest=LiquibaseChangelogTest -pl :your-module
- name: Run integration tests
run: ./mvnw test -Dtest=LiquibaseIntegrationTest -pl :your-module
env:
SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/testdb
SPRING_DATASOURCE_USERNAME: testuser
SPRING_DATASOURCE_PASSWORD: testpass
- name: Verify rollbacks
run: ./mvnw test -Dtest=LiquibaseRollbackTest -pl :your-module
- name: Validate status
run: |
mvn liquibase:validate \
-Dliquibase.url=jdbc:postgresql://localhost:5432/testdb \
-Dliquibase.username=testuser \
-Dliquibase.password=testpassUsing Contexts and Labels for Environment-Specific Testing
Liquibase contexts let you tag changesets for specific environments:
<changeSet id="006" author="eve" context="development,test">
<loadData tableName="users" file="db/seed/test-users.csv" separator=","/>
<rollback>
<delete tableName="users"/>
</rollback>
</changeSet>In tests, use the development context to include seed data:
@Test
void seedDataIsAppliedInTestContext() throws Exception {
liquibase.update(new Contexts("test"), new LabelExpression());
Integer userCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users", Integer.class);
assertThat(userCount).isGreaterThan(0);
}
@Test
void seedDataIsNotAppliedInProductionContext() throws Exception {
liquibase.update(new Contexts("production"), new LabelExpression());
Integer userCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users", Integer.class);
assertThat(userCount).isEqualTo(0);
}Testing YAML Changelogs
Not all teams use XML. YAML changelogs are increasingly common and test identically:
# db/changelog/changes/007-add-audit-fields.yaml
databaseChangeLog:
- changeSet:
id: "007"
author: "frank"
changes:
- addColumn:
tableName: users
columns:
- column:
name: last_login_at
type: TIMESTAMP WITH TIME ZONE
- column:
name: login_count
type: INT
defaultValueNumeric: 0
constraints:
nullable: false
rollback:
- dropColumn:
tableName: users
columnName: last_login_at
- dropColumn:
tableName: users
columnName: login_countThe same Java Liquibase API handles YAML, XML, JSON, and SQL changelogs transparently.
What a Complete Test Suite Looks Like
Here is the recommended test class organization:
src/test/java/
db/
LiquibaseChangelogParseTest.java # Fast, no DB: parses XML, checks ids/authors
LiquibaseH2MigrationTest.java # Fast: applies migrations to H2 in-memory
LiquibasePostgresMigrationTest.java # Slow: applies migrations to Testcontainers PG
LiquibaseRollbackTest.java # Slow: tests forward + rollback on each changeset
LiquibasePreconditionTest.java # Slow: validates precondition skip/halt behavior
LiquibaseContextTest.java # Medium: tests context-specific changesetsRun the fast tests on every local build. Run the Testcontainers tests in CI and before merging to main. The rollback and precondition tests are the most valuable for preventing production incidents — run them on every changelog change.
HelpMeTest can run your Liquibase migration tests automatically on every pull request, catching changelog errors before they reach production — sign up free