Если вы используете mock для базы данных в тестах, вы проверяете то, что ваш repository layer вызывает правильные методы. Вы не проверяете то, что данные переживут сбой, что уникальное ограничение действительно блокирует дубликаты, или что транзакция откатывается при ошибке.

Это различие имеет значение. Замоканный save() возвращает то, что вы ему сказали вернуть. Настоящий save() может вызвать deadlock, скрытно обрезать строку или записать в реплику, которая отстаёт от primary на три секунды. Ваш test suite будет зелёным, пока ваши пользователи теряют данные.

Почему mock-тесты базы данных дают ложную уверенность

Большинство persistence-тестов выглядят так:

// A test that verifies syntax, not physics
jest.mock('./db', () => ({
  query: jest.fn().mockResolvedValue({ rows: [{ id: 1 }] }),
}));

it('saves a user', async () => {
  const repo = new UserRepository();
  const user = await repo.create({ name: 'Ada' });
  expect(user.id).toBe(1);
});

Этот тест доказывает, что ваш SQL синтаксически валиден и ваш mapper не выбрасывает исключение. Он ничего не доказывает про persistence.

Настоящие опасности живут в разрыве между «запрос выполнился» и «данные надёжны». Ваш ORM сбрасывает write buffer перед возвратом? Ваш пул соединений переиспользует транзакцию, которая уже откатилась? ON CONFLICT DO NOTHING проглатывает insert, который вы ожидали увидеть успешным?

Вы не найдёте эти баги с mock-ом, который возвращает { id: 1 } по команде.

Round-trip test: простейший честный тест

Самый базовый persistence-тест — это также самый честный. Запишите данные, прочитайте их обратно, проверьте, что они совпадают.

import { createConnection } from './db';

it('persists and retrieves a user', async () => {
  const db = await createConnection();
  const repo = new UserRepository(db);

  await repo.create({ id: 'user-1', name: 'Ada', email: 'ada@example.com' });

  const found = await repo.findById('user-1');
  expect(found).toEqual({
    id: 'user-1',
    name: 'Ada',
    email: 'ada@example.com',
  });
});

Этот тест ловит очевидные сбои: потерянные коммиты, неверные маппинги колонок, баги сериализации. Он также ловит тонкие. Если ваш create хеширует email перед записью, а findById не хеширует перед запросом, этот тест упадёт. Замоканный — нет.

Тестирование ограничений там, где они живут

Ограничения базы данных — это логика. Они заслуживают тестов так же, как и любая другая ветка в вашем коде.

it('enforces unique emails at the database level', async () => {
  const db = await createConnection();
  const repo = new UserRepository(db);

  await repo.create({ id: 'user-1', email: 'ada@example.com' });

  await expect(
    repo.create({ id: 'user-2', email: 'ada@example.com' })
  ).rejects.toThrow(/unique constraint/i);
});

Этот тест документирует бизнес-правило в единственном месте, которое его реально обеспечивает. Если кто-то позже удалит уникальный индекс, чтобы «починить» flaky migration, этот тест заорёт. Mock спокойно пропустит двух пользователей с одним email, потому что у mock-а нет понятия индекса.

Поведение транзакций — это не теория

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

it('rolls back the entire transfer on failure', async () => {
  const db = await createConnection();
  const accounts = new AccountRepository(db);

  await accounts.create({ id: 'A', balance: 100 });
  await accounts.create({ id: 'B', balance: 50 });

  // Force a failure mid-transaction by violating a check constraint
  const transfer = db.transaction(async (trx) => {
    const acc = new AccountRepository(trx);
    await acc.debit('A', 30); // succeeds
    await acc.debit('B', 9999); // exceeds balance, throws
  });

  await expect(transfer).rejects.toThrow();

  const a = await accounts.findById('A');
  const b = await accounts.findById('B');
  expect(a.balance).toBe(100); // rollback preserved original state
  expect(b.balance).toBe(50);
});

Это тот тест, который спасает вас от пейджа в три часа ночи, когда счета теряют деньги, потому что downstream API получил timeout, а debit закоммитился, а credit — нет. Mock-и не могут моделировать transaction isolation. Только настоящая база данных может.

Изоляция: сложная часть

Возражение, которое все выдвигают, — это скорость и нестабильность. Если каждый тест бьёт в PostgreSQL, ваш suite будет ползти, и тесты будут мешать друг другу.

Есть три практических ответа, и вы, вероятно, используете все три.

Template databases. PostgreSQL и большинство серьёзных СУБД поддерживают CREATE DATABASE ... TEMPLATE. Вы инициализируете одну базу данных со своей схемой, затем клонируете её на каждый test file за миллисекунды.

// test-setup.ts
import { execSync } from 'child_process';

let templateCreated = false;

