Миграции баз данных - это критические операции, которые могут сделать или сломать развертывания в продакшене. Как обсуждается в API Testing Architecture: From Monoliths to Microservices, правильное тестирование баз данных необходимо для поддержания надежности системы. Это всеобъемлющее руководство охватывает стратегии тестирования для миграций баз данных с использованием Flyway и Liquibase, обеспечивая безопасные изменения схемы и развертывания без простоя.
Понимание вызовов миграций баз данных
Миграции баз данных представляют уникальные вызовы тестирования:
- Необратимость: Многие изменения схемы невозможно легко отменить
- Сохранение данных: Существующие данные должны оставаться нетронутыми и доступными
- Требования нулевого простоя: Продакшен-системы часто не могут позволить окна обслуживания
- Совместимость версий: Код приложения должен работать с множественными версиями схемы
- Влияние на производительность: Большие миграции могут блокировать таблицы и ухудшать производительность (узнайте больше о тестировании производительности в Тестирование производительности баз данных)
Настройка и конфигурация Flyway
Flyway использует миграции на основе SQL с контролем версий:
# Установить Flyway CLI
brew install flyway
# Инициализировать проект Flyway
flyway init
# Создать структуру директории миграции
mkdir -p db/migration
Конфигурация Flyway (flyway.conf):
flyway.url=jdbc:postgresql://localhost:5432/mydb
flyway.user=dbuser
flyway.password=dbpassword
flyway.schemas=public
flyway.locations=filesystem:db/migration
flyway.validateOnMigrate=true
flyway.outOfOrder=false
flyway.baselineOnMigrate=true
Пример миграции (V1__create_users_table.sql):
-- V1__create_users_table.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
username VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_username ON users(username);
Тестирование миграции с Flyway:
// flyway-test.js
const { Client } = require('pg');
const { execSync } = require('child_process');
describe('Миграции Flyway', () => {
let client;
beforeAll(async () => {
// Очистить базу данных
execSync('flyway clean -configFiles=flyway-test.conf');
client = new Client({
host: 'localhost',
port: 5432,
database: 'test_db',
user: 'test_user',
password: 'test_password'
});
await client.connect();
});
test('должна применить миграции успешно', () => {
const result = execSync('flyway migrate -configFiles=flyway-test.conf');
expect(result.toString()).toContain('Successfully applied');
});
test('должна создать таблицу users с правильной схемой', async () => {
const result = await client.query(`
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'users'
ORDER BY ordinal_position
`);
const columns = result.rows;
expect(columns).toContainEqual({
column_name: 'id',
data_type: 'integer',
is_nullable: 'NO'
});
expect(columns).toContainEqual({
column_name: 'email',
data_type: 'character varying',
is_nullable: 'NO'
});
});
test('должна иметь правильные индексы', async () => {
const result = await client.query(`
SELECT indexname
FROM pg_indexes
WHERE tablename = 'users'
`);
const indexNames = result.rows.map(r => r.indexname);
expect(indexNames).toContain('idx_users_email');
expect(indexNames).toContain('idx_users_username');
});
afterAll(async () => {
await client.end();
});
});
Настройка и конфигурация Liquibase
Liquibase использует changesets XML, YAML, JSON или SQL:
<!-- liquibase.properties -->
changeLogFile=db/changelog/db.changelog-master.xml
url=jdbc:postgresql://localhost:5432/mydb
username=dbuser
password=dbpassword
driver=org.postgresql.Driver
Пример 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.0.xsd">
<changeSet id="1" author="developer">
<createTable tableName="products">
<column name="id" type="SERIAL">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="VARCHAR(255)">
<constraints nullable="false"/>
</column>
<column name="price" type="DECIMAL(10,2)">
<constraints nullable="false"/>
</column>
<column name="stock" type="INTEGER" defaultValue="0"/>
</createTable>
</changeSet>
<changeSet id="2" author="developer">
<createIndex tableName="products" indexName="idx_products_name">
<column name="name"/>
</createIndex>
</changeSet>
<changeSet id="3" author="developer">
<addColumn tableName="products">
<column name="category" type="VARCHAR(100)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
Тестирование Liquibase:
// liquibase-test.js
const { execSync } = require('child_process');
const { Client } = require('pg');
describe('Миграции Liquibase', () => {
let client;
beforeAll(async () => {
client = new Client({
connectionString: 'postgresql://test_user:test_password@localhost:5432/test_db'
});
await client.connect();
// Очистить базу данных
execSync('liquibase dropAll --defaultsFile=liquibase-test.properties');
});
test('должна применить все changesets', () => {
const result = execSync('liquibase update --defaultsFile=liquibase-test.properties');
expect(result.toString()).toContain('successfully');
});
test('должна отслеживать выполнение changesets', async () => {
const result = await client.query('SELECT * FROM databasechangelog');
expect(result.rows.length).toBeGreaterThan(0);
expect(result.rows[0]).toHaveProperty('id');
expect(result.rows[0]).toHaveProperty('author');
expect(result.rows[0]).toHaveProperty('filename');
});
test('должна создать таблицу products со всеми колонками', async () => {
const result = await client.query(`
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'products'
`);
const columnNames = result.rows.map(r => r.column_name);
expect(columnNames).toContain('id');
expect(columnNames).toContain('name');
expect(columnNames).toContain('price');
expect(columnNames).toContain('stock');
expect(columnNames).toContain('category');
});
afterAll(async () => {
await client.end();
});
});
Стратегии тестирования отката
Тестирование возможностей отката критически важно для безопасности продакшена:
Тестирование отката Flyway
Flyway не поддерживает автоматические откаты, но вы можете создавать миграции отмены:
-- V2__add_status_column.sql
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
-- U2__remove_status_column.sql (миграция отмены)
ALTER TABLE users DROP COLUMN status;
Тестирование отката:
describe('Откат Flyway', () => {
test('должна откатить миграцию успешно', async () => {
// Применить миграцию
execSync('flyway migrate -configFiles=flyway-test.conf');
// Проверить существование колонки
let result = await client.query(`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'status'
`);
expect(result.rows.length).toBe(1);
// Применить миграцию отмены
execSync('flyway undo -configFiles=flyway-test.conf');
// Проверить удаление колонки
result = await client.query(`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'status'
`);
expect(result.rows.length).toBe(0);
});
});
Тестирование отката Liquibase
Liquibase имеет встроенную поддержку отката:
<changeSet id="4" author="developer">
<addColumn tableName="products">
<column name="description" type="TEXT"/>
</addColumn>
<rollback>
<dropColumn tableName="products" columnName="description"/>
</rollback>
</changeSet>
Тестирование отката Liquibase:
describe('Откат Liquibase', () => {
test('должна откатиться к предыдущей версии', async () => {
// Получить текущую версию
const versionBefore = await client.query(
'SELECT COUNT(*) FROM databasechangelog'
);
// Применить новый changeset
execSync('liquibase update --defaultsFile=liquibase-test.properties');
// Откатить один changeset
execSync('liquibase rollbackCount 1 --defaultsFile=liquibase-test.properties');
// Проверить откат
const versionAfter = await client.query(
'SELECT COUNT(*) FROM databasechangelog'
);
expect(versionAfter.rows[0].count).toBe(versionBefore.rows[0].count);
});
test('должна откатиться по тегу', async () => {
// Пометить текущее состояние
execSync('liquibase tag version-1.0 --defaultsFile=liquibase-test.properties');
// Применить больше изменений
execSync('liquibase update --defaultsFile=liquibase-test.properties');
// Откатиться к тегу
execSync('liquibase rollback version-1.0 --defaultsFile=liquibase-test.properties');
// Проверить соответствие состояния тегу
const result = await client.query(
"SELECT tag FROM databasechangelog WHERE tag = 'version-1.0'"
);
expect(result.rows.length).toBeGreaterThan(0);
});
});
Валидация целостности данных
Убедитесь, что миграции сохраняют и правильно трансформируют данные. Для более глубокого понимания стратегий тестирования баз данных, обратитесь к нашей статье Глубокое погружение в тестирование баз данных:
// data-integrity-test.js
describe('Целостность данных во время миграции', () => {
beforeEach(async () => {
// Вставить тестовые данные
await client.query(`
INSERT INTO users (email, username)
VALUES
('user1@example.com', 'user1'),
('user2@example.com', 'user2'),
('user3@example.com', 'user3')
`);
});
test('должна сохранить существующие данные во время миграции', async () => {
const countBefore = await client.query('SELECT COUNT(*) FROM users');
// Применить миграцию, которая добавляет колонку
execSync('flyway migrate -configFiles=flyway-test.conf');
const countAfter = await client.query('SELECT COUNT(*) FROM users');
expect(countAfter.rows[0].count).toBe(countBefore.rows[0].count);
});
test('должна правильно трансформировать данные', async () => {
// Миграция: V3__split_username.sql
// Разделяет 'username' на 'first_name' и 'last_name'
await client.query(`
UPDATE users SET username = 'John Doe' WHERE email = 'user1@example.com'
`);
execSync('flyway migrate -target=3 -configFiles=flyway-test.conf');
const result = await client.query(`
SELECT first_name, last_name
FROM users
WHERE email = 'user1@example.com'
`);
expect(result.rows[0].first_name).toBe('John');
expect(result.rows[0].last_name).toBe('Doe');
});
test('должна правильно обрабатывать null значения', async () => {
await client.query(`
INSERT INTO users (email, username) VALUES ('null@example.com', NULL)
`);
execSync('flyway migrate -configFiles=flyway-test.conf');
const result = await client.query(`
SELECT * FROM users WHERE email = 'null@example.com'
`);
expect(result.rows.length).toBe(1);
expect(result.rows[0].username).toBeNull();
});
afterEach(async () => {
await client.query('TRUNCATE users CASCADE');
});
});
Тестирование миграций в CI/CD
Интегрировать тестирование миграций в ваш CI/CD pipeline. Для полного руководства по настройке CI/CD для тестирования, смотрите наше руководство CI/CD Pipeline для тестировщиков:
# .github/workflows/db-migration-test.yml
name: Тесты миграции базы данных
on:
pull_request:
paths:
- 'db/migration/**'
- 'db/changelog/**'
jobs:
test-migrations:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:14
env:
POSTGRES_DB: test_db
POSTGRES_USER: test_user
POSTGRES_PASSWORD: test_password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Настроить Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Установить Flyway
run: |
wget -qO- https://repo1.maven.org/maven2/org/flywaydb/flyway-commandline/9.16.0/flyway-commandline-9.16.0-linux-x64.tar.gz | tar xvz
sudo ln -s `pwd`/flyway-9.16.0/flyway /usr/local/bin
- name: Запустить тесты миграции
run: |
npm install
npm run test:migrations
env:
DB_HOST: localhost
DB_PORT: 5432
DB_NAME: test_db
DB_USER: test_user
DB_PASSWORD: test_password
- name: Тестировать откат
run: npm run test:rollback
- name: Валидировать контрольные суммы миграций
run: flyway validate -configFiles=flyway-test.conf
Паттерны развертывания без простоя
Паттерн Расширение-Сжатие
// Фаза 1: Расширение - Добавить новую колонку (обратно совместимо)
// V4__add_email_verified_column.sql
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT FALSE;
// Фаза 2: Мигрировать данные
// V5__migrate_email_verification.sql
UPDATE users
SET email_verified = (
SELECT EXISTS(
SELECT 1 FROM email_verifications
WHERE email_verifications.user_id = users.id
)
);
// Фаза 3: Сжатие - Удалить старую таблицу (после развертывания)
// V6__remove_email_verifications_table.sql
DROP TABLE email_verifications;
Тестирование миграции без простоя:
describe('Миграция без простоя', () => {
test('приложение работает во время фазы расширения', async () => {
// Применить миграцию расширения
execSync('flyway migrate -target=4 -configFiles=flyway-test.conf');
// Симулировать старый код приложения
await client.query(`
INSERT INTO users (email, username)
VALUES ('test@example.com', 'testuser')
`);
// Проверить что работают и старая и новая схема
const result = await client.query('SELECT * FROM users WHERE email = $1', [
'test@example.com'
]);
expect(result.rows[0]).toHaveProperty('email');
expect(result.rows[0]).toHaveProperty('email_verified');
});
test('миграция данных сохраняет целостность', async () => {
// Вставить тестовые данные
await client.query(`
INSERT INTO users (email, username) VALUES ('user@example.com', 'user')
`);
await client.query(`
INSERT INTO email_verifications (user_id) VALUES (1)
`);
// Применить миграцию данных
execSync('flyway migrate -target=5 -configFiles=flyway-test.conf');
const result = await client.query('SELECT email_verified FROM users WHERE id = 1');
expect(result.rows[0].email_verified).toBe(true);
});
});
Лучшие практики тестирования миграций
Чеклист тестирования
- Тестировать миграции на чистой базе данных
- Тестировать миграции на базе данных с существующими данными
- Проверить что все индексы созданы правильно
- Тестировать ограничения внешних ключей
- Валидировать значения по умолчанию и ограничения
- Тестировать процедуры отката
- Проверить целостность данных после миграции
- Тестировать производительность больших миграций данных
- Проверить проблемы блокировки на больших таблицах
- Тестировать миграции на множественных версиях баз данных
- Валидировать контрольные суммы миграций
- Тестировать сценарии конкурентных миграций
Сравнение тестирования миграций
Аспект | Flyway | Liquibase |
---|---|---|
Поддержка отката | Ручные миграции отмены | Встроенный откат |
Формат | SQL файлы | XML, YAML, JSON, SQL |
Кривая обучения | Низкая (знакомый SQL) | Средняя (XML синтаксис) |
Контроль версий | Версионирование на основе файлов | ID changesets |
Условная логика | Ограниченная | Обширная (preconditions) |
Поддержка БД | Широкая | Широкая |
Генерация Diff | Только коммерческая | Встроенная |
Заключение
Эффективное тестирование миграций баз данных требует всеобъемлющих стратегий, охватывающих валидацию схемы, целостность данных, процедуры отката и паттерны развертывания без простоя. Реализуя тщательное тестирование с Flyway или Liquibase, интегрируя тесты в CI/CD pipelines и следуя лучшим практикам, вы можете обеспечить безопасные и надежные изменения баз данных.
Ключевые выводы:
- Всегда тестировать миграции на чистой базе данных и с существующими данными
- Реализовывать и тестировать процедуры отката для каждой миграции
- Использовать паттерн расширение-сжатие для развертываний без простоя
- Валидировать целостность данных до и после миграций
- Тестировать на множественных версиях баз данных
- Автоматизировать тестирование миграций в CI/CD pipelines
Надежное тестирование миграций создает уверенность в изменениях баз данных и предотвращает дорогостоящие инциденты в продакшене.