В этой серии статей мы рассмотрим, как издеваться над Jest.
Jest Mocking — Часть 1: Функция
Jest Mocking — Часть 2: Модуль
Jest Mocking — Часть 3: Таймер
Jest Mocking — Часть 4: React Component
Спасибо моему другу Али Балбарсу за помощь в написании английской версии этой статьи.
Коды вы можете найти в статье на Github.
Мы говорили о том, как имитировать методы объектов. Теперь давайте посмотрим, как мы можем смоделировать функциональные модули.
Введение
Что такое модуль?
Мы храним код, выполняющий действие, в functions. Это позволяет нам применять абстракцию. Мы также можем группировать связанные функции, переменные, объекты и т. д. в файле. Эти файлы называются модулями.
Есть два разных модульных механизма, которые мы чаще всего используем в Node.js. Нам будет очень полезно ознакомиться с тем, что они возвращают при импорте с использованием разных методов, когда мы издеваемся.
CommonJs:
// File: utils.ts const axios = require("axios"); const API_URL = "https://dummyjson.com"; async function get(apiUrl: string): Promise<any> { try { const response = await axios.get(apiUrl); return response.data; } catch (error) { return null; } } function getProduct(productId: number): Promise<any> { return get(`${API_URL}/products/${productId}`); } function getUser(userId: number): Promise<any> { return get(`${API_URL}/users/${userId}`); } module.exports = { get, getUser, getProduct, }; // File: utils.test.ts const utils = require("./utils"); test("playground", () => { console.log("require as normal:", utils); } /* OUTPUT: require as normal: { get: [Function: get], getUser: [Function: getUser], getProduct: [Function: getProduct], default: { get: [Function: get], getUser: [Function: getUser], getProduct: [Function: getProduct] } } */
Модули ЕС:
// File: utils.ts import axios from "axios"; const API_URL = "https://dummyjson.com"; async function get(apiUrl: string): Promise<any> { try { const response = await axios.get(apiUrl); return response.data; } catch (error) { return null; } } function getProduct(productId: number): Promise<any> { return get(`${API_URL}/products/${productId}`); } function getUser(userId: number): Promise<any> { return get(`${API_URL}/users/${userId}`); } export { get, getUser, getProduct }; export default { get, getUser, getProduct, }; // File: utils.test.ts import * as utilsWithStar from "./utils"; import utilsWithDefault, from "./utils"; test("playground", () => { console.log("import with * as:", utilsWithStar); console.log("import as default:", utilsWithDefault); }); /* OUTPUT: import with * as: { get: [Function: get], getUser: [Function: getUser], getProduct: [Function: getProduct], default: { get: [Function: get], getUser: [Function: getUser], getProduct: [Function: getProduct] } } import as default: { get: [Function: get], getUser: [Function: getUser], getProduct: [Function: getProduct] } */
Мы будем часто использовать import * as
syntax в тестах.
Насмешка над модулем
Допустим, у нас есть вспомогательные функции для извлечения информации о продукте и пользователе.
Разделение блоков кода на функции, основанные на действиях (абстрагирование), как и эти методы, является неотъемлемой частью тестирования.
// File: utils.ts import axios from "axios"; const API_URL = "https://dummyjson.com"; export async function get(apiUrl: string): Promise<any> { try { const response = await axios.get(apiUrl); return response.data; } catch (error) { return null; } } export function getProduct(productId: number): Promise<any> { return get(`${API_URL}/products/${productId}`); } export function getUser(userId: number): Promise<any> { return get(`${API_URL}/users/${userId}`); }
Давайте попробуем издеваться над имеющейся у нас информацией. Первое, что приходит на ум, может быть переопределение путем импорта.
// File: overrideWithMock.test.ts import { get } from "./utils"; test("should be mock", () => { get = jest.fn(); expect(jest.isMockFunction(get)).toBe(true); }); /* OUTPUT: error TS2632: Cannot assign to 'get' because it is an import. */
Мы столкнулись с ошибкой, в которой говорится, что функция, которую мы пытаемся имитировать, доступна только для чтения. Импорт файла как объекта может решить проблему.
// File: overrideObjectWithMock.test.ts import * as UtilsModule from "./utils"; test("should be mock", () => { UtilsModule.get = jest.fn(); expect(jest.isMockFunction(UtilsModule.get)).toBe(true); }); /* OUTPUT: error TS2540: Cannot assign to 'get' because it is a read-only property. */
Однако мы по-прежнему получаем ошибку TypeScript. Хорошая попытка. Даже если бы это было возможно, не рекомендуется напрямую переопределять файл Import. Оставим это в умелых руках Jest.
шутка ()
Мы можем использовать jest.mock(relativeFilePath, factory, options)
для имитации модуля. Если указан только путь к файлу, он автоматически имитирует все экспортированные методы с помощью jest.fn
.
// File: utils.test.ts import * as UtilsModule from "./utils"; jest.mock("./utils"); test("playground", () => { console.log("utils Module:", UtilsModule); expect(jest.isMockFunction(UtilsModule.get)).toBe(true); }); /* OUTPUT: utils Module: { __esModule: true, getProduct: [Function: getProduct] { _isMockFunction: true, getMockImplementation: [Function (anonymous)], mock: [Getter/Setter], mockClear: [Function (anonymous)], mockReset: [Function (anonymous)], mockRestore: [Function (anonymous)], mockReturnValueOnce: [Function (anonymous)], mockResolvedValueOnce: [Function (anonymous)], mockRejectedValueOnce: [Function (anonymous)], mockReturnValue: [Function (anonymous)], mockResolvedValue: [Function (anonymous)], mockRejectedValue: [Function (anonymous)], mockImplementationOnce: [Function (anonymous)], withImplementation: [Function: bound withImplementation], mockImplementation: [Function (anonymous)], mockReturnThis: [Function (anonymous)], mockName: [Function (anonymous)], getMockName: [Function (anonymous)] }, getUser: [Function: getUser] { _isMockFunction: true, ... }, default: [Function: get] { _isMockFunction: true, ... } } PASS utils.test.ts ✓ playground (53 ms) */
Мы издевались после вызова import. Как это произошло?
Всегда необходимо издеваться над любым объектом перед его использованием. Чтобы следовать правилу наличия операторов импорта в верхней части файла, Jest поднимает jest.mock
операторов, чтобы сохранить эту структуру.
Насмешка над реализацией методов модуля
Поскольку мы знаем, что методы автоматически имитируются с помощью jest.fn
, мы можем использовать методы, упомянутые в предыдущей статье.
Приступим к тестированию метода get
в модуле Utils
. Если мы его рассмотрим, то увидим, что он использует пакет axios. Хорошей практикой является имитирование других функций, от которых зависит тестируемая функция.
// File: utils.test.ts import axios from "axios"; import * as UtilsModule from "./utils"; // mock axios package. jest.mock("axios"); // wrap jest mock method types to package. const mockedAxios = jest.mocked(axios); describe("utils tests", () => { afterEach(() => { jest.clearAllMocks(); }); describe("get() tests", () => { test("should return product when request is success", async () => { const apiUrl = "https://dummyjson.com/product/1"; const mockProduct = { id: 1, title: "iPhone 9", description: "An apple mobile which is nothing like apple", price: 549, discountPercentage: 12.96, rating: 4.69, stock: 94, brand: "Apple", category: "smartphones", }; // make the axios.get function return mock data. mockedAxios.get.mockResolvedValueOnce({ data: mockProduct, }); // call the function we are going to test. const result = await UtilsModule.get(apiUrl); expect(mockedAxios.get).toHaveBeenCalledWith(apiUrl); expect(result).toStrictEqual(mockProduct); }); test("should return null when request is failed", async () => { const apiUrl = "https://dummyjson.com/product/1000"; mockedAxios.get.mockRejectedValueOnce( new Error("Error occured when fetching data!") ); const result = await UtilsModule.get(apiUrl); expect(mockedAxios.get).toHaveBeenCalledWith(apiUrl); expect(result).toBeNull(); }); }); });
Jest не добавляет типы TypeScript для собственных методов после имитации модулей. Если мы хотим обернуть типы методов Jest во все содержащиеся в нем функции, мы можем использовать метод jest.mocked(source)
.
Заводской параметр
Если мы хотим создать макет вручную, мы можем использовать заводской параметр. Давайте изменим пример, чтобы отклонить запрос, если возвращаемое значение не смоделировано, и рефакторим второй тест.
// File: utils.test.ts import axios from "axios"; import * as UtilsModule from "./utils"; jest.mock("axios", () => { return { get: jest.fn().mockRejectedValue(new Error("Error occured when fetching data!")), }; }); const mockedAxios = jest.mocked(axios); describe("utils tests", () => { afterEach(() => { jest.clearAllMocks(); }); describe("get() tests", () => { test("should return product when request is success", async () => { const apiUrl = "https://dummyjson.com/product/1"; const mockProduct = { id: 1, title: "iPhone 9", description: "An apple mobile which is nothing like apple", price: 549, discountPercentage: 12.96, rating: 4.69, stock: 94, brand: "Apple", category: "smartphones", }; console.log("mockedAxios", mockedAxios); mockedAxios.get.mockResolvedValueOnce({ data: mockProduct, }); const result = await UtilsModule.get(apiUrl); expect(mockedAxios.get).toHaveBeenCalledWith(apiUrl); expect(result).toStrictEqual(mockProduct); }); test("should return null when request is failed", async () => { const apiUrl = "https://dummyjson.com/product/1000"; const result = await UtilsModule.get(apiUrl); expect(mockedAxios.get).toHaveBeenCalledWith(apiUrl); expect(result).toBeNull(); }); }); }); /* PASS src/tests/utils.test.ts utils tests get() tests ✓ should return product whe request is success ✓ should return null when request is failed */
Если нам нужно определить фиктивное значение по умолчанию или выполнить частичную фикцию, мы можем использовать фабрику.
Важным моментом, который следует учитывать, является то, как макеты работают вместе с фабриками и тестами. Давайте рассмотрим три различных варианта использования.
// File: mock-order-1.test.ts import axios from "axios"; jest.mock("axios", () => { return { get: jest.fn().mockResolvedValue("Mock in module factory"), }; }); const mockedAxios = jest.mocked(axios); test("playground", async () => { const apiUrl = "https://dummyjson.com"; mockedAxios.get.mockResolvedValue("Mock in test"); console.log(await mockedAxios.get(apiUrl)); // Output: Mock in test console.log(await mockedAxios.get(apiUrl)); // Output: Mock in test }); // File: mock-order-2.test.ts import axios from "axios"; jest.mock("axios", () => { return { get: jest.fn().mockResolvedValue("Mock in module factory"), }; }); const mockedAxios = jest.mocked(axios); test("playground", async () => { const apiUrl = "https://dummyjson.com"; mockedAxios.get.mockResolvedValueOnce("Mock in test"); console.log(await mockedAxios.get(apiUrl)); // Output: Mock in test console.log(await mockedAxios.get(apiUrl)); // Output: Mock in module factory }); // File: mock-order-3.test.ts import axios from "axios"; jest.mock("axios", () => { return { get: jest.fn().mockResolvedValueOnce("Mock in module factory"), }; }); const mockedAxios = jest.mocked(axios); test("playground", async () => { const apiUrl = "https://dummyjson.com"; mockedAxios.get.mockResolvedValue("Mock in test"); console.log(await mockedAxios.get(apiUrl)); // Output: Mock in module factory console.log(await mockedAxios.get(apiUrl)); // Output: Mock in test }); // File: mock-order-4.test.ts import axios from "axios"; jest.mock("axios", () => { return { get: jest.fn().mockResolvedValueOnce("Mock in module factory"), }; }); const mockedAxios = jest.mocked(axios); test("playground", async () => { const apiUrl = "https://dummyjson.com"; mockedAxios.get.mockResolvedValueOnce("Mock in test"); console.log(await mockedAxios.get(apiUrl)); // Output: Mock in module factory console.log(await mockedAxios.get(apiUrl)); // Output: Mock in test });
Частичное издевательство
До сих пор мы издевались над всем модулем. Однако мы можем захотеть имитировать только определенные функции. Для доступа к реальному содержимому модуля используется jest.requireActual
.
jest.mock('axios', () => { const originalAxiosModule = jest.requireActual('axios'); return { __esModule: true, ...originalAxiosModule, get: jest.fn(), }; });
Мокирующий модуль для конкретных тестов
Перейдем к тесту метода getProduct
. Мы будем издеваться над функцией get
, используемой в getProduct
.
// File: utils.test.ts import axios from "axios"; import * as UtilsModule from "./utils"; jest.mock("./utils"); jest.mock("axios", () => { return { get: jest .fn() .mockRejectedValue(new Error("Error occured when fetching data!")), }; }); const mockedUtils = jest.mocked(UtilsModule); const mockedAxios = jest.mocked(axios); describe("utils tests", () => { // ... describe("getProduct() tests", () => { test("should call get func with api product endpoint when given product id", () => { const productId = 1; const mockProduct = { id: 1, title: "iPhone 9", description: "An apple mobile which is nothing like apple", price: 549, discountPercentage: 12.96, rating: 4.69, stock: 94, brand: "Apple", category: "smartphones", }; mockedUtils.get.mockResolvedValue(mockProduct); const result = UtilsModule.getProduct(productId); expect(UtilsModule.get).toHaveBeenCalledWith( `https://dummyjson.com/products/${productId}` ); expect(result).toStrictEqual(mockProduct); }); }); }); /* OUTPUT: utils tests get() tests ✕ should return product whe request is success (4 ms) ✕ should return null when request is failed getProduct() tests ✕ should call get func with api product endpoint when given product id ● utils tests › get() tests › should return product whe request is success Expected: "https://dummyjson.com/product/1" Number of calls: 0 ● utils tests › get() tests › should return null when request is failed Expected: "https://dummyjson.com/product/1000" Number of calls: 0 ● utils tests › getProduct() tests › should call get func with api product endpoint when given product id Expected: "https://dummyjson.com/products/1" Number of calls: 0 */
Наш тест провалился, как и предыдущие тесты. Потому что издевательская функция не вызывается. Мы смоделировали модуль Utils
для метода getProduct
. Методы get
и getProduct
не должны подвергаться насмешкам в их собственных тестах.
Было бы полезно иметь возможность имитировать функции для каждого теста. Мы можем решить эту проблему, используя несколько различных методов.
шутка.doMock()
Еще один способ смоделировать модуль в Jest — использовать jest.doMock
. Отличие от jest.mock
в том, что он не подъёмный. То есть он имитирует только импорт, написанный после себя.
// File: utils.test.ts import axios from "axios"; import UtilsModule from "./utils"; jest.mock("axios", () => { return { get: jest.fn().mockRejectedValue(new Error("Error occured when fetching data!")), }; }); const mockedAxios = jest.mocked(axios); describe("utils tests", () => { afterEach(() => { jest.clearAllMocks(); jest.resetModules(); // clears all module mocks in this file. }); //... describe("getProduct() tests", () => { test("should call get func with api product endpoint when given product id", () => { const productId = 1; const mockProduct = { id: 1, title: "iPhone 9", description: "An apple mobile which is nothing like apple", price: 549, discountPercentage: 12.96, rating: 4.69, stock: 94, brand: "Apple", category: "smartphones", }; jest.doMock("./utils", () => ({ __esModule: true, ...jest.requireActual("./utils"), get: jest.fn().mockResolvedValue(mockProduct), })); // this is a critical point. we import module with require we // made afterwards befacuse of doMock will not be hoisted const GetModule = require("./utils"); const UtilsModule = require("./utils"); const result = UtilsModule.getProduct(productId); expect(GetModule.default).toHaveBeenCalledWith(`https://dummyjson.com/products/${productId}`); expect(result).toStrictEqual(mockProduct); }); }); });
Мы получили сообщение об ошибке, что нам не удалось имитировать метод get
. Это происходит потому, что getProduct
и get
находятся в одном файле, и мы ожидаем, что импорт будет имитировать их. Чтобы убедиться в этом, запишем переменную mockedUtils
.
// File: utils.test.ts test("should call get func with api product endpoint when given product id", () => { // ... const UtilsModule = require("./utils"); console.log("get:", UtilsModule.get); // ... }) /* OUTPUT: get: [Function: mockConstructor] { _isMockFunction: true, getMockImplementation: [Function (anonymous)], mock: [Getter/Setter], mockClear: [Function (anonymous)], mockReset: [Function (anonymous)], mockRestore: [Function (anonymous)], mockReturnValueOnce: [Function (anonymous)], mockResolvedValueOnce: [Function (anonymous)], mockRejectedValueOnce: [Function (anonymous)], mockReturnValue: [Function (anonymous)], mockResolvedValue: [Function (anonymous)], mockRejectedValue: [Function (anonymous)], mockImplementationOnce: [Function (anonymous)], withImplementation: [Function: bound withImplementation], mockImplementation: [Function (anonymous)], mockReturnThis: [Function (anonymous)], mockName: [Function (anonymous)], getMockName: [Function (anonymous)] } */
Теперь давайте запишем метод get
внутри метода getProduct
.
export function getProduct(productId: number): Promise<any> { console.log("get Function: ", get.toString()); // ... } /* OUTPUT: get Function: [Function: get] */
Как мы и думали, над методом не издевались. Мы можем извлечь его в отдельный файл и смоделировать его через новый файл.
// File: get.ts import axios from "axios"; export default async function get(apiUrl: string): Promise<any> { try { const response = await axios.get(apiUrl); return response.data; } catch (error) { return null; } } // File: utils.test.ts import axios from "axios"; import * as GetModule from "./get"; jest.mock("axios", () => { return { get: jest.fn().mockRejectedValue(new Error("Error occured when fetching data!")), }; }); const mockedAxios = jest.mocked(axios); describe("utils tests", () => { afterEach(() => { jest.clearAllMocks(); jest.resetModules(); // clears all module mocks in this file. }); //... describe("getProduct() tests", () => { test("should call get func with api product endpoint when given product id", async () => { const productId = 1; const mockProduct = { id: 1, title: "iPhone 9", description: "An apple mobile which is nothing like apple", price: 549, discountPercentage: 12.96, rating: 4.69, stock: 94, brand: "Apple", category: "smartphones", }; jest.doMock("./get", () => { return { __esModule: true, default: jest.fn().mockResolvedValue(mockProduct), }; }); const GetModule = require("./get"); const UtilsModule = require("./utils"); const result = await UtilsModule.getProduct(productId); expect(GetModule.default).toHaveBeenCalledWith(`https://dummyjson.com/products/${productId}`); expect(result).toStrictEqual(mockProduct); }); }); }); /* OUTPUT: utils tests get() tests ✓ should return product whe request is success (4 ms) ✓ should return null when request is failed getProduct() tests ✓ should call get func with api product endpoint when given product id */
шутка.spyOn()
Помните, что мы имитировали методы объекта, используя jest.spyOn
. Мы также можем имитировать модули путем импорта с import * as
в качестве объекта. Хотя извлечение в отдельный файл по-прежнему необходимо, это более чистый подход.
// File: utils.test.ts import axios from "axios"; import * as GetModule from "./get"; import * as UtilsModule from "./utils"; jest.mock("axios", () => { return { get: jest.fn().mockRejectedValue(new Error("Error occured when fetching data!")), }; }); const mockedAxios = jest.mocked(axios); describe("utils tests", () => { afterEach(() => { jest.clearAllMocks(); jest.resetModules(); // clears all module mocks in this file. }); //... describe("getProduct() tests", () => { test("should call get func with api product endpoint when given product id", async () => { const productId = 1; const mockProduct = { id: 1, title: "iPhone 9", description: "An apple mobile which is nothing like apple", price: 549, discountPercentage: 12.96, rating: 4.69, stock: 94, brand: "Apple", category: "smartphones", }; jest.spyOn(GetModule, "default").mockResolvedValue(mockProduct); const result = await UtilsModule.getProduct(productId); expect(GetModule.default).toHaveBeenCalledWith(`https://dummyjson.com/products/${productId}`); expect(result).toStrictEqual(mockProduct); }); }); }); /* OUTPUT: utils tests get() tests ✓ should return product whe request is success (4 ms) ✓ should return null when request is failed getProduct() tests ✓ should call get func with api product endpoint when given product id */
Модуль очистки Mock
Мы издевались над модулем, но, возможно, захотим использовать его настоящее содержимое для некоторых тестов. У нас есть два метода для этого. jest.dontMock
очищает макеты объектов импорта, которые идут после него.
// File: dontMock.test.ts jest.mock("axios"); test("playground", () => { const axiosInstance1 = require("axios"); // mocked console.log( "Is axiosInstance1.get mocked:", jest.isMockFunction(axiosInstance1.get) ); jest.dontMock("axios"); const axiosInstance2 = require("axios"); // unmocked console.log( "Is axiosInstance2.get mocked:", jest.isMockFunction(axiosInstance2.get) ); }); /* OUTPUT: Is axiosInstance1.get mocked: true Is axiosInstance2.get mocked: false */
jest.unmock
очищает макеты всех соответствующих объектов импорта в блоке кода, в котором он находится.
// File: unmock.test.ts jest.mock("axios"); test("playground", () => { const axiosInstance1 = require("axios"); // mocked console.log( "Is axiosInstance1.get mocked:", jest.isMockFunction(axiosInstance1.get) ); jest.unmock("axios"); const axiosInstance2 = require("axios"); // unmocked console.log( "Is axiosInstance2.get mocked:", jest.isMockFunction(axiosInstance2.get) ); }); /* Output: Is axiosInstance1.get mocked: false Is axiosInstance2.get mocked: false */
Мы говорили о том, как мокать модули. В следующей статье мы поговорим о таймерах.