测试中的模拟(Mocking)
模拟(Mocking)是创建仿制品、傀儡的一种手段。这通常以 当 'a' 时,做 'b' 的方式进行操作。其思想是限制活动部件的数量,并控制那些“不重要”的东西。严格来说,“模拟(mocks)”和“存根(stubs)”是不同类型的“测试替身(test doubles)”。对于好奇的读者,存根(stub)是一个什么都不做(no-op)但会跟踪其调用的替代品。模拟(mock)则是一个带有虚假实现的存根(即 当 'a' 时,做 'b')。在本文档中,这种差异并不重要,存根也被称为模拟。
测试应该是确定性的:可以按任何顺序、任何次数运行,并且总是产生相同的结果。适当的设置和模拟使这成为可能。
Node.js 提供了多种方式来模拟代码的各个部分。
本文涉及以下类型的测试
| 类型 | 描述 | 示例 | 模拟候选对象 |
|---|---|---|---|
| 单元测试 | 你能隔离的最小代码片段 | const sum = (a, b) => a + b | 自己的代码、外部代码、外部系统 |
| 组件测试 | 一个单元 + 依赖项 | const arithmetic = (op = sum, a, b) => ops[op](a, b) | 外部代码、外部系统 |
| 集成测试 | 组件之间的协作 | - | 外部代码、外部系统 |
| 端到端测试 (e2e) | 应用程序 + 外部数据存储、交付等 | 一个虚拟用户(例如 Playwright 代理)实际使用连接到真实外部系统的应用程序。 | 无(不模拟) |
关于何时模拟、何时不模拟,有不同的学派,其大致思路如下所述。
何时模拟与不模拟
有 3 种主要的模拟候选对象
- 自己的代码
- 外部代码
- 外部系统
自己的代码
这是你的项目所控制的部分。
import from './foo.mjs';
export function () {
const = ();
}
在这里,foo 是 main 的一个“自有代码”依赖。
为什么
为了对 main 进行真正的单元测试,应该模拟 foo:你测试的是 main 能否正常工作,而不是 main + foo 能否协同工作(那是另一种测试)。
为什么不
模拟 foo 可能得不偿失,特别是当 foo 简单、经过充分测试且很少更新时。
不模拟 foo 可能更好,因为它更真实,并且增加了 foo 的覆盖率(因为 main 的测试也会验证 foo)。然而,这可能会产生干扰:当 foo 出现问题时,其他许多测试也会失败,从而使得追踪问题变得更加繁琐:如果只有最终导致问题的那个测试失败,那么问题就很容易被发现;而 100 个测试失败则需要在大海捞针中寻找真正的问题。
外部代码
这是你的项目无法控制的部分。
import from 'bar';
export function () {
const = ();
}
在这里,bar 是一个外部包,例如一个 npm 依赖。
毫无争议地,对于单元测试,这应该总是被模拟。对于组件和集成测试,是否模拟取决于它是什么。
为什么
验证非项目维护的代码能否正常工作,并不是单元测试的目标(而且那些代码应该有自己的测试)。
为什么不
有时候,模拟并不现实。例如,你几乎永远不会去模拟像 React 或 Angular 这样的大型框架(这样做会弊大于利)。
外部系统
这些是指数据库、环境(Web 应用的 Chromium 或 Firefox,Node 应用的操作系统等)、文件系统、内存存储等。
理想情况下,不需要模拟这些。除了为每个测试用例创建隔离的副本(通常由于成本、额外的执行时间等原因非常不切实际)外,次优的选择就是模拟。如果不进行模拟,测试会相互干扰。
import { } from 'db';
export function (, = false) {
validate(, val);
if () {
return .getAll();
}
return .getOne();
}
export function (, ) {
validate(, );
return .upsert(, );
}
在上面的例子中,第一个和第二个测试用例(it() 语句)可能会相互干扰,因为它们是并发运行的,并且修改了同一个存储(存在竞争条件):save() 的插入操作可能会导致原本有效的 read() 测试因断言找到的项目数量而失败(而 read() 也可能对 save() 造成同样的问题)。
模拟什么
模块 + 单元
这利用了 Node.js 测试运行器中的 mock。
import from 'node:assert/strict';
import { , , , } from 'node:test';
('foo', { : true }, () => {
const = .();
let ;
(async () => {
const = await import('./bar.mjs')
// discard the original default export
.(({ default: , ... }) => );
// It's usually not necessary to manually call restore() after each
// nor reset() after all (node does this automatically).
.('./bar.mjs', {
: ,
// Keep the other exports that you don't want to mock.
: ,
});
// This MUST be a dynamic import because that is the only way to ensure the
// import starts after the mock has been set up.
({ } = await import('./foo.mjs'));
});
('should do the thing', () => {
..(function () {
/* … */
});
.((), 42);
});
});
API
一个鲜为人知的事实是,有一种内置的方法来模拟 fetch。undici 是 fetch 的 Node.js 实现。它与 node 一起发布,但目前并未由 node 本身直接暴露,因此必须进行安装(例如 npm install undici)。
import from 'node:assert/strict';
import { , , } from 'node:test';
import { , } from 'undici';
import from './endpoints.mjs';
('endpoints', { : true }, () => {
let ;
(() => {
= new ();
();
});
('should retrieve data', async () => {
const = 'foo';
const = 200;
const = {
: 'good',
: 'item',
};
.get('https://example.com')
.intercept({
: ,
: 'GET',
})
.reply(, );
.(await .get(), {
,
,
});
});
('should save data', async () => {
const = 'foo/1';
const = 201;
const = {
: 'good',
: 'item',
};
.get('https://example.com')
.intercept({
: ,
: 'PUT',
})
.reply(, );
.(await .save(), {
,
,
});
});
});
时间
就像奇异博士一样,你也可以控制时间。你通常这样做只是为了方便,以避免人为延长测试运行时间(你真的想等 3 分钟让那个 setTimeout() 触发吗?)。你也可能想要穿越时间。这利用了 Node.js 测试运行器中的 mock.timers。
请注意此处时区的使用(时间戳中的 Z)。如果不包含一个一致的时区,很可能会导致意想不到的结果。
import from 'node:assert/strict';
import { , , } from 'node:test';
import from './ago.mjs';
('whatever', { : true }, () => {
('should choose "minutes" when that\'s the closet unit', () => {
..({ : new ('2000-01-01T00:02:02Z') });
const = ('1999-12-01T23:59:59Z');
.(, '2 minutes ago');
});
});
这在与静态固定数据(已签入代码仓库)进行比较时特别有用,例如在快照测试中。