Миграции баз данных - это критические операции, которые могут сделать или сломать развертывания в продакшене. Как обсуждается в 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);
  });
});

Лучшие практики тестирования миграций

Чеклист тестирования

  • Тестировать миграции на чистой базе данных
  • Тестировать миграции на базе данных с существующими данными
  • Проверить что все индексы созданы правильно
  • Тестировать ограничения внешних ключей
  • Валидировать значения по умолчанию и ограничения
  • Тестировать процедуры отката
  • Проверить целостность данных после миграции
  • Тестировать производительность больших миграций данных
  • Проверить проблемы блокировки на больших таблицах
  • Тестировать миграции на множественных версиях баз данных
  • Валидировать контрольные суммы миграций
  • Тестировать сценарии конкурентных миграций

Сравнение тестирования миграций

АспектFlywayLiquibase
Поддержка откатаРучные миграции отменыВстроенный откат
ФорматSQL файлыXML, YAML, JSON, SQL
Кривая обученияНизкая (знакомый SQL)Средняя (XML синтаксис)
Контроль версийВерсионирование на основе файловID changesets
Условная логикаОграниченнаяОбширная (preconditions)
Поддержка БДШирокаяШирокая
Генерация DiffТолько коммерческаяВстроенная

Заключение

Эффективное тестирование миграций баз данных требует всеобъемлющих стратегий, охватывающих валидацию схемы, целостность данных, процедуры отката и паттерны развертывания без простоя. Реализуя тщательное тестирование с Flyway или Liquibase, интегрируя тесты в CI/CD pipelines и следуя лучшим практикам, вы можете обеспечить безопасные и надежные изменения баз данных.

Ключевые выводы:

  • Всегда тестировать миграции на чистой базе данных и с существующими данными
  • Реализовывать и тестировать процедуры отката для каждой миграции
  • Использовать паттерн расширение-сжатие для развертываний без простоя
  • Валидировать целостность данных до и после миграций
  • Тестировать на множественных версиях баз данных
  • Автоматизировать тестирование миграций в CI/CD pipelines

Надежное тестирование миграций создает уверенность в изменениях баз данных и предотвращает дорогостоящие инциденты в продакшене.