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

Миграции в Dexie.js представляют собой последовательные изменения схемы IndexedDB-базы данных, включающие добавление и удаление таблиц, изменение индексов, трансформацию структуры объектов и перенос данных между версиями. Ошибки в миграциях приводят к потере данных, рассинхронизации схемы и нестабильной работе приложения, поэтому тестирование миграционного слоя становится критически важной частью архитектуры.


Модель миграции как тестируемый артефакт

Миграции в Dexie.js реализуются через цепочку версий:

db.version(1).stores({
  users: '++id,name'
});

db.version(2).stores({
  users: '++id,fullName'
}).upgrade(tx => {
  return tx.users.toCollection().modify(user => {
    user.fullName = user.name;
    delete user.name;
  });
});

Каждая версия содержит две ключевые части:

  • Схема (stores) — декларативное описание структуры
  • Миграция (upgrade) — императивное преобразование данных

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


Изоляция тестовой базы

Основной принцип тестирования миграций — полная изоляция состояния IndexedDB между тестами.

Dexie позволяет использовать отдельные имена баз данных:

const createTestDB = (name) => {
  return new Dexie(name);
};

Рекомендуется генерировать уникальные имена баз:

const dbName = `test-db-${Date.now()}-${Math.random()}`;

Это предотвращает утечку состояния между тестами, особенно в средах с параллельным запуском.


Подготовка фиктивного состояния старой версии

Ключевой подход — создание базы с предыдущей версией схемы, заполнение её данными и последующее обновление версии.

const dbV1 = new Dexie('test-db');

dbV1.version(1).stores({
  users: '++id,name'
});

await dbV1.open();

await dbV1.users.bulkAdd([
  { name: 'Alice' },
  { name: 'Bob' }
]);

await dbV1.close();

После этого создаётся новая версия с миграцией:

const dbV2 = new Dexie('test-db');

dbV2.version(1).stores({
  users: '++id,name'
});

dbV2.version(2).stores({
  users: '++id,fullName'
}).upgrade(tx => {
  return tx.users.toCollection().modify(u => {
    u.fullName = u.name;
    delete u.name;
  });
});

await dbV2.open();

Проверка результата миграции

После выполнения миграции необходимо проверять:

  • корректность структуры записей
  • сохранность данных
  • отсутствие дублирования
  • соответствие новой схеме
const users = await dbV2.users.toArray();

console.assert(users[0].fullName === 'Alice');
console.assert(users[1].fullName === 'Bob');

Важно проверять не только положительный сценарий, но и отсутствие старых полей:

console.assert(users[0].name === undefined);

Тестирование сложных трансформаций данных

Миграции часто включают нетривиальные преобразования:

  • разбиение одного поля на несколько
  • агрегация данных
  • нормализация связей
  • изменение формата дат

Пример разбиения поля:

db.version(2).upgrade(tx => {
  return tx.users.toCollection().modify(u => {
    const [first, last] = u.fullName.split(' ');
    u.firstName = first;
    u.lastName = last;
    delete u.fullName;
  });
});

Тест должен проверять граничные случаи:

  • отсутствие пробела
  • несколько пробелов
  • пустые строки
  • null значения

Проверка идемпотентности миграций

Миграция должна быть безопасной при повторном применении логики в тестовом контексте. Хотя Dexie применяет upgrade только один раз, тесты могут многократно пересоздавать окружение.

Идея проверки:

  • один и тот же набор данных
  • повторное создание базы
  • повторное выполнение upgrade

Результат должен быть стабилен:

for (let i = 0; i < 3; i++) {
  await recreateAndMigrate();
  const users = await db.users.toArray();
  assertStable(users);
}

Тестирование цепочек миграций

В реальных проектах редко бывает одна миграция. Чаще используется цепочка версий:

db.version(1)...
db.version(2)...
db.version(3)...
db.version(4)...

Тестирование должно учитывать переходы:

  • 1 → 2
  • 2 → 3
  • 3 → 4
  • 1 → 4 (полная миграция за один шаг)

