测试中的 Mock
模拟是一种创建仿制品、傀儡的手段。这通常以 when 'a', do 'b'
的方式进行傀儡操作。其思想是限制移动部件的数量并控制那些“无关紧要”的东西。“mocks”和“stubs”在技术上是不同类型的“测试替身”。对于好奇的人来说,stub 是一种不执行任何操作(空操作)但会跟踪其调用的替代品。mock 是一种也具有伪实现 (when 'a', do 'b'
) 的 stub。在此文档中,差异并不重要,并且 stubs 被称为 mocks。
测试应该是确定性的:可以以任何顺序、任何次数运行,并且始终产生相同的结果。正确的设置和模拟使这成为可能。
Node.js 提供了多种模拟各种代码片段的方法。
本文讨论以下类型的测试
类型 | 描述 | 示例 | 模拟候选者 |
---|---|---|---|
单元 | 您可以隔离的最小代码位 | const sum = (a, b) => a + b | 自己的代码、外部代码、外部系统 |
组件 | 一个单元 + 依赖项 | const arithmetic = (op = sum, a, b) => ops[op](a, b) | 外部代码、外部系统 |
集成 | 组件之间的配合 | - | 外部代码、外部系统 |
端到端 (e2e) | 应用程序 + 外部数据存储、交付等 | 一个假用户(例如 Playwright 代理)实际上使用连接到真实外部系统的应用程序。 | 无(不要模拟) |
关于何时模拟和何时不模拟,存在不同的思想流派,其大致轮廓如下。
何时模拟,何时不模拟
有 3 个主要的模拟候选者
- 自己的代码
- 外部代码
- 外部系统
自己的代码
这是您的项目控制的内容。
import foo from './foo.mjs';
export function main() {
const f = foo();
}
在这里,foo
是 main
的“自己的代码”依赖项。
原因
对于 main
的真实单元测试,应该模拟 foo
:您正在测试 main
是否工作,而不是测试 main
+ foo
是否工作(这是一个不同的测试)。
为什么不
模拟 foo
可能会比值得的麻烦更多,尤其是当 foo
简单、经过良好测试且很少更新时。
不模拟 foo
可能会更好,因为它更真实并且增加了 foo
的覆盖率(因为 main
的测试也将验证 foo
)。然而,这可能会产生噪音:当 foo
崩溃时,一堆其他测试也会崩溃,因此跟踪问题更加乏味:如果只有最终负责该问题的项目的 1 个测试失败,那很容易发现;而 100 个测试失败会创建一个大海捞针来找到真正的问题。
外部代码
这是您的项目不控制的内容。
import bar from 'bar';
export function main() {
const f = bar();
}
在这里,bar
是一个外部包,例如 npm 依赖项。
毫无争议地,对于单元测试,这应该始终被模拟。对于组件和集成测试,是否模拟取决于它是什么。
原因
验证您的项目不维护的代码是否工作不是单元测试的目标(并且该代码应该有自己的测试)。
为什么不
有时,模拟是不现实的。例如,您几乎永远不会模拟像 react 或 angular 这样的大型框架(这种疗法会比疾病更糟糕)。
外部系统
这些是数据库、环境(Web 应用程序的 Chromium 或 Firefox、Node 应用程序的操作系统等)、文件系统、内存存储等。
理想情况下,模拟这些是不必要的。除了以某种方式为每个案例创建隔离副本(由于成本、额外的执行时间等原因,通常非常不切实际)之外,下一个最佳选择是模拟。没有模拟,测试会互相破坏
import { db } from 'db';
export function read(key, all = false) {
validate(key, val);
if (all) return db.getAll(key);
return db.getOne(key);
}
export function save(key, val) {
validate(key, val);
return db.upsert(key, val);
}
在上面,第一个和第二个案例(it()
语句)可能会互相破坏,因为它们是同时运行的,并且会改变同一个存储(竞争条件):save()
的插入会导致原本有效的 read()
的测试在找到的项目上断言失败(并且 read()
也可以对 save()
做同样的事情)。
模拟什么
模块 + 单元
这利用了 Node.js 测试运行器中的 mock
。
import assert from 'node:assert/strict';
import { before, describe, it, mock } from 'node:test';
describe('foo', { concurrency: true }, () => {
let barMock = mock.fn();
let foo;
before(async () => {
const barNamedExports = await import('./bar.mjs')
// discard the original default export
.then(({ default: _, ...rest }) => rest);
// It's usually not necessary to manually call restore() after each
// nor reset() after all (node does this automatically).
mock.module('./bar.mjs', {
defaultExport: barMock,
// Keep the other exports that you don't want to mock.
namedExports: barNamedExports,
});
// This MUST be a dynamic import because that is the only way to ensure the
// import starts after the mock has been set up.
({ foo } = await import('./foo.mjs'));
});
it('should do the thing', () => {
barMock.mockImplementationOnce(function bar_mock() {
/* … */
});
assert.equal(foo(), 42);
});
});
APIs
一个鲜为人知的事实是,有一种内置的方法可以模拟 fetch
。undici
是 fetch
的 Node.js 实现。它随 node
一起提供,但目前未由 node
本身公开,因此必须安装(例如 npm install undici
)。
import assert from 'node:assert/strict';
import { beforeEach, describe, it } from 'node:test';
import { MockAgent, setGlobalDispatcher } from 'undici';
import endpoints from './endpoints.mjs';
describe('endpoints', { concurrency: true }, () => {
let agent;
beforeEach(() => {
agent = new MockAgent();
setGlobalDispatcher(agent);
});
it('should retrieve data', async () => {
const endpoint = 'foo';
const code = 200;
const data = {
key: 'good',
val: 'item',
};
agent
.get('https://example.com')
.intercept({
path: endpoint,
method: 'GET',
})
.reply(code, data);
assert.deepEqual(await endpoints.get(endpoint), {
code,
data,
});
});
it('should save data', async () => {
const endpoint = 'foo/1';
const code = 201;
const data = {
key: 'good',
val: 'item',
};
agent
.get('https://example.com')
.intercept({
path: endpoint,
method: 'PUT',
})
.reply(code, data);
assert.deepEqual(await endpoints.save(endpoint), {
code,
data,
});
});
});
时间
像奇异博士一样,您也可以控制时间。您通常这样做只是为了方便起见,以避免人为地延长测试运行(您真的想等待 3 分钟来触发 setTimeout()
吗?)。您可能还想穿越时空。这利用了 Node.js 测试运行器中的 mock.timers
。
请注意此处时区的使用(时间戳中的 Z
)。忽略包含一致的时区可能会导致意外的结果。
import assert from 'node:assert/strict';
import { describe, it, mock } from 'node:test';
import ago from './ago.mjs';
describe('whatever', { concurrency: true }, () => {
it('should choose "minutes" when that\'s the closet unit', () => {
mock.timers.enable({ now: new Date('2000-01-01T00:02:02Z') });
const t = ago('1999-12-01T23:59:59Z');
assert.equal(t, '2 minutes ago');
});
});
当与静态装置(已签入存储库)进行比较时,这尤其有用,例如在 快照测试中。