Моки, тест-дублёры и ловушка чрезмерного мокинга
Стаб возвращает заготовку, мок проверяет поведение, фейк — рабочая реализация. Подменяй зависимости Nest через overrideProvider, а не jest.mock. Ловушка over-mocking: мокай чужие границы, фейкай или интегрируй своё — иначе зелёный сьют тестирует предположения, а не код.
Сьют был зелёной стеной. Двести юнит-тестов, каждый проходит за 900 мс, и спека UsersService, которая мокает findOne репозитория так, чтобы тот возвращал { id: 1, email: 'a@b.com' }. Затем тикет в поддержку: GET /users/9999 отдаёт 500, а не 404. В стектрейсе — Cannot read properties of null (reading 'email'). Сервис делает const user = await repo.findOne(...); return { email: user.email } — а реальный запрос на отсутствующий id возвращает null. Тест никогда не возвращал null, потому что моку было сказано возвращать пользователя, всегда. Тест проходил, потому что проверял, что наше предположение о findOne внутренне непротиворечиво — а не что реальный репозиторий ведёт себя так. Мок лгал, вежливо, двести раз за прогон. Этот урок — о том, какой тест-дублёр выбрать, как подменить реальную зависимость в Nest, и о черте, которую сеньор проводит между мокингом и чрезмерным мокингом.
Словарь тест-дублёров, употреблённый точно
Почему важна точность словаря? Потому что когда ты называешь что-то «моком» и тянешься за jest.fn(), ты уже решил, что тест проверяет — а это решение может оказаться неверным так, что выяснится только в продакшене. Точный выбор слова заставляет точно выбрать поведение.
«Мок» — слово, которым все называют всё подряд, и эта небрежность прячет, что тест на самом деле проверяет. Видов пять, и разница между ними — это разница между проверкой состояния (assert на результат) и проверкой поведения (assert, что вызов случился).
- Dummy передаётся, чтобы удовлетворить сигнатуру, но никогда не используется — аргумент-заполнитель.
- Стаб возвращает заготовленные ответы. Ты используешь его для проверки состояния: запрограммируй его вернуть пользователя, затем проверь, что твой код вычислил из этого пользователя. Ему всё равно, вызвали ли его.
- Спай записывает, как его вызвали (и может опционально пробросить вызов в настоящую реализацию). Ты проверяешь, что его вызвали, с какими аргументами, сколько раз.
- Мок имеет заранее запрограммированные ожидания и проваливает тест, если они не выполнены. Это проверка поведения: assert запечён в самом дублёре.
- Фейк — рабочая лёгкая реализация: in-memory репозиторий на
Map, фейковые часы. Он действительно ведёт себя как настоящий, просто без IO.
// stub: canned return, used for STATE verification (assert on the computed result)
const repoStub = { findOne: jest.fn().mockResolvedValue({ id: 1, email: 'a@b.com' }) };
// spy: assert it WAS called, with what (behaviour, but read-only)
const mailSpy = jest.fn();
expect(mailSpy).toHaveBeenCalledWith('a@b.com');
// fake: a real, working in-memory impl — it enforces its own contract
class FakeUserRepo {
private rows = new Map<number, User>();
async findOne({ where: { id } }: any) { return this.rows.get(id) ?? null; } // returns null!
async save(u: User) { this.rows.set(u.id, u); return u; }
}Обрати внимание: findOne фейка возвращает null на отсутствующий id — потому что так делает настоящий. Стаб, который всегда возвращает пользователя, никогда не сможет породить эту ветку. Точный выбор слова заставляет тебя точно выбрать поведение. Вместе пять типов покрывают все потребности: стаб и фейк — для проверки состояния, спай и мок — для проверки поведения, dummy — для заглушки сигнатуры. Пропустишь фейк — потеряешь единственного дублёра, который на самом деле обеспечивает реальный контракт.
Подмена провайдеров в Nest: override, а не jest.mock
Тестовый модуль ты уже собираешь через Test.createTestingModule. Специфичный для мокинга манёвр — подмена реального провайдера дублёром через DI Nest, вызовом overrideProvider(...).useValue(...) до .compile(). Это важно, потому что подмена идёт через контейнер — SUT получает дублёр по тому же токену, по которому получил бы настоящую зависимость, и больше ничто в графе этого не замечает.
const mailMock = { send: jest.fn() };
const repoMock = { findOne: jest.fn(), save: jest.fn() };
const moduleRef = await Test.createTestingModule({
providers: [UsersService, MailService /* real token, will be overridden */],
})
.overrideProvider(MailService).useValue(mailMock)
// a TypeORM repo is injected by a TOKEN, not the class — override the token
.overrideProvider(getRepositoryToken(User)).useValue(repoMock)
.compile();
const service = moduleRef.get(UsersService);Строка с репозиторием — та, где ошибаются: @InjectRepository(User) инжектит под getRepositoryToken(User), строко-подобным токеном, а не классом Repository. Подменяй токен, а не Repository. И сопротивляйся jest.mock('@nestjs/typeorm') для этого — мокинг на уровне модуля воюет с DI Nest: он заменяет символ глобально до того, как контейнер что-либо свяжет, так что ты теряешь контроль на уровне теста и токена и в итоге дебажишь, почему несвязанный тест в том же файле теперь видит твой мок. overrideProvider ограничен этим тестовым модулем и говорит на языке DI.
Auto-mocking: перестань писать пустые объекты руками
Писать руками { findOne: jest.fn(), save: jest.fn(), find: jest.fn(), ... } для широкой зависимости утомительно и протухает. useMocker в Nest вешает фабрику на любую непредоставленную зависимость; в паре с createMock из @golevelup/ts-jest он авто-генерирует глубокий, полностью типизированный мок всего интерфейса, и ты программируешь только те методы, которых касается тест.
import { createMock } from '@golevelup/ts-jest';
const moduleRef = await Test.createTestingModule({ providers: [UsersService] })
.useMocker(createMock) // every missing dep becomes a deep auto-mock typed to its interface
.compile();
const repo = moduleRef.get(getRepositoryToken(User));
repo.findOne.mockResolvedValue({ id: 1, email: 'a@b.com' }); // program just what you needУ удобства острый край: глубокий авто-мок типизирован к интерфейсу, так что он с радостью выдаст jest.fn() для метода, которого уже нет на реальном типе, или для метода, у которого уехала сигнатура. Мок удовлетворяет компилятор против формы, которую реальность с тех пор покинула — а это ровно тот провал, о котором следующий раздел.
▸Почему это работает
Почему полностью замоканные юнит-тесты дают ложную уверенность? Потому что мок возвращает то, что ты ему велел вернуть. Тест затем проверяет, что твой код, накормленный твоим предположением, выдаёт результат, согласованный с тем же предположением — замкнутая петля. Он доказывает, что код внутренне когерентен твоей ментальной модели зависимости; он ничего не доказывает о том, ведёт ли зависимость себя так на самом деле. В тот момент, когда реальный контракт уезжает — findOne теперь возвращает null на промахе, save теперь обеспечивает unique-ограничение, метод теперь выбрасывает на плохом входе — мок продолжает подавать старый счастливый ответ, и тест остаётся зелёным, пока прод ломается. Только фейк (который реализует контракт) или интеграционный тест (который гоняет настоящую вещь) замечает дрейф. Мок не может сказать тебе, что ты ошибаешься насчёт мира, потому что ты сам записал, что он говорит.
Ловушка чрезмерного мокинга
Мок кодирует твоё предположение о том, как ведёт себя зависимость. Это нормально для границы, которой ты не владеешь — платёжный шлюз, почтовый провайдер, сторонний HTTP API — где ты не можешь гонять настоящую вещь в юнит-тесте, а контракт стабилен и внешний. Это ловушка для того, чем ты владеешь.
Канонический провал — тот, что из Hook. Мок репозитория всегда возвращает { id, email }; реальный запрос на not-found возвращает null. Сервис делает user.email. Тест зелёный; прод выбрасывает Cannot read properties of null. Брат-близнец: замоканный save никогда не обеспечивает реальное ограничение UNIQUE(email), так что тест «отклонить дубликат email» проходит против мока, который радостно сохраняет дважды — тест не может поймать дубликат, потому что у мока нет ограничения, которое можно нарушить. В обоих случаях тест тестирует мок, а не код. Перемокай — и твой зелёный сьют измеряет точность твоих предположений, а не корректность твоей системы.
Позиция сеньора — это граница, проведённая осознанно:
- Мокай на архитектурных границах, которыми не владеешь — сторонний HTTP, почта, платежи, очереди. Их нельзя гонять в юнит-тесте; их контракт внешний и редко меняется под тобой.
- Фейкай или интегрируй то, чем владеешь — твой репозиторий, твои доменные сервисы. Используй in-memory фейк, который чтит контракт (возвращает
nullна промахе, обеспечивает уникальность), или интеграционный тест на настоящей БД. Это части, которые вероятнее всего дрейфуют, поэтому именно их мок опаснее всего замораживает. - Держи моки честными интеграционными тестами, которые прогоняют реальный контракт. Юнит-тесты с моками идут в однозначных миллисекундах, потому что нет IO — эта скорость и есть вся причина мокать. Цена — точность. Ты покупаешь скорость там, где точность дешева (стабильные границы), и платишь за точность там, где она важна (твой собственный слой данных), с более тонким слоем интеграционных тестов, прикалывающих реальный контракт, чтобы дрейф делал тест красным, а не пейджер.
Тебе нужно протестировать UsersService, который зависит от репозитория пользователей TypeORM И от стороннего EmailService (шлёт приветственное письмо). Как выбрать дублёры, чтобы тест был и быстрым, и честным?
В UsersService инжектится репозиторий TypeORM через @InjectRepository(User). В тестовом модуле какой провайдер ты подменяешь, чтобы заменить репо дублёром?
Тест сервиса мокает findOne репо, чтобы тот вернул пользователя, и проверяет возвращённый email. В проде GET /users/9999 выбрасывает 'Cannot read properties of null (reading email)'. Почему тест этого не поймал?
- 01Различи dummy, стаб, спай, мок и фейк и скажи, какие делают проверку состояния, а какие — поведения.
- 02Объясни ловушку over-mocking и границу, которую проводит сеньор, на примерах null-разыменования и unique-ограничения.
«Мок» — одно слово на пять разных дублёров, и небрежность прячет, что тест проверяет. Dummy заполняет сигнатуру; стаб возвращает заготовку для проверки СОСТОЯНИЯ; спай записывает, что вызов случился; мок несёт заранее запрограммированные ожидания для проверки ПОВЕДЕНИЯ; фейк — рабочая лёгкая реализация, чтущая реальный контракт. В Nest ты подменяешь реальную зависимость через DI вызовом Test.createTestingModule(…).overrideProvider(MailService).useValue(mock).compile(), а для репозитория TypeORM подменяешь ТОКЕН — overrideProvider(getRepositoryToken(User)).useValue(repoMock), — а не класс Repository; предпочитай это вместо jest.mock, который воюет с DI, заменяя символы глобально. useMocker(createMock) авто-генерирует глубокий типизированный мок каждой зависимости, так что ты программируешь только методы, которых касается тест — удобно, но он замокает методы, которых уже нет, если тип дрейфует. Этот дрейф и есть ловушка over-mocking: мок кодирует твоё предположение, так что зелёный полностью замоканный сьют подтверждает, что твои предположения самосогласованы, а не что реальность им соответствует — мок репо, всегда возвращающий { id, email }, прячет ветку null-на-not-found (прод выбрасывает на user.email), а замоканный save прячет отсутствующее ограничение UNIQUE (дубликат проскальзывает). Граница сеньора: мокай границы, которыми не владеешь (сторонний HTTP, почта, платежи — стабильно, внешне, быстро в мс), фейкай или интеграционно тестируй то, чем владеешь (твой репо и домен — вероятнее всего дрейфует), и держи тонкий слой интеграционных тестов, прогоняющих реальный контракт, чтобы дрейф делал тест красным, а не пейджер. Теперь, когда увидишь зелёный сьют, в котором findOne никогда не вернул null, спроси себя: это прошедший тест — или хорошо оформленное предположение?
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.