使用 Node.js 的测试运行器
🌐 Using Node.js's test runner
Node.js 拥有一个灵活且强大的内置测试运行器。本指南将向你展示如何设置和使用它。
🌐 Node.js has a flexible and robust built-in test runner. This guide will show you how to set up and use it.
example/
├ …
├ src/
├ app/…
└ sw/…
└ test/
├ globals/
├ …
├ IndexedDb.js
└ ServiceWorkerGlobalScope.js
├ setup.mjs
├ setup.units.mjs
└ setup.ui.mjs
注意:globs 需要 node v21 及以上版本,并且 globs 本身必须用引号括起来(如果不加引号,行为可能与你预期不同,表面上看似可用,但实际上并不可用)。
有些东西你总是想要的,所以把它们放在一个基础配置文件中,就像下面这样。这个文件会被其他更定制的配置所导入。
🌐 There are some things you always want, so put them in a base setup file like the following. This file will get imported by other, more bespoke setups.
一般设置
🌐 General setup
import { } from 'node:module';
('some-typescript-loader');
// TypeScript is supported hereafter
// BUT other test/setup.*.mjs files still must be plain JavaScript!
然后针对每个设置,创建一个专用的 setup 文件(确保在每个文件中都导入了基础的 setup.mjs 文件)。将设置隔离的原因有很多,但最明显的原因是 YAGNI + 性能:你可能要设置的很多内容都是特定环境的模拟/桩,这可能非常耗费资源并会减慢测试运行速度。当不需要这些时,你希望避免这些开销(你为 CI 支付的实际成本、等待测试完成的时间等)。
🌐 Then for each setup, create a dedicated setup file (ensuring the base setup.mjs file is imported within each). There are a number of reasons to isolate the setups, but the most obvious reason is YAGNI + performance: much of what you may be setting up are environment-specific mocks/stubs, which can be quite expensive and will slow down test runs. You want to avoid those costs (literal money you pay to CI, time waiting for tests to finish, etc) when you don't need them.
下面的每个例子都取自真实项目;它们可能不适用于你的项目,但每个例子都展示了广泛适用的一般概念。
🌐 Each example below was taken from real-world projects; they may not be appropriate/applicable to yours, but each demonstrate general concepts that are broadly applicable.
动态生成测试用例
🌐 Dynamically generating test cases
有时,你可能希望动态生成测试用例。例如,你希望在一堆文件上测试相同的内容。这是可行的,虽然有些复杂。你必须使用 test(不能使用 describe)+ testContext.test:
🌐 Some times, you may want to dynamically generate test-cases. For instance, you want to test the same thing across a bunch of files. This is possible, albeit slightly arcane. You must use test (you cannot use describe) + testContext.test:
简单示例
🌐 Simple example
import from 'node:assert/strict';
import { } from 'node:test';
import { } from '…';
const = [
{
: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.3',
: 'WIN',
},
// …
];
('Detect OS via user-agent', { : true }, => {
for (const { , } of ) {
.(, () => .((), ));
}
});
高级示例
🌐 Advanced example
import from 'node:assert/strict';
import { } from 'node:test';
import { } from './getWorkspacePJSONs.mjs';
const = ['node.js', 'sliced bread'];
('Check package.jsons', { : true }, async => {
const = await ();
for (const of ) {
// ⚠️ `t.test`, NOT `test`
.(`Ensure fields are properly set: ${.name}`, () => {
.(.keywords, );
});
}
});
注意:在 23.8.0 版本之前,设置方式有所不同,因为
testContext.test不会自动等待。
ServiceWorker 测试
🌐 ServiceWorker tests
ServiceWorkerGlobalScope 包含在其他环境中不存在的非常特定的 API,其中一些 API 看起来与其他 API 类似(例如 fetch),但具有增强的行为。你不希望这些 API 渗入无关的测试中。
import { } from 'node:test';
import { } from './globals/ServiceWorkerGlobalScope.js';
import './setup.mjs'; // 💡
();
function () {
. = new ();
}
import from 'node:assert/strict';
import { , , } from 'node:test';
import { } from './onActivate.js';
('ServiceWorker::onActivate()', () => {
const = .;
const = .(async function () {});
const = .(async function () {});
class extends {
constructor(...) {
super('activate', ...);
}
}
before(() => {
. = {
: { , },
};
});
after(() => {
. = ;
});
('should claim all clients', async () => {
await (new ());
.(..(), 1);
.(..(), 1);
});
});
快照测试
🌐 Snapshot tests
这些由 Jest 推广开来;现在,许多库都实现了这种功能,包括从 v22.3.0 开始的 Node.js。有几种使用场景,比如验证组件渲染输出和基础设施即代码配置。无论使用场景如何,概念都是相同的。
🌐 These were popularised by Jest; now, many libraries implement such functionality, including Node.js as of v22.3.0. There are several use-cases such as verifying component rendering output and Infrastructure as Code config. The concept is the same regardless of use-case.
没有特别需要的配置,只需通过 --experimental-test-snapshots` 启用该功能即可。但为了演示可选配置,你可能会在现有的某个测试配置文件中添加类似如下的内容。
🌐 There is no specific configuration required except enabling the feature via --experimental-test-snapshots. But to demonstrate the optional configuration, you would probably add something like the following to one of your existing test config files.
默认情况下,Node 会生成一个与语法高亮检测不兼容的文件名:.js.snapshot。生成的文件实际上是一个 CJS 文件,因此更合适的文件名应该以 .snapshot.cjs 结尾(或者更简洁地,如下所示为 .snap.cjs);这在 ESM 项目中也会有更好的处理效果。
🌐 By default, node generates a filename that is incompatible with syntax highlighting detection: .js.snapshot. The generated file is actually a CJS file, so a more appropriate file name would end with .snapshot.cjs (or more succinctly .snap.cjs as below); this will also handle better in ESM projects.
import { , , , } from 'node:path';
import { snapshot } from 'node:test';
snapshot.();
/**
* @param {string} testFilePath '/tmp/foo.test.js'
* @returns {string} '/tmp/foo.test.snap.cjs'
*/
function () {
const = ();
const = (, );
const = ();
return (, `${}.snap.cjs`);
}
下面的示例演示了如何使用 testing library 对 UI 组件进行快照测试;请注意访问 assert.snapshot 的两种不同方式:
🌐 The example below demonstrates snapshot testing with testing library for UI components; note the two different ways of accessing assert.snapshot):
import { , } from 'node:test';
import { } from '@testing-library/dom';
import { } from '@testing-library/react'; // Any framework (ex svelte)
import { } from './SomeComponent.jsx';
('<SomeComponent>', () => {
// For people preferring "fat-arrow" syntax, the following is probably better for consistency
('should render defaults when no props are provided', => {
const = (< />)..;
..(());
});
('should consume `foo` when provided', function () {
const = (< ="bar" />)..;
this.assert.snapshot(());
// `this` works only when `function` is used (not "fat arrow").
});
});
⚠️
assert.snapshot来自测试上下文(t或this),不是node:assert。这是必要的,因为测试上下文可以访问node:assert无法访问的作用域(如果使用node:assert,每次使用assert.snapshot都必须手动提供,例如snapshot(this, value),这会相当繁琐)。
单元测试
🌐 Unit tests
单元测试是最简单的测试,通常几乎不需要任何特殊条件。大多数测试很可能都是单元测试,因此保持设置的简洁非常重要,因为设置性能的哪怕一点下降都会被放大并产生连锁反应。
🌐 Unit tests are the simplest tests and generally require relatively nothing special. The vast majority of your tests will likely be unit tests, so it is important to keep this setup minimal because a small decrease to setup performance will magnify and cascade.
import { } from 'node:module';
import './setup.mjs'; // 💡
('some-plaintext-loader');
// plain-text files like graphql can now be imported:
// import GET_ME from 'get-me.gql'; GET_ME = '
import from 'node:assert/strict';
import { , } from 'node:test';
import { } from './Cat.js';
import { } from './Fish.js';
import { } from './Plastic.js';
('Cat', () => {
('should eat fish', () => {
const = new ();
const = new ();
.(() => .eat());
});
('should NOT eat plastic', () => {
const = new ();
const = new ();
.(() => .eat());
});
});
用户界面测试
🌐 User Interface tests
UI 测试通常需要 DOM,以及可能的其他浏览器特定 API(例如下面使用的 IndexedDb)。这些往往设置起来非常复杂且成本高昂。
🌐 UI tests generally require a DOM, and possibly other browser-specific APIs (such as IndexedDb used below). These tend to be very complicated and expensive to setup.
如果你使用像 IndexedDb 这样的 API,但它非常隔离,那么像下面这样的全局模拟可能不是最佳选择。相反,可以考虑将这个 beforeEach 移动到将访问 IndexedDb 的特定测试中。需要注意的是,如果访问 IndexedDb(或其他任何 API)的模块本身被广泛使用,那么要么模拟该模块(可能是更好的选择),要么确实保留这里的模拟。
🌐 If you use an API like IndexedDb but it's very isolated, a global mock like below is perhaps not the way to go. Instead, perhaps move this beforeEach into the specific test where IndexedDb will be accessed. Note that if the module accessing IndexedDb (or whatever) is itself widely accessed, either mock that module (probably the better option), or do keep this here.
import { } from 'node:module';
// ⚠️ Ensure only 1 instance of JSDom is instantiated; multiples will lead to many 🤬
import from 'global-jsdom';
import './setup.units.mjs'; // 💡
import { } from './globals/IndexedDb.js';
('some-css-modules-loader');
(, {
: 'https://test.example.com', // ⚠️ Failing to specify this will likely lead to many 🤬
});
// Example of how to decorate a global.
// JSDOM's `history` does not handle navigation; the following handles most cases.
const = ...(.);
.. = function (, , ) {
(, , );
..();
};
beforeEach();
function () {
.indexedDb = new ();
}
你可以进行两种不同级别的 UI 测试:一种类似单元测试(外部依赖被模拟),另一种更倾向于端到端测试(只有像 IndexedDb 这样的外部依赖被模拟,其余的链条是真实的)。前者通常是更纯粹的选择,而后者通常会被推迟到通过像 Playwright 或 Puppeteer 之类的工具进行的完全端到端自动化可用性测试。下面是前者的一个示例。
🌐 You can have 2 different levels of UI tests: a unit-like (wherein externals & dependencies are mocked) and a more end-to-end (where only externals like IndexedDb are mocked but the rest of the chain is real). The former is generally the purer option, and the latter is generally deferred to a fully end-to-end automated usability test via something like Playwright or Puppeteer. Below is an example of the former.
import { , , , } from 'node:test';
import { } from '@testing-library/dom';
import { } from '@testing-library/react'; // Any framework (ex svelte)
// ⚠️ Note that SomeOtherComponent is NOT a static import;
// this is necessary in order to facilitate mocking its own imports.
('<SomeOtherComponent>', () => {
let ;
let ;
(async () => {
// ⚠️ Sequence matters: the mock must be set up BEFORE its consumer is imported.
// Requires the `--experimental-test-module-mocks` be set.
= .('./calcSomeValue.js', {
: .(),
});
({ } = await import('./SomeOtherComponent.jsx'));
});
('when calcSomeValue fails', () => {
// This you would not want to handle with a snapshot because that would be brittle:
// When inconsequential updates are made to the error message,
// the snapshot test would erroneously fail
// (and the snapshot would need to be updated for no real value).
('should fail gracefully by displaying a pretty error', () => {
.mockImplementation(function () {
return null;
});
(< />);
const = .queryByText('unable');
assert.ok();
});
});
});