export async function createIsolatedDatabase() {
  if (!templateCreated) {
    execSync('createdb test_template');
    execSync('psql test_template -f schema.sql');
    templateCreated = true;
  }
  const dbName = `test_${process.pid}_${Date.now()}`;
  execSync(`createdb -T test_template ${dbName}`);
  return connect(dbName);
}

Каждый test file получает свежую базу данных. Никакой логики очистки не нужно. В конце прогона дропните клоны.

Транзакции как границы изоляции. Если template databases слишком тяжеловесны, запускайте каждый тест внутри транзакции и откатывайте её в конце. Большинство клиентов баз данных делают это явно.

afterEach(async () => {
  await db.query('ROLLBACK');
});

it('inserts a row', async () => {
  await db.query('BEGIN');
  await repo.create({ id: 'x' });
  expect(await repo.findById('x')).toBeDefined();
  // rolled back by afterEach
});

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

Testcontainers для CI parity. Если ваши тесты бегут против SQLite локально, а PostgreSQL в продакшене, вы тестируете другую базу данных. Используйте Testcontainers, чтобы поднять настоящую базу данных в Docker для каждого CI run.

import { PostgreSqlContainer } from '@testcontainers/postgresql';

const container = await new PostgreSqlContainer().start();
const db = await createConnection(container.getConnectionUri());

Это добавляет 2-3 секунды к test run. Это устраняет целый класс багов, которые воспроизводятся только на Postgres 15 с standard_conforming_strings = on.

Что mock-ать, а что нет

Я не призываю к zero mocks. Mock-и корректны на границах, которые вы не контролируете: Stripe API calls, S3 uploads, SMTP servers. Они медленные, дорогие и rate-limited.

Ваша база данных — это не внешняя граница в том же смысле. Это инфраструктура, которую вы оперируете. Её поведение детерминировано. Её схема — код, который вы написали. Мокинг её означает тестирование против fantasy version системы, которую вы реально запускаете.

Компромисс — скорость. Настоящий тест базы данных занимает 20-50 мс. Замоканный — 2-5 мс. Если у вас десять тысяч persistence tests, эта разница имеет значение. У большинства команд не десять тысяч persistence tests. У них сто, и честность стоит ожидания.

Schema drift tests ловят баги, которые code reviews пропускают

Есть ещё один тест, который до смешного эффективен, и почти никто его не пишет. После того как ваши миграции отработали, проверьте, что ваши TypeScript types всё ещё соответствуют схеме.

import { sql } from './db';

it('types match the database schema', async () => {
  const columns = await sql`
    SELECT column_name, data_type, is_nullable
    FROM information_schema.columns
    WHERE table_name = 'users'
  `;

  const userColumns = new Map(columns.map((c) => [c.column_name, c]));

  // If someone adds a NOT NULL column without updating the type, this fails
  expect(userColumns.get('email').is_nullable).toBe('NO');
  expect(userColumns.get('created_at').data_type).toBe('timestamp with time zone');
});

Это тест, который ловит миграцию, добавляющую NOT NULL default к колонке, которую ваш TypeScript всё ещё считает optional. Поддерживать его вручную утомительно, поэтому существуют инструменты вроде kysely-codegen или drizzle-kit generate. Но даже небольшая ручная версия спасёт вас от продакшен-инцидента.

FAQ

Should I delete all my mocked database tests?

Нет. Оставьте mock-и для pure logic tests, где база данных не важна, вроде тестирования того, что service форматирует query string корректно. Замените их настоящими тестами базы данных там, где persistence behavior — это суть.

How do I handle migrations in tests?

Запускайте миграции против вашей template database один раз, затем клонируйте. Или запускайте их на каждый test suite, если они быстрые. Никогда не пропускайте миграции в CI и не надейтесь, что схема совпадает.

What about read replicas?

Если ваше приложение пишет в primary и читает с реплики, ваши тесты должны делать то же самое. Поднимите два connection в Testcontainers и сконфигурируйте routing так же, как в продакшене. Lag-induced stale reads — реальные баги.

Does this apply to NoSQL databases?

Да. Принцип тот же. Замоканный MongoDB client не скажет вам, поддерживает ли ваш compound index query, в который вы верите. Настоящий — скажет.

Запустите один round-trip test сегодня

Возьмите метод репозитория, к которому вы обращаетесь чаще всего. Удалите его mock. Напишите тест, который создаёт строку, читает её обратно и проверяет, что поля совпадают. Замерьте время. Если занимает меньше 50 мс, напишите ещё один.

Большинство команд находит, что настоящие тесты базы данных — не причина, по которой их suite медленный. Причина обычно в запуске headless browser для unit test, или вызове реального payment API в CI, или в отсутствии parallelization test files. Тесты базы данных честны, достаточно быстры и ловят баги, которые лишают вас сна.