В этой серии статей мы рассмотрим, как издеваться над 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 * assyntax в тестах.

Насмешка над модулем

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

Разделение блоков кода на функции, основанные на действиях (абстрагирование), как и эти методы, является неотъемлемой частью тестирования.

// 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
*/

Мы говорили о том, как мокать модули. В следующей статье мы поговорим о таймерах.

Ресурсы