На этой странице

Мокинг (mocking) — это способ создать имитацию, «марионетку». Обычно это делается в стиле кукловодства «когда 'a', делай 'b'». Идея в том, чтобы ограничить число подвижных частей и контролировать вещи, которые «не важны». «Моки» (mocks) и «заглушки» (stubs) технически являются разными видами «тестовых двойников» (test doubles). Для любопытных: заглушка — это замена, которая ничего не делает (no-op), но отслеживает своё вызывание. Мок — это заглушка, у которой ещё есть поддельная реализация (то самое «когда 'a', делай 'b'»). В рамках этого документа различие несущественно, и заглушки называются моками.

Тесты должны быть детерминированными: запускаемыми в любом порядке, любое число раз и всегда дающими один и тот же результат. Правильная настройка и мокинг делают это возможным.

Node.js предоставляет множество способов мокать различные куски кода.

Эта статья имеет дело со следующими типами тестов:

типописаниепримеркандидаты на мок
unitнаименьший кусок кода, который можно изолироватьconst sum = (a, b) => a + bсвой код, внешний код, внешняя система
componentunit + зависимостиconst arithmetic = (op = sum, a, b) => ops[op](a, b)внешний код, внешняя система
integrationкомпоненты, стыкующиеся вместе-внешний код, внешняя система
end-to-end (e2e)приложение + внешние хранилища данных, доставка и т. д.Поддельный пользователь (например, агент Playwright), буквально использующий приложение, подключённое к реальным внешним системам.ничего (не мокать)

Существуют разные школы мысли о том, когда мокать, а когда нет; их общие черты обрисованы ниже.

Есть 3 основных кандидата на мок:

  • Свой код
  • Внешний код
  • Внешняя система

Это то, что контролирует ваш проект.

import foo from './foo.mjs';

export function main() {
  const f = foo();
}

Здесь foo — зависимость main из «своего кода».

Для настоящего модульного теста main foo следует замокать: вы тестируете, что работает main, а не что работают main + foo (это другой тест).

Мокинг foo может доставить больше хлопот, чем пользы, особенно когда foo прост, хорошо протестирован и редко обновляется.

Не мокать foo может быть лучше, потому что это аутентичнее и повышает покрытие foo (потому что тесты main также проверят foo). Это, однако, может создавать шум: когда foo ломается, куча других тестов тоже сломается, поэтому отследить проблему труднее: если падает только 1 тест того элемента, что в конечном счёте ответственен за проблему, это очень легко заметить; тогда как 100 падающих тестов создают «иголку в стоге сена» для поиска настоящей проблемы.

Это то, что ваш проект не контролирует.

import bar from 'bar';

export function main() {
  const f = bar();
}

Здесь bar — внешний пакет, например npm-зависимость.

Бесспорно, для модульных тестов его всегда следует мокать. Для компонентных и интеграционных тестов, мокать ли, зависит от того, что это такое.

Проверять, что работает код, который ваш проект не сопровождает, — не цель модульного теста (и у того кода должны быть собственные тесты).

Иногда мокать просто нереалистично. Например, вы почти никогда не станете мокать большой фреймворк вроде react или angular (лекарство было бы хуже болезни).

Это такие вещи, как базы данных, окружения (Chromium или Firefox для веб-приложения, операционная система для node-приложения и т. д.), файловые системы, хранилище в памяти и т. д.

В идеале мокать их было бы не нужно. Помимо того чтобы как-то создавать изолированные копии для каждого случая (обычно очень непрактично из-за стоимости, дополнительного времени выполнения и т. д.), следующий по предпочтительности вариант — мокать. Без мокинга тесты саботируют друг друга:

import { db } from 'db';

export function read(key, all = false) {
  validate(key);

  if (all) {
    return db.getAll(key);
  }

  return db.getOne(key);
}

export function save(key, val) {
  validate(key, val);

  return db.upsert(key, val);
}

