Заглушка ускоряет тест и одновременно создает вторую модель внешнего мира. Если эта модель неверна, тест остается зеленым именно на сломанном поведении. Поэтому выбор начинается не с библиотеки, а с границы: чей контракт мы проверяем - свой или чужой.
Мок на своей границе
Сценарий оформления заказа должен отреагировать на отказ платежного шлюза. Для этого не нужен партнерский сервер: достаточно собственного узкого интерфейса.
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 проверяют наблюдаемый результат, а не каждый внутренний вызов.
Что почитать дальше
- Интеграционные тесты на Node.js - когда вместо заглушки нужен PostgreSQL.
- Сеть на Node.js - таймауты, повторы и идемпотентность, которые моделируют в тестах.
- Тестирование NestJS - как подменять провайдеры через TestingModule.