[Перевод] Знакомство с Jest Mocks
Мокинг — это техника изоляции объектов тестирования путем замены зависимостей объектами, которые можно проверять и контролировать. Зависимостью может быть всё, от чего зависит объект, но обычно это модуль, который объект импортирует.
Есть хорошие библиотеки для мокинга в JavaScript, такие как testdouble и sinon, а Jest обеспечивает мокинг из коробки.
Недавно я стал соавтором Jest, чтобы помочь разобраться с баг-трекером, и сразу заметил, что возникает много вопросов о том, как работает мокинг в Jest. Поэтому я решил составить соответствующее руководство.
Говоря о мокинге в Jest, мы обычно имеем в виду замену зависимостей на мок-функции. В этой статье мы рассмотрим мок-функции, а затем познакомимся с различными способами замены зависимостей с их помощью.
Мок-функция
Цель мокинга, выражаясь простым языком — заменить то, что мы не контролируем, на то, что контролируем. Поэтому важно, чтобы то, чем мы это заменим, обладало всеми необходимыми функциями.
Мок-функция позволяет:
- Перехватывать вызовы
- Устанавливать возвращаемые значения
- Изменять реализацию
Самый простой способ создать мок-экземпляр — это jest.fn ().
С помощью него и Jest Expect можно легко протестировать перехваченные вызовы:
test("returns undefined by default", () => {
const mock = jest.fn();
let result = mock("foo");
expect(result).toBeUndefined();
expect(mock).toHaveBeenCalled();
expect(mock).toHaveBeenCalledTimes(1);
expect(mock).toHaveBeenCalledWith("foo");
});
и изменить возвращаемое значение, реализацию или разрешение промиса:
test("mock implementation", () => {
const mock = jest.fn(() => "bar");
expect(mock("foo")).toBe("bar");
expect(mock).toHaveBeenCalledWith("foo");
});
test("also mock implementation", () => {
const mock = jest.fn().mockImplementation(() => "bar");
expect(mock("foo")).toBe("bar");
expect(mock).toHaveBeenCalledWith("foo");
});
test("mock implementation one time", () => {
const mock = jest.fn().mockImplementationOnce(() => "bar");
expect(mock("foo")).toBe("bar");
expect(mock).toHaveBeenCalledWith("foo");
expect(mock("baz")).toBe(undefined);
expect(mock).toHaveBeenCalledWith("baz");
});
test("mock return value", () => {
const mock = jest.fn();
mock.mockReturnValue("bar");
expect(mock("foo")).toBe("bar");
expect(mock).toHaveBeenCalledWith("foo");
});
test("mock promise resolution", () => {
const mock = jest.fn();
mock.mockResolvedValue("bar");
expect(mock("foo")).resolves.toBe("bar");
expect(mock).toHaveBeenCalledWith("foo");
});
Теперь, когда мы поговорили о том, что такое мок-функция и что с ее помощью можно делать, перейдем к рассмотрению способов ее использования.
Инъекция зависимостей
Один из распространенных способов использования мок-функции — передача ее непосредственно в качестве аргумента тестируемой функции. Это позволяет запустить объект тестирования, а затем проверить, как был вызван мок и с какими аргументами:
const doAdd = (a, b, callback) => {
callback(a + b);
};
test("calls callback with arguments added", () => {
const mockCallback = jest.fn();
doAdd(1, 2, mockCallback);
expect(mockCallback).toHaveBeenCalledWith(3);
});
Эта стратегия хороша, но она требует, чтобы код поддерживал инъекцию зависимостей. Зачастую это не так, поэтому нам понадобятся инструменты для мокинга существующих модулей и функций.
Мокинг модулей и функций
В Jest есть три основных типа мокинга модулей и функций:
- jest.fn: имитация функции
- jest.mock: имитация модуля
- jest.spyOn: «шпионство» или имитация функции
Каждый из них в той или иной степени создает мок-функцию. Чтобы объяснить, как каждый из них это делает, рассмотрим структуру проекта:
├ example/
| └── app.js
| └── app.test.js
| └── math.js
В этом случае обычно тестируют app.js и хотят либо не вызывать фактические функции math.js, либо шпионить за ними, чтобы убедиться, что они вызываются так, как ожидается. Этот пример банален, но представьте, что math.js представляет собой сложное вычисление или требует некоторых операций ввода-вывода, которых вы хотите избежать:
export const add = (a, b) => a + b;
export const subtract = (a, b) => b - a;
export const multiply = (a, b) => a * b;
export const divide = (a, b) => b / a;
import * as math from './math.js';
export const doAdd = (a, b) => math.add(a, b);
export const doSubtract = (a, b) => math.subtract(a, b);
export const doMultiply = (a, b) => math.multiply(a, b);
export const doDivide = (a, b) => math.divide(a, b);
Мокинг функции с помощью jest.fn
Базовая стратегия мокинга — переназначить функцию на мок-функцию. После этого везде, где используются переназначенные функции, вместо исходной функции будет вызываться мок:
import * as app from "./app";
import * as math from "./math";
math.add = jest.fn();
math.subtract = jest.fn();
test("calls math.add", () => {
app.doAdd(1, 2);
expect(math.add).toHaveBeenCalledWith(1, 2);
});
test("calls math.subtract", () => {
app.doSubtract(1, 2);
expect(math.subtract).toHaveBeenCalledWith(1, 2);
});
Этот тип мокинга встречается реже по нескольким причинам:
jest.mock
делает это автоматически для всех функций в модулеjest.spyOn
делает то же самое, но позволяет восстановить исходную функцию
Мокинг модуля с помощью jest.mock
Более распространенным подходом является использование jest.mock
для автоматической установки всех экспортов модуля в мок-функцию. Так, вызов jest.mock('./math.js');
по сути, устанавливает math.js
в:
export const add = jest.fn();
export const subtract = jest.fn();
export const multiply = jest.fn();
export const divide = jest.fn();
Можно использовать любую из вышеперечисленных фичей мок-функций для всех экспортов модуля:
import * as app from "./app";
import * as math from "./math";
// Установим для всех функций модуля значение jest.fn
jest.mock("./math.js");
test("calls math.add", () => {
app.doAdd(1, 2);
expect(math.add).toHaveBeenCalledWith(1, 2);
});
test("calls math.subtract", () => {
app.doSubtract(1, 2);
expect(math.subtract).toHaveBeenCalledWith(1, 2);
});
Это самая простая и распространенная форма мокинга, и это тот тип мокинга, который Jest делает за вас с помощью automock: true
.
Единственным недостатком этой стратегии является труднодоступность оригинальной реализации модуля. Для таких случаев можно использовать spyOn
.
Шпионство или мокинг функции с помощью jest.spyOn
Иногда вы хотите только наблюдать за вызовом метода, сохраняя при этом его оригинальную реализацию. В других случаях вам нужно будет провести мокинг реализации, но восстановить оригинал позже в наборе.
В этих случаях можно использовать jest.spyOn
.
Здесь мы просто «шпионим» за вызовами математической функции, но оставляем оригинальную реализацию на месте:
import * as app from "./app";
import * as math from "./math";
test("calls math.add", () => {
const addMock = jest.spyOn(math, "add");
// вызывает оригинальную реализацию
expect(app.doAdd(1, 2)).toEqual(3);
// а шпион хранит вызовы для добавления
expect(addMock).toHaveBeenCalledWith(1, 2);
});
Это полезно в ряде сценариев, когда вы хотите проверить, что определенные побочные эффекты имеют место, не заменяя их на самом деле.
В других случаях вам понадобится имитировать функцию, но затем восстановить исходную реализацию:
import * as app from "./app";
import * as math from "./math";
test("calls math.add", () => {
const addMock = jest.spyOn(math, "add");
// переопределяем реализацию
addMock.mockImplementation(() => "mock");
expect(app.doAdd(1, 2)).toEqual("mock");
// восстановливаем исходную реализацию
addMock.mockRestore();
expect(app.doAdd(1, 2)).toEqual(3);
});
Это полезно для тестов в одном файле, но не нужно делать в afterAll
хуке, поскольку каждый файл теста в Jest находится в песочнице.
Главное, что нужно помнить о jest.spyOn
, — так это то, что это просто сахар для базового использования jest.fn()
. Можно достичь той же цели, если сохранить оригинальную реализацию, установить реализацию мока на оригинальную и переназначить оригинальную позже:
import * as app from "./app";
import * as math from "./math";
test("calls math.add", () => {
// сохраняем оригинальную реализацию
const originalAdd = math.add;
// имитируем add с помощью оригинальной реализации
math.add = jest.fn(originalAdd);
// шпионим за вызовами add
expect(app.doAdd(1, 2)).toEqual(3);
expect(math.add).toHaveBeenCalledWith(1, 2);
// переопределяем реализацию
math.add.mockImplementation(() => "mock");
expect(app.doAdd(1, 2)).toEqual("mock");
expect(math.add).toHaveBeenCalledWith(1, 2);
// восстановливаем исходную реализацию
math.add = originalAdd;
expect(app.doAdd(1, 2)).toEqual(3);
});
Фактически, именно так реализованjest.spyOn
.
Заключение
В этой статье мы узнали о мок-функции и различных стратегиях переназначения модулей и функций для отслеживания вызовов, замены реализаций и установки возвращаемых значений.
Надеюсь, вы стали лучше понимать Jest mocks, и написание тестов будет проходить легче. Чтобы узнать больше о мокинге, ознакомьтесь с 700+ слайдовым докладом под названием Don’t Mock Me от Justin Searles.