Особенно важно тестировать пропуск версий, когда пользователь обновляет приложение не последовательно.


Проверка частично повреждённых данных

IndexedDB может содержать неполные или устаревшие данные. Миграции должны учитывать такие сценарии:

  • отсутствующие поля
  • неожиданные типы
  • legacy-структуры

Пример защитной миграции:

db.version(3).upgrade(tx => {
  return tx.users.toCollection().modify(u => {
    if (!u.fullName && u.name) {
      u.fullName = u.name;
    }

    if (typeof u.fullName !== 'string') {
      u.fullName = '';
    }
  });
});

Тестирование таких сценариев требует ручного создания “грязных” данных.


Мокирование IndexedDB и ускорение тестов

Для ускорения тестирования миграций часто используется замена реального IndexedDB на in-memory реализации:

  • fake IndexedDB
  • polyfill среды
  • JSDOM с IndexedDB shim

Это позволяет:

  • ускорить выполнение тестов
  • избежать нестабильности браузерного окружения
  • запускать тесты в CI

Проверка производительности миграций

При больших объёмах данных миграции могут блокировать основной поток. В тестах важно оценивать:

  • время выполнения upgrade
  • количество операций modify
  • нагрузку на память

Типовой подход:

console.time('migration');
await db.open();
console.timeEnd('migration');

Для более точного анализа используется разбиение данных на батчи:

tx.users.toCollection().modify(...)

и контроль размера выборки.


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

Хотя Dexie.js не поддерживает классический rollback миграций, тесты должны учитывать сценарии:

  • частично выполненная миграция
  • ошибка внутри upgrade
  • повторное открытие базы после сбоя

Проверяется поведение:

  • завершённость транзакции
  • консистентность данных
  • отсутствие “полумигрированного” состояния

Тестирование миграций с внешними зависимостями

Иногда upgrade зависит от внешних данных или конфигурации:

  • справочники
  • API-данные
  • локальные настройки

Такие зависимости должны быть замокированы:

const getMapping = () => ({ A: 'Alpha', B: 'Beta' });

db.version(2).upgrade(tx => {
  return tx.items.toCollection().modify(item => {
    item.label = getMapping()[item.code];
  });
});

Тесты фиксируют поведение маппинга, чтобы исключить флаки.


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

Регрессионный подход предполагает:

  • сохранение снапшота данных старой версии
  • прогон миграции при каждом изменении схемы
  • сравнение результата с эталонной структурой

Сравнение может быть структурным:

  • ключи объектов
  • типы полей
  • количество записей

или семантическим:

  • логическое соответствие бизнес-данных

Генерация тестовых сценариев миграций

Для сложных систем эффективно использовать генерацию данных:

  • случайные пользователи
  • синтетические связи
  • вариативные структуры

Это позволяет выявлять неочевидные баги:

  • NaN в числах
  • null в строках
  • несоответствие индексов

Контроль целостности после миграции

Финальный слой проверки связан с целостностью базы:

  • корректность индексов
  • доступность всех таблиц
  • отсутствие “осиротевших” записей
  • соответствие схемы declared stores
console.assert(db.tables.length === expectedTables);

Также проверяется выборочная консистентность:

await db.users.where('id').above(0).toArray();

Изоляция миграций в CI-средах

В CI важно учитывать особенности:

  • параллельный запуск тестов
  • ограниченные ресурсы IndexedDB
  • нестабильность эмуляторов браузера

Практика:

  • использование уникальных имён баз
  • очистка IndexedDB после теста
  • последовательный прогон миграций

Модель покрытия миграций тестами

Покрытие оценивается по нескольким осям:

  • покрытие версий схем
  • покрытие веток upgrade-логики
  • покрытие данных (edge cases)
  • покрытие сценариев обновления (skip versions)

Полное покрытие миграций означает не только проверку кода, но и проверку всех возможных путей эволюции данных внутри базы.