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

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

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


Изоляция окружения и подмена IndexedDB

В Node.js и тестовых рантаймах отсутствует нативный IndexedDB, поэтому базовый слой тестирования строится на подмене реализации:

  • fake-indexeddb
  • dexie с подменённым backend
  • либо гибридные среды (Vitest + jsdom)

Минимальная настройка среды:

import Dexie fr om "dexie";
import "fake-indexeddb/auto";

Важно, что fake-indexeddb должен подключаться до первого создания экземпляра базы, иначе Dexie может закешировать нативный backend (или его отсутствие).


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

Тест миграции всегда разбивается на три фазы:

  1. Создание базы на старой версии схемы
  2. Наполнение тестовыми данными
  3. Апгрейд до новой версии и проверка результата

Пример: эволюция схемы

Версия 1:

const dbV1 = new Dexie("app");

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

Версия 2:

const dbV2 = new Dexie("app");

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

dbV2.version(2).stores({
  users: "id,name,email"
});

Тестирование перехода между версиями

Ключевой момент — открытие базы с принудительным переходом версии:

test("migration from v1 to v2 adds email field", async () => {
  const db = new Dexie("migration-test");

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

  db.version(2).stores({
    users: "id,name,email"
  });

  await db.open();

  await db.table("users").add({ id: 1, name: "Alex" });

  db.close();

Перезапуск с новой версией:

  const upgraded = new Dexie("migration-test");

  upgraded.version(1).stores({
    users: "id,name"
  });

  upgraded.version(2).stores({
    users: "id,name,email"
  });

  await upgraded.open();

  const users = await upgraded.table("users").toArray();

  expect(users[0]).toHaveProperty("name", "Alex");
});

Контроль миграций через upgrade-хуки

Dexie позволяет явно управлять миграцией через upgrade:

db.version(2).upgrade(async (tx) => {
  const users = tx.table("users");

  await users.toCollection().modify(user => {
    user.email = "";
  });
});

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


Проверка преобразования данных

Сложные миграции часто включают трансформации:

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

Пример миграции:

db.version(2).upgrade(async (tx) => {
  const users = tx.table("users");

  for (const user of await users.toArray()) {
    user.fullName = user.name.toUpperCase();
    delete user.name;
    await users.put(user);
  }
});

Тест:

const user = await db.table("users").get(1);

expect(user.fullName).toBe("ALEX");
expect(user.name).toBeUndefined();

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

Миграции должны быть безопасны при повторном запуске логики обновления (в рамках одной версии перехода).

Проверка:

await db.open();
await db.close();
await db.open(); // повторное открытие

const users = await db.table("users").toArray();

expect(users.length).toBe(1);

Изоляция между тестами

Каждый тест миграции обязан работать с чистым экземпляром базы:

afterEach(async () => {
  await Dexie.delete("migration-test");
});

Критично использовать Dexie.delete, а не только db.clear(), поскольку schema cache сохраняется в IndexedDB.


Тестирование downgrade-сценариев

Хотя IndexedDB не поддерживает прямой даунгрейд, можно симулировать сценарии:

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

Пример:

db.version(3).stores({
  users: "id,email"
});

Тест проверяет, что устаревшее поле не используется:

const user = await db.table("users").get(1);

expect(user.name).toBeUndefined();
expect(user.email).toBeDefined();

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

Миграции выполняются в транзакционном контексте, и тест должен учитывать атомарность:

db.version(2).upgrade(async (tx) => {
  const users = tx.table("users");

  await users.clear();
  await users.add({ id: 1, name: "Migrated" });
});

Проверка:

const users = await db.table("users").toArray();

expect(users).toEqual([
  { id: 1, name: "Migrated" }
]);

Симуляция сложных миграций с большими данными

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

function seedUsers(db, count) {
  const batch = [];

  for (let i = 0; i < count; i++) {
    batch.push({ id: i, name: "User" + i });
  }

  return db.table("users").bulkAdd(batch);
}

Тест:

await seedUsers(db, 1000);

db.version(2).upgrade(async (tx) => {
  await tx.table("users").toCollection().modify(u => {
    u.active = true;
  });
});

Проверка:

const all = await db.table("users").toArray();

expect(all.every(u => u.active === true)).toBe(true);

Отладка миграций через логирование версий

Dexie предоставляет информацию о версии базы:

console.log(db.verno);

Полезно в тестах:

expect(db.verno).toBe(2);

Частые ошибки при тестировании миграций

  • Повторное использование одного имени базы без очистки
  • Отсутствие fake-indexeddb в Node окружении
  • Несовпадение схем между версиями в одном тесте
  • Попытка тестировать миграции без перезапуска базы
  • Игнорирование асинхронности upgrade

Стабильные паттерны тестирования

Устойчивый подход строится на следующих принципах:

  • каждая версия схемы описана явно
  • каждый тест запускает миграцию “с нуля”
  • проверка данных после reopen базы
  • обязательное удаление базы после теста
  • минимизация побочных эффектов внутри upgrade

Проверка совместимости старых данных

Особое внимание уделяется сценарию, когда данные уже существуют до миграции:

await db.table("users").add({ id: 1, name: "Legacy" });

await db.close();
await db.open(); // триггер миграции

Проверка:

const user = await db.table("users").get(1);

expect(user.name).toBe("Legacy");

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

Сложные приложения имеют несколько версий подряд:

  • v1 → v2
  • v2 → v3
  • v3 → v4

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


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

IndexedDB может прервать апгрейд при ошибке. Тестирование включает сценарии:

  • исключение в upgrade
  • неконсистентное состояние транзакции
  • повторное открытие базы после падения
db.version(2).upgrade(() => {
  throw new Error("Migration failed");
});

Ожидаемое поведение:

  • база не обновляется
  • предыдущая версия остаётся активной

Тестирование схем с индексами

Изменение индексов требует проверки не только данных, но и возможности запросов:

db.version(2).stores({
  users: "id,name,email,*tags"
});

Проверка:

const result = await db.table("users")
  .wh ere("email")
  .equals("test@mail.com")
  .toArray();

expect(Array.isArray(result)).toBe(true);

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

IndexedDB может блокироваться при одновременных соединениях:

db.on("blocked", () => {
  console.warn("DB blocked during migration");
});

В тестах важно убедиться, что:

  • нет гонок open()
  • база закрыта перед повторным открытием
  • миграция не вызывается параллельно