如何使用 Jest 模拟导入的函数
📣注意
本文是系列文章的一部分:使用 Jest 测试 JavaScript
为了充分利用本文,我建议阅读本系列的上一篇文章:
为带有依赖关系的代码编写单元测试可能很困难。对于大型代码库来说尤其如此,因为很难找到能够覆盖所有需要测试用例的测试夹具。
但是如果我们可以控制函数依赖项的返回值,无论使用什么参数调用它,情况会怎样?
这就是模拟函数发挥作用的地方。
模拟函数是一种测试工具,它允许我们追踪函数依赖项的调用方式并控制它们的返回值。这使得我们可以操纵被测程序的控制流,甚至在编写测试时遇到那些难以重现的边缘情况。
本文将介绍模拟背后的概念及其与单元测试的关系。我们将学习如何使用 Jest 模拟函数和导入的函数模块,并编写依赖于这些模拟的测试用例来提高测试用例的覆盖率。
我们假设我们正在测试几个验证规则:
// isInteger.js
module.exports = (value) => Number.isSafeInteger(value);
// isAtLeast18.js
const isInteger = require("./isInteger");
module.exports = (value) => isInteger(value) && value >= 18;
我们希望通过测试用例的通过和失败来了解代码中存在的缺陷。本文不涉及如何修复代码实现,但您可以在阅读本文的过程中尝试一下。
继续阅读以了解更多信息!
如何使用 Jest 模拟导入的函数?
为了使用 Jest 模拟导入的函数,我们使用jest.mock()
函数。
jest.mock()
调用时会传入一个必需参数——我们正在模拟的模块的导入路径。它也可以传入一个可选的第二个参数——模拟的工厂函数。如果没有提供工厂函数,Jest 将自动模拟导入的模块。
💡注意:
Jest automock是通过表面替换实现对导入模块进行自动模拟的功能。自Jest 15起,自动模拟功能默认处于禁用状态,但可以通过在 Jest 中使用标志 来automock
启用。
测试时isAtLeast18()
我们必须牢记isInteger()
依赖关系会影响模块的行为:
- 如果
isInteger()
是false
,isAtLeast18()
就是false
; - 如果
isInteger()
是true
,isAtLeast18()
则取决于值参数。
isInteger()
我们将首先测试返回的情况false
。
该isInteger.js
模块只有一个默认导出 -isInteger()
函数。我们将使用一个工厂函数来模拟导入的模块,该工厂函数的行为与默认导出类似,并返回一个函数。该函数在调用时始终返回false
。
// isAtLeast18.spec.js
const isAtLeast18 = require("./isAtLeast18");
// The mock factory returns the function () => false
jest.mock("./isInteger", () => () => false);
describe("isAtLeast18", () => {
it("fails if value is not recognised as integer", () => {
// Should pass, but fails because of the isInteger() mock
expect(isAtLeast18(123)).toBe(false);
// Should fail either way
expect(isAtLeast18("abc")).toBe(false);
});
});
💡注意:
模拟模块的导入路径必须与我们正在测试的模块中的导入路径匹配。该isAtLeast18.js
模块导入isInteger.js
路径为“./isInteger”。这就是为什么我们的模拟导入路径也是“./isInteger”。
isAtLeast18()
false
现在无论我们用什么调用它都会返回,因为isInteger()
模拟设置为始终返回false
。
isInteger()
但是返回的情况怎么样true
?
为了根据测试模拟不同的返回值,我们将创建一个模拟函数。
💡注意:
此单元测试是单独单元测试,因为被测单元与其依赖项隔离。有关单独单元测试的更多信息,请参阅上一篇文章:如何使用 Jest 在 JavaScript 中编写单元测试。
什么是模拟函数?
模拟函数是一种用“假的”(模拟)实现替换函数的实际实现的函数。
模拟函数会追踪外部代码如何调用它们。通过模拟函数,我们可以了解该函数的调用次数、调用时传入的参数、返回的结果等等。这种“监视”函数调用的能力正是模拟函数也称为“间谍”的原因。
我们使用模拟函数,通过自定义模拟实现来覆盖原始函数的行为。模拟实现帮助我们控制函数的返回值。这使得我们的测试更具可预测性(确定性),也更易于编写。
如何使用 Jest 模拟一个函数?
为了用 Jest 模拟一个函数,我们使用jest.fn()
函数。
jest.fn()
可以使用实现函数作为可选参数进行调用。如果提供了实现,则调用模拟函数将调用该实现并返回其返回值。
如果没有提供实现,则调用模拟将返回,undefined
因为返回值未定义。
// Without implementation, this mock returns `undefined`.
const mockUndefined = jest.fn();
// With implementation, this mock returns `true`.
const mockTrue = jest.fn(() => true).
Jest 默认使用 "jest.fn()" 名称注册模拟函数。我们可以使用mockName()
method为模拟函数指定自定义名称。打印测试结果时将使用模拟名称。
const mockOne = jest.fn(() => false);
// Example error: expect(jest.fn()).toHaveBeenCalledWith(...expected)
const mockTwo = jest.fn(() => false).mockName('mockTwo');
// Example error: expect(mockTwo).toHaveBeenCalledWith(...expected)
💡注意:
如果使用大量不同的模拟函数,最好为模拟函数命名。这样更容易区分模拟函数,并调试不符合预期的代码。
如何使用 Jest 更改函数的模拟实现?
要使用 Jest 更改函数的模拟实现,我们使用模拟函数的mockImplementation()
方法。
该mockImplementation()
方法以新的实现作为参数进行调用。当模拟被调用时,新的实现将取代之前的实现。
// The initial mock is a function that returns `true`.
const myMock = jest.fn(() => true);
// The new mock implementation has the function return `false`.
myMock.mockImplementation(() => false);
我们可以将其与jest.mock()
工厂函数结合,创建包含模拟函数的模拟模块。这样,我们就可以根据测试内容来控制模拟函数的实现行为。
// isAtLeast18.spec.js
const isAtLeast18 = require("./isAtLeast18");
const isInteger = require("./isInteger");
// The mock factory returns a mocked function
jest.mock("./isInteger", () => jest.fn());
describe("isAtLeast18", () => {
it("fails if value is not recognised as integer", () => {
// For this test we'll mock isInteger to return `false`
isInteger.mockImplementation(() => false);
expect(isAtLeast18(123)).toBe(false);
expect(isAtLeast18("abc")).toBe(false);
});
it("passes if value is recognised as integer and is at least 18", () => {
// For this test we'll mock isInteger to return `true`
isInteger.mockImplementation(() => true);
expect(isAtLeast18(123)).toBe(true);
expect(isAtLeast18("abc")).toBe(false);
});
});
💡 Note 的
jest.mock()
工作原理是修改 Node 模块缓存,以便在测试文件中导入模拟模块时,返回模拟结果而不是原始实现。为了支持 ES 模块导入(其中import
语句必须放在文件中的最前面),Jest会自动将jest.mock()
调用提升到模块顶部。点击此处了解更多关于此技术的信息。
如何使用 Jest 检查函数是否被正确调用?
为了检查函数是否被 Jest 正确调用,我们使用带有特定匹配器方法的expect()
函数来创建断言。
我们可以使用toHaveBeenCalledWith()
匹配器方法来断言模拟函数被调用的参数。
为了断言到目前为止模拟函数被调用了多少次,我们可以使用toHaveBeenCalledTimes()
匹配器方法。
// isAtLeast18.spec.js
const isAtLeast18 = require("./isAtLeast18");
const isInteger = require("./isInteger");
jest.mock("./isInteger", () => jest.fn());
describe("isAtLeast18", () => {
it("fails if value is not recognised as integer", () => {
isInteger.mockImplementation(() => false);
expect(isAtLeast18(123)).toBe(false);
// We expect isInteger to be called with 123
expect(isInteger).toHaveBeenCalledWith(123);
// We expect isInteger to be called once
expect(isInteger).toHaveBeenCalledTimes(1);
});
});
💡注意虽然这些是函数最常见的匹配器方法,但 Jest API 文档
中还有更多可用的匹配器方法。
Jest 会追踪所有对模拟函数的调用。模拟函数会记住其调用的参数、调用时间以及调用结果。
在测试之间重用模拟函数时,在运行新测试之前重置它们的状态以获得清晰的基准很有用。我们可以通过清除测试之间的模拟函数来实现这一点。
如何使用 Jest 清除模拟函数?
为了使用 Jest 清除模拟函数,我们使用模拟函数的mockClear()
方法。
mockClear()
重置模拟函数中存储的所有信息,这对于清理断言或测试之间的模拟使用数据很有用。
// isAtLeast18.spec.js
const isAtLeast18 = require("./isAtLeast18");
const isInteger = require("./isInteger");
jest.mock("./isInteger", () => jest.fn());
describe("isAtLeast18", () => {
it("fails if value is not recognised as integer", () => {
isInteger.mockImplementation(() => false);
expect(isAtLeast18(123)).toBe(false);
expect(isInteger).toHaveBeenCalledWith(123);
expect(isInteger).toHaveBeenCalledTimes(1);
// Clear the mock so the next test starts with fresh data
isInteger.mockClear();
});
it("passes if value is recognised as integer and is at least 18", () => {
isInteger.mockImplementation(() => true);
expect(isAtLeast18(123)).toBe(true);
expect(isInteger).toHaveBeenCalledWith(123);
// Without clearing, there would be 2 calls total at this point
expect(isInteger).toHaveBeenCalledTimes(1);
});
});
💡注意
Jest 还提供了用于重置和恢复模拟函数的模拟函数方法,以及用于创建直接返回、解析或拒绝值的模拟函数的简写。
如何在每次使用 Jest 测试之前清除模拟函数?
为了在每次使用 Jest 进行测试之前清除模拟函数,我们使用beforeEach()
函数。
beforeEach()
调用时会传入一个必需参数——测试文件中每个测试开始前运行的函数。我们用它来清除模拟、设置 Fixture 或重置测试中使用的其他状态。
// isAtLeast18.spec.js
const isAtLeast18 = require("./isAtLeast18");
const isInteger = require("./isInteger");
jest.mock("./isInteger", () => jest.fn());
// Clear mock data before each test
beforeEach(() => {
isInteger.mockClear();
});
describe("isAtLeast18", () => {
it("fails if value is not recognised as integer", () => {
isInteger.mockImplementation(() => false);
expect(isAtLeast18(123)).toBe(false);
expect(isInteger).toHaveBeenCalledWith(123);
expect(isInteger).toHaveBeenCalledTimes(1);
});
it("passes if value is recognised as integer and is at least 18", () => {
isInteger.mockImplementation(() => true);
expect(isAtLeast18(123)).toBe(true);
expect(isInteger).toHaveBeenCalledWith(123);
expect(isInteger).toHaveBeenCalledTimes(1);
});
});
💡注意
: Jest 提供了四个函数来挂载到测试文件中的每个或所有测试之前和之后的设置和拆卸过程中。这些函数是:afterAll()
、afterEach()
、beforeAll()
、beforeEach()
。afterAll()
和beforeAll()
变体在整个测试文件中仅调用一次。afterEach()
和beforeEach()
变体在测试文件中的每个测试中都会调用一次。
如何使用 Jest 重用模拟?
为了使用 Jest 重用模拟,我们在与要模拟的模块相邻的__mocks__/
子目录中创建模拟。
子目录中的模拟文件__mocks__/
用于在模拟模块时自动模拟与其相邻的模块jest.mock()
。这在设置模拟时需要大量重复工作(例如模拟常见依赖项或配置对象)时非常有用,因为它使得编写模拟工厂函数变得没有必要。
假设一个由许多不同模块使用的通用配置文件,模拟它将如下所示:
// common/config.js
module.exports = { foo: "bar" };
// common/__mocks__/config.js
module.exports = { foo: "mockBar" };
// example.js
const config = require.("./common/config");
// Logs "bar"
module.exports = () => console.log(config.foo);
// example.spec.js
const example = require("./example");
jest.mock("./common/config");
// Logs "mockBar", no need for a mock factory
example();
💡注意
:模拟和自动模拟仅在使用 Jest 运行测试时有效。它们对开发或生产中的代码没有影响。
就这样!现在我们可以使用 Jest 来模拟导入的函数了。
Jest mock 导入函数示例代码
中的依赖关系isInteger.js
:
// isInteger.js
module.exports = (value) => Number.isSafeInteger(value);
被测试的单元isAtLeast18.js
:
// isAtLeast18.js
const isInteger = require("./isInteger");
module.exports = (value) => isInteger(value) && value >= 18;
单元测试isAtLeast18.spec.js
:
// isAtLeast18.spec.js
const isAtLeast18 = require("./isAtLeast18");
const isInteger = require("./isInteger");
// The mock factory returns a mocked function
jest.mock("./isInteger", () => jest.fn());
beforeEach(() => {
isInteger.mockClear();
});
describe("isAtLeast18", () => {
it("fails if value is not recognised as integer", () => {
isInteger.mockImplementation(() => false);
expect(isAtLeast18(123)).toBe(false);
expect(isInteger).toHaveBeenCalledWith(123);
expect(isInteger).toHaveBeenCalledTimes(1);
});
it("passes if value is recognised as integer and is at least 18", () => {
isInteger.mockImplementation(() => true);
expect(isAtLeast18(123)).toBe(true);
expect(isInteger).toHaveBeenCalledWith(123);
expect(isInteger).toHaveBeenCalledTimes(1);
});
});
家庭作业和后续步骤
- 编写更全面的测试,并使用 Fixture 来覆盖任何额外的情况。如果你已经完成了上一篇文章的准备工作,可以尝试从上次中断的地方继续。
- 修复代码以便任何失败的测试都能够通过,或者编写更新、更好的实现。
- 在覆盖率报告中实现100%的代码覆盖率。
感谢您花时间阅读这篇文章!
你之前尝试过用 Jest 模拟导入函数吗?体验如何?
留下评论并开始讨论!
鏂囩珷鏉ユ簮锛�https://dev.to/dstrekelj/how-to-mock-imported-functions-with-jest-3pfl