Stub vs mock vs fake: ликбез на пальцах с примерами на TypeScript
Слова «stub», «mock», «fake» в команде звучат как синонимы. На код-ревью один пишет «замокал API», другой — «застабил клиента», третий — «подставил fake-репозиторий». Все имеют в виду «подменили реальную зависимость на тестовую», и в 80% случаев правда так и есть. Но когда тест падает странно, или его невозможно поддерживать, обычно выясняется: путаница в терминах привела к путанице в дизайне.
Семь лет в QA — за это время эти три термина мы с разработчиками переопределяли в каждой команде. Покажу определения, которыми пользуюсь, и примеры на TypeScript. Без академии и Мейзаросом, по делу.
Откуда термины и почему они путаются
Изначально определения дал Мартин Фаулер в статье Mocks Aren't Stubs, ссылаясь на Джерарда Мейзароса. Деление по-простому:
- Stub — отдаёт заранее заданный ответ. Не проверяет, как с ним общались. Просто заглушка.
- Mock — проверяет, как с ним общались. На него вешают ожидания: «должен быть вызван метод X с аргументами Y».
- Fake — рабочая, но упрощённая реализация. Например, in-memory база вместо Postgres.
Терминологическая путаница пошла с Jest и других фреймворков, где функция называется jest.fn(), метод — mockReturnValue(), а используется как stub. На уровне API — все мок, на уровне роли в тесте — могут быть и stub, и mock одновременно.
Stub: «вернуть нужный ответ»
Stub — самая частая роль. Тестируешь функцию, которая ходит в API, тебе нужно подсунуть ей ответ. Никаких проверок «вызвался ли запрос» — просто отдать данные.
interface UserClient {
getUser(id: string): Promise<{ id: string; name: string }>;
}
class GreetingService {
constructor(private readonly users: UserClient) {}
async greet(id: string): Promise<string> {
const user = await this.users.getUser(id);
return `Привет, ${user.name}!`;
}
}
// тест с stub-ом
import { describe, it, expect } from 'vitest';
describe('GreetingService', () => {
it('формирует приветствие по имени', async () => {
const stub: UserClient = {
getUser: async () => ({ id: '1', name: 'Лена' }),
};
const service = new GreetingService(stub);
const result = await service.greet('1');
expect(result).toBe('Привет, Лена!');
});
});
Здесь нет ни одного expect на сам stub. Тест проверяет результат функции, не способ её работы. Это правильный stub: дешёвый, читаемый, не ломается при рефакторинге внутреннего поведения.
Stub через библиотеку
Чисто-руками stub нормально работает на 1–2 методах. На большом интерфейсе быстрее через vi.fn() или jest.fn():
import { vi } from 'vitest';
const users: UserClient = {
getUser: vi.fn().mockResolvedValue({ id: '1', name: 'Лена' }),
};
Главное — не вешать на этот объект expect(users.getUser).toHaveBeenCalled(), иначе stub превратился в mock и тест начал заботиться о том, как именно работала функция внутри.
Mock: «проверить, что вызвался»
Mock нужен, когда поведение функции — это и есть факт вызова чего-то снаружи. Самый частый случай — отправка событий, логирование, нотификации.
interface Notifier {
notify(userId: string, message: string): Promise<void>;
}
class OrderService {
constructor(private readonly notifier: Notifier) {}
async cancel(orderId: string, userId: string): Promise<void> {
// ... отмена заказа ...
await this.notifier.notify(userId, `Заказ ${orderId} отменён`);
}
}
// тест-mock
import { describe, it, expect, vi } from 'vitest';
describe('OrderService.cancel', () => {
it('отправляет уведомление пользователю', async () => {
const notifier: Notifier = {
notify: vi.fn().mockResolvedValue(undefined),
};
const service = new OrderService(notifier);
await service.cancel('o-42', 'u-1');
expect(notifier.notify).toHaveBeenCalledWith(
'u-1',
'Заказ o-42 отменён',
);
expect(notifier.notify).toHaveBeenCalledTimes(1);
});
});
В этом тесте сам факт вызова — поведение, которое мы тестируем. Уведомление не возвращает результат для бизнес-логики, его эффект — само сообщение пользователю. Mock здесь оправдан.
Где mock-и портят тесты
Если на одну зависимость накладываешь и проверки вызова, и точные значения, и порядок, и количество — тест становится хрупким. Любой рефакторинг внутренностей ломает его. Использую правило: на mock — максимум одна проверка вызова. Если хочется проверить пять параметров — это уже похоже на тест на состояние, и удобнее проверять состояние, а не процесс.
Fake: «маленькая рабочая реализация»
Fake — это полноценная реализация интерфейса, но упрощённая до тестового масштаба. Любимый пример — in-memory репозиторий вместо БД.
interface UserRepo {
save(user: { id: string; name: string }): Promise<void>;
findById(id: string): Promise<{ id: string; name: string } | null>;
}
class InMemoryUserRepo implements UserRepo {
private readonly store = new Map<string, { id: string; name: string }>();
async save(user: { id: string; name: string }): Promise<void> {
this.store.set(user.id, { ...user });
}
async findById(id: string): Promise<{ id: string; name: string } | null> {
return this.store.get(id) ?? null;
}
}
Дальше в тестах ты пишешь поверх InMemoryUserRepo любые сценарии: сохранил, прочитал, обновил, удалил. В отличие от stub, fake реально хранит состояние между вызовами. В отличие от mock, fake не проверяет, как именно его дёргали — только результат.
Когда fake выгоднее всего
- Есть сложный сценарий с несколькими записями и чтениями.
- Логика бизнес-правил завязана на состояние хранилища.
- Stub-ить десяток методов вручную дольше, чем написать упрощённую реализацию.
Например, в тестах сервиса корзины: stub-ы на пять методов CartRepo с разными возвращаемыми значениями превращаются в нечитаемое полотно. Fake на 30 строк — и тесты пишутся на чистом языке домена.
Spy — четвёртый, который часто упоминают
Spy — это обёртка над реальной реализацией, которая записывает вызовы. Полезен, когда не хочешь подменять зависимость, но хочешь зафиксировать факт обращения:
import { vi } from 'vitest';
const realLogger = { log: (msg: string) => console.log(msg) };
const spy = vi.spyOn(realLogger, 'log');
doSomething(realLogger);
expect(spy).toHaveBeenCalledWith('starting');
Spy редко нужен в чистых юнит-тестах с DI. Полезен на интеграционных, где зависимость нельзя подменить, но хочется проверить, что её дёрнули.
Какой вид выбирать
Простое правило, на которое ориентируюсь:
- Тестируешь возвращаемое значение или итоговое состояние? Бери stub или fake.
- Тестируешь побочный эффект во внешнем мире (отправили письмо, опубликовали событие)? Бери mock.
- Зависимость дёргается много раз с состоянием? Бери fake.
- Не нужно подменять зависимость, но хочется проверить факт вызова? Spy.
Что не зашло
- Mock на каждый внешний вызов «на всякий случай». Тесты превращаются в проверку реализации, а не поведения. Любой рефакторинг — пять часов починки.
- Fake вместо реальной БД на интеграционных тестах. Поведение SQL-запросов отличается. На in-memory всё работает, на проде — индексы, транзакции, изоляции. Минимум один уровень тестов должен ходить в реальную БД (testcontainers).
- Stub-ы на 50 строк с условными ветками:
if (id === '1') return ...; else if (id === '2') .... Это уже fake, оформи его явно — будет читаемее. - Универсальный «mockEverything()» хелпер. Прячет неявные зависимости. Через год никто не помнит, что именно подменено и какие у этого побочные эффекты.
Главный вывод
Правильное название не помогает само по себе. Оно помогает на код-ревью: когда ты видишь «stub», ожидаешь, что в тесте есть проверка результата. Когда «mock» — что проверяется факт вызова. Когда «fake» — что есть состояние и вокруг него крутятся ассерты.
Если в команде все три термина означают «vi.fn()», тесты будут писаться без понимания, что именно проверяют. Заведи общий словарь, прогоняй его на онбординге новых разработчиков. Через месяц будет видно по тестам, какие из них фиксируют поведение, а какие — реализацию.