Моки и стабы (Test Doubles)
Test doubles — подменные объекты, которые заменяют реальные зависимости в тестах. Позволяют тестировать код в изоляции.
Зачем нужно
Реальный код зависит от БД, API, файловой системы, таймеров. В тестах мы не хотим: ждать ответ от API, заполнять базу, зависеть от интернета. Test doubles подменяют эти зависимости предсказуемыми заглушками.
Где используется
В unit-тестах повсеместно. В интеграционных — частично (подменяют внешние сервисы). В E2E — минимально (тестируют реальную систему).
Предпосылки
Основы тестирования, Виды тестов
5 видов Test Doubles
1. Dummy — заглушка-заполнитель
Передаётся, но никогда не используется. Нужен, чтобы заполнить обязательный параметр.
// Функция требует logger, но в этом тесте логгер не важен
test('createUser возвращает объект пользователя', () => {
const dummyLogger = {}; // Никогда не вызывается
const user = createUser('Алиса', 'alice@test.com', dummyLogger);
expect(user.name).toBe('Алиса');
});
2. Stub — возвращает заранее заданные значения
Подменяет ответы зависимостей. Не проверяет, как был вызван.
// Стаб для API: всегда возвращает одинаковый ответ
test('отображает данные пользователя из API', async () => {
const apiStub = {
getUser: jest.fn.mockResolvedValue({
id: 1,
name: 'Алиса',
email: 'alice@test.com',
}),
};
const profile = await loadProfile(apiStub, 1);
expect(profile.name).toBe('Алиса');
});
// Стаб для даты: всегда 1 января 2025
test('форматирует текущую дату', () => {
jest.useFakeTimers;
jest.setSystemTime(new Date('2025-01-01'));
expect(getCurrentDate).toBe('01.01.2025');
jest.useRealTimers;
});
3. Mock — проверяет, КАК был вызван
Не только подменяет ответ, но и запоминает вызовы для проверки.
test('отправляет email при регистрации', async () => {
// Создаём мок email-сервиса
const emailService = {
send: jest.fn.mockResolvedValue(true),
};
await registerUser('Алиса', 'alice@test.com', emailService);
// Проверяем, ЧТО мок был вызван и КАК
expect(emailService.send).toHaveBeenCalledTimes(1);
expect(emailService.send).toHaveBeenCalledWith({
to: 'alice@test.com',
subject: 'Добро пожаловать!',
body: expect.stringContaining('Алиса'),
});
});
4. Spy — наблюдает за реальным вызовом
Не подменяет реализацию, а «подглядывает» за реальным вызовом.
test('логирует предупреждение при пустом вводе', () => {
// Шпионим за console.warn, не подменяя его
const spy = jest.spyOn(console, 'warn');
validateInput('');
expect(spy).toHaveBeenCalledWith('Ввод пуст');
spy.mockRestore(); // Восстанавливаем оригинал
});
// Spy с подменой реализации
test('шпионит за методом и подменяет результат', () => {
const calculator = new Calculator();
const spy = jest.spyOn(calculator, 'complexCalc')
.mockReturnValue(42); // Подменяем результат
const result = calculator.process(10);
expect(spy).toHaveBeenCalledWith(10);
expect(result).toBe(42);
spy.mockRestore();
});
5. Fake — упрощённая рабочая реализация
Реально работает, но проще оригинала. Например, in-memory БД вместо PostgreSQL.
// Fake-хранилище вместо реальной БД
class FakeUserRepository {
constructor {
this.users = new Map();
this.nextId = 1;
}
async save(user) {
const id = this.nextId++;
const saved = { ...user, id };
this.users.set(id, saved);
return saved;
}
async findById(id) {
return this.users.get(id) || null;
}
async findByEmail(email) {
return [...this.users.values()].find(u => u.email === email) || null;
}
}
test('сервис сохраняет и находит пользователя', async () => {
const fakeRepo = new FakeUserRepository();
const service = new UserService(fakeRepo);
const user = await service.register('Алиса', 'alice@test.com');
const found = await service.getById(user.id);
expect(found.name).toBe('Алиса');
});
Сравнительная таблица
| Тип | Возвращает данные | Проверяет вызовы | Реализация |
|---|---|---|---|
| Dummy | Нет | Нет | Пустой объект |
| Stub | Да | Нет | Захардкоженные ответы |
| Mock | Да | Да | jest.fn |
| Spy | Реальный результат | Да | jest.spyOn |
| Fake | Да | Нет | Упрощённая логика |
Моки в Jest — практика
jest.fn — создание мока
const mockFn = jest.fn;
// Задаём возвращаемое значение
mockFn.mockReturnValue(42);
console.log(mockFn); // 42
// Разные значения при разных вызовах
mockFn
.mockReturnValueOnce('первый')
.mockReturnValueOnce('второй')
.mockReturnValue('остальные');
console.log(mockFn); // 'первый'
console.log(mockFn); // 'второй'
console.log(mockFn); // 'остальные'
jest.mock — мок модуля
// Мокаем весь модуль axios
jest.mock('axios');
const axios = require('axios');
test('загружает пользователей', async () => {
axios.get.mockResolvedValue({
data: [{ id: 1, name: 'Алиса' }],
});
const users = await fetchUsers;
expect(users).toHaveLength(1);
expect(axios.get).toHaveBeenCalledWith('/api/users');
});
jest.spyOn — шпион за методом
test('вызывает Math.random', () => {
const spy = jest.spyOn(Math, 'random').mockReturnValue(0.5);
const result = generateId;
expect(spy).toHaveBeenCalled();
expect(result).toContain('5'); // детерминированный результат
spy.mockRestore();
});
Очистка моков
afterEach(() => {
jest.clearAllMocks; // Сбрасывает вызовы и результаты
// jest.resetAllMocks; // + сбрасывает реализацию
// jest.restoreAllMocks; // + восстанавливает оригинал (для spyOn)
});
| Метод | Вызовы | Реализация | Оригинал |
|---|---|---|---|
| clearAllMocks | Очищает | Сохраняет | Нет |
| resetAllMocks | Очищает | Сбрасывает | Нет |
| restoreAllMocks | Очищает | Сбрасывает | Восстанавливает |
Частые ошибки
1. Слишком много моков
// ПЛОХО: всё замокано — тест ничего не проверяет
test('processOrder', () => {
const mockValidator = jest.fn.mockReturnValue(true);
const mockCalc = jest.fn.mockReturnValue(100);
const mockDb = jest.fn.mockResolvedValue({ id: 1 });
const mockEmail = jest.fn;
// Тест проверяет только, что моки вызваны...
});
// ХОРОШО: мокай только внешние зависимости
test('processOrder рассчитывает итог с учётом скидки', () => {
const mockDb = { save: jest.fn.mockResolvedValue({ id: 1 }) };
// Реальная логика расчёта, мок только для БД
const result = processOrder(items, discount, mockDb);
expect(result.total).toBe(850);
});
2. Забыл восстановить spy
// ПЛОХО: spy остаётся между тестами
jest.spyOn(console, 'error');
// Все последующие тесты видят мок console.error
// ХОРОШО: всегда восстанавливай
afterEach( => jest.restoreAllMocks);
3. Мок возвращает неправильный формат
// ПЛОХО: API возвращает { data: [...] }, а мок просто [...]
axios.get.mockResolvedValue([{ id: 1 }]);
// ХОРОШО: мок повторяет реальную структуру
axios.get.mockResolvedValue({ data: [{ id: 1 }], status: 200 });
Практика
- Напиши тест с мок-функцией для callback-а:
fetchData(url, callback)— проверь, что callback вызван с правильными данными - Создай Fake-репозиторий для задач (add, getAll, getById, remove) и напиши тесты для TaskService
- Используй
jest.spyOnдля проверки вызоваlocalStorage.setItemв функции сохранения настроек - Замокай модуль
fsи напиши тест для функции чтения конфига из файла