Моки и стабы (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 });

Практика

  1. Напиши тест с мок-функцией для callback-а: fetchData(url, callback) — проверь, что callback вызван с правильными данными
  2. Создай Fake-репозиторий для задач (add, getAll, getById, remove) и напиши тесты для TaskService
  3. Используй jest.spyOn для проверки вызова localStorage.setItem в функции сохранения настроек
  4. Замокай модуль fs и напиши тест для функции чтения конфига из файла

Связанные темы

Ресурсы