В коде выше первый и второй случаи (инструкции it()) могут саботировать друг друга, потому что выполняются конкурентно и мутируют одно и то же хранилище (состояние гонки, race condition): вставка save() может привести к тому, что в остальном валидный тест read() провалит своё утверждение о числе найденных элементов (а read() может сделать то же самое с save()).

Здесь используется mock из test runner Node.js.

import assert from 'node:assert/strict';
import { before, describe, it, mock } from 'node:test';

describe('foo', { concurrency: true }, () => {
  const barMock = mock.fn();
  let foo;

  before(async () => {
    const barNamedExports = await import('./bar.mjs')
      // отбрасываем исходный default-экспорт
      .then(({ default: _, ...rest }) => rest);

    // Обычно не нужно вручную вызывать restore() после каждого
    // и reset() после всех (node делает это автоматически).
    mock.module('./bar.mjs', {
      defaultExport: barMock,
      // Оставляем остальные экспорты, которые вы не хотите мокать.
      namedExports: barNamedExports,
    });

    // Это ДОЛЖЕН быть динамический import, потому что это единственный способ гарантировать, что
    // импорт начнётся после того, как мок был настроен.
    ({ foo } = await import('./foo.mjs'));
  });

  it('should do the thing', () => {
    barMock.mock.mockImplementationOnce(function bar_mock() {
      /* … */
    });

    assert.equal(foo(), 42);
  });
});

Малоизвестный факт: есть встроенный способ мокать fetch. undici — это реализация fetch в Node.js. Она поставляется вместе с node, но в настоящее время не выставляется самим node, поэтому её нужно установить (например, npm install undici).

import assert from 'node:assert/strict';
import { beforeEach, describe, it } from 'node:test';

import { MockAgent, setGlobalDispatcher } from 'undici';

import endpoints from './endpoints.mjs';

describe('endpoints', { concurrency: true }, () => {
  let agent;
  beforeEach(() => {
    agent = new MockAgent();
    setGlobalDispatcher(agent);
  });

  it('should retrieve data', async () => {
    const endpoint = 'foo';
    const code = 200;
    const data = {
      key: 'good',
      val: 'item',
    };

    agent
      .get('https://example.com')
      .intercept({
        path: endpoint,
        method: 'GET',
      })
      .reply(code, data);

    assert.deepEqual(await endpoints.get(endpoint), {
      code,
      data,
    });
  });

  it('should save data', async () => {
    const endpoint = 'foo/1';
    const code = 201;
    const data = {
      key: 'good',
      val: 'item',
    };

    agent
      .get('https://example.com')
      .intercept({
        path: endpoint,
        method: 'PUT',
      })
      .reply(code, data);

    assert.deepEqual(await endpoints.save(endpoint), {
      code,
      data,
    });
  });
});

Как Доктор Стрэндж, вы тоже можете управлять временем. Обычно это делают просто для удобства, чтобы избежать искусственно затянутых прогонов тестов (вы правда хотите ждать 3 минуты, пока сработает тот setTimeout()?). Возможно, вам также захочется «путешествовать во времени». Здесь используется mock.timers из test runner Node.js.

Обратите внимание на использование часового пояса здесь (Z во временных метках). Если пренебречь указанием согласованного часового пояса, это, скорее всего, приведёт к неожиданным результатам.

import assert from 'node:assert/strict';
import { describe, it, mock } from 'node:test';

import ago from './ago.mjs';

describe('whatever', { concurrency: true }, () => {
  it('should choose "minutes" when that\'s the closet unit', () => {
    mock.timers.enable({ now: new Date('2000-01-01T00:02:02Z') });

    const t = ago('1999-12-01T23:59:59Z');

    assert.equal(t, '2 minutes ago');
  });
});

Это особенно полезно при сравнении со статической фикстурой (закоммиченной в репозиторий), например при snapshot-тестировании.