← назад к разделу

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

Мок на своей границе

Сценарий оформления заказа должен отреагировать на отказ платежного шлюза. Для этого не нужен партнерский сервер: достаточно собственного узкого интерфейса.

interface PaymentGateway {
  charge(command: ChargeCommand): Promise<PaymentResult>;
}

const payments: PaymentGateway = {
  charge: jest.fn().mockResolvedValue({ status: 'declined' }),
};
const checkout = new Checkout(payments, orderStore);

await expect(checkout.place(order)).rejects.toThrow(PaymentDeclined);

PaymentGateway принадлежит приложению и содержит только нужную операцию. Адаптер над реальным SDK проверяется отдельно. Такой мок отвечает на вопрос «что сделает сценарий при отказе», а не «как устроен чужой SDK».

В NestJS зависимость подставляют через useValue при сборке небольшого TestingModule или overrideProvider, если нужен настоящий модуль с одной заменой. Для обычного класса DI-контейнер вообще не нужен: передайте зависимость в конструктор.

Где мок вредит

Не подменяйте DataSource, Repository, QueryBuilder и транзакционный manager ради проверки SQL. Тест начинает описывать вашу фантазию о TypeORM: настоящие типы, блокировки, откаты и numeric в ней отсутствуют.

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

Не проверяйте каждый внутренний вызов. expect(repo.save).toHaveBeenCalled() привязывает тест к способу реализации. Лучше проверить сохраненное состояние. Вызовы важны для исходящего эффекта: письмо отправлено, платеж не повторен, партнер получил ровно один запрос.

Fake вместо длинного мока

Для доменного сценария часто полезна маленькая рабочая реализация порта:

class FakeOrderStore implements OrderStore {
  private readonly items = new Map<string, Order>();

  async save(order: Order): Promise<void> {
    this.items.set(order.id, order);
  }

  async byId(id: string): Promise<Order> {
    const order = this.items.get(id);
    if (!order) throw new OrderNotFound(id);
    return order;
  }
}

Fake действительно сохраняет и находит, поэтому тест не повторяет последовательность методов. Если fake начинает имитировать SQL, изоляцию и курсоры, он стал второй базой и перестал быть упрощением. В этой точке нужен PostgreSQL в Testcontainers.

HTTP-заглушка вместо мока fetch

Мок fetch не проверяет реальную сериализацию, соединение, таймаут и чтение ответа. Локальный HTTP-сервер на свободном порту проверяет весь клиент:

const requests: ReceivedRequest[] = [];
const server = createServer(async (req, res) => {
  requests.push(await readRequest(req));
  res.writeHead(402, { 'content-type': 'application/json' });
  res.end(JSON.stringify({ status: 'declined' }));
});

await listen(server);
const address = server.address();
if (!address || typeof address === 'string') throw new Error('no tcp port');
const client = new PaymentClient(`http://127.0.0.1:${address.port}`);

await expect(client.charge(command)).resolves.toEqual({ status: 'declined' });
expect(requests[0]).toMatchObject({ method: 'POST', url: '/charge' });

await close(server);

В реальном spec сервер закрывают в afterEach или afterAll, включая путь ошибки. baseUrl обязан приходить из конфигурации или конструктора. Иначе тест рискует позвонить настоящему партнеру.

Отдельные сценарии моделируют:

  • задержку больше клиентского AbortSignal.timeout;
  • разрыв соединения без HTTP-статуса;
  • невалидный JSON и неожиданную схему;
  • два 503, затем успех, с проверкой ровно трех попыток;
  • повтор POST с тем же idempotency key.

Nock и MSW дают более короткую запись таких сценариев. Проверяйте, что выбранный перехватчик действительно перехватывает ваш HTTP-клиент: fetch на undici, Axios и node:http имеют разные пути. Тест «партнер ответил» должен доказывать, что запрос прошел через заглушку, а не мимо нее.

Время и случайность

Самый чистый способ - явная зависимость:

interface Clock {
  now(): Date;
}

class SystemClock implements Clock {
  now(): Date {
    return new Date();
  }
}

class FixedClock implements Clock {
  constructor(private readonly value: Date) {}
  now(): Date {
    return new Date(this.value);
  }
}

Сценарий получает Clock и IdGenerator, тест задает конкретный момент и ID. Не сравнивайте время с неявным часовым поясом: для моментов используют ISO UTC и timestamptz.

Jest fake timers полезны для кода с таймерами:

jest.useFakeTimers().setSystemTime(new Date('2026-01-15T10:00:00Z'));

expect(service.expiresAt()).toEqual(new Date('2026-01-16T10:00:00Z'));

jest.useRealTimers();

Восстановление таймеров делают в afterEach, чтобы один тест не менял остальные. Для setInterval и цепочек promises продвижение времени комбинируют с микрозадачами осознанно; если получается сложный танец с таймерами, явный Clock и Scheduler обычно проще.

Контракт с партнером

Локальная заглушка знает только то, что в нее записали. Если партнер заменил status на state, ваши тесты продолжат верить старому ответу.

Consumer-driven contract tests на Pact связывают две стороны: потребитель публикует ожидания, поставщик проигрывает их против своего реального API. Для событий проверяют схему сообщения и совместимость версий так же строго, как для HTTP.

Контрактный тест не заменяет smoke на тестовом контуре партнера, а smoke не должен быть единственной проверкой каждого PR. Уровни решают разные задачи: unit - вашу реакцию, HTTP-заглушка - клиент, контракт - согласованность, smoke - доступность реального окружения.

Коротко

  • Мокают собственный узкий порт на внешней границе, а не TypeORM и чужой SDK.
  • Fake хранит состояние по контракту, мок не должен повторять всю реализацию.
  • HTTP-клиент проверяется через реальную заглушку на уровне протокола.
  • Время, ID и планировщик - явные зависимости или аккуратно восстановленные fake timers.
  • Контрактные тесты нужны, чтобы заглушка не разошлась с партнером.
  • Assertions проверяют наблюдаемый результат, а не каждый внутренний вызов.

Что почитать дальше