使用 Node.js 的测试运行器
Node.js 有一个灵活且强大的内置测试运行器。本指南将向您展示如何设置和使用它。
example/
├ …
├ src/
├ app/…
└ sw/…
└ test/
├ globals/
├ …
├ IndexedDb.js
└ ServiceWorkerGlobalScope.js
├ setup.mjs
├ setup.units.mjs
└ setup.ui.mjs
npm init -y
npm install --save-dev concurrently
{
"name": "example",
"scripts": {
"test": "concurrently --kill-others-on-fail --prefix none npm:test:*",
"test:sw": "node --import ./test/setup.sw.mjs --test './src/sw/**/*.spec.*'",
"test:units": "node --import ./test/setup.units.mjs --test './src/app/**/*.spec.*'",
"test:ui": "node --import ./test/setup.ui.mjs --test './src/app/**/*.test.*'"
}
}
注意
globs 需要 node v21+,并且 globs 本身必须用引号括起来(否则,您将得到与预期不同的行为,它可能最初看起来有效,但实际上并非如此)。
有一些东西你总是需要的,所以把它们放在一个基本的设置文件中,如下所示。这个文件将被其他更定制的设置文件导入。
通用设置
import { register } from 'node:module';
register('some-typescript-loader');
// 此后支持 TypeScript
// 但其他 test/setup.*.mjs 文件必须是纯 JavaScript!
然后,为每个设置创建一个专用的 setup
文件(确保在每个文件中都导入了基础 setup.mjs
文件)。隔离设置的原因有很多,但最明显的原因是 YAGNI + 性能:您可能正在设置的大部分内容都是环境特定的模拟/存根,这可能非常昂贵,并且会减慢测试运行速度。当您不需要它们时,您想避免这些成本(您支付给 CI 的实际金钱,等待测试完成的时间等)。
下面的每个示例都来自真实的项目;它们可能不适合/适用于您的项目,但每个示例都演示了广泛适用的通用概念。
ServiceWorker 测试
ServiceWorkerGlobalScope
包含在其他环境中不存在的非常具体的 API,并且它的一些 API 与其他 API(例如 fetch
)看似相似,但具有增强的行为。您不希望这些泄漏到不相关的测试中。
import { beforeEach } from 'node:test';
import { ServiceWorkerGlobalScope } from './globals/ServiceWorkerGlobalScope.js';
import './setup.mjs'; // 💡
beforeEach(globalSWBeforeEach);
function globalSWBeforeEach() {
globalThis.self = new ServiceWorkerGlobalScope();
}
import assert from 'node:assert/strict';
import { describe, mock, it } from 'node:test';
import { onActivate } from './onActivate.js';
describe('ServiceWorker::onActivate()', () => {
const globalSelf = globalThis.self;
const claim = mock.fn(async function mock__claim() {});
const matchAll = mock.fn(async function mock__matchAll() {});
class ActivateEvent extends Event {
constructor(...args) {
super('activate', ...args);
}
}
before(() => {
globalThis.self = {
clients: { claim, matchAll },
};
});
after(() => {
global.self = globalSelf;
});
it('should claim all clients', async () => {
await onActivate(new ActivateEvent());
assert.equal(claim.mock.callCount(), 1);
assert.equal(matchAll.mock.callCount(), 1);
});
});
快照测试
Jest 推广了这种测试方法;现在许多库都实现了此功能,包括从 v22.3.0 版本开始的 Node.js。它有几个用例,例如验证组件渲染输出和基础设施即代码 配置。无论用例如何,其概念都是相同的。
除了通过 --experimental-test-snapshots
启用该功能外,不需要任何特定配置。但为了演示可选配置,您可能会将如下内容添加到您现有的测试配置文件之一。
默认情况下,Node 生成的文件名与语法高亮检测不兼容:.js.snapshot
。生成的 文件实际上是一个 CJS 文件,因此更合适的名称应该以 .snapshot.cjs
结尾(或者像下面那样更简洁的 .snap.cjs
);这在 ESM 项目中也能更好地处理。
import { basename, dirname, extname, join } from 'node:path';
import { snapshot } from 'node:test';
snapshot.setResolveSnapshotPath(generateSnapshotPath);
/**
* @param {string} testFilePath '/tmp/foo.test.js'
* @returns {string} '/tmp/foo.test.snap.cjs'
*/
function generateSnapshotPath(testFilePath) {
const ext = extname(testFilePath);
const filename = basename(testFilePath, ext);
const base = dirname(testFilePath);
return join(base, `${filename}.snap.cjs`);
}
下面的示例演示了使用针对 UI 组件的测试库 进行快照测试;请注意访问 assert.snapshot
的两种不同方式:
import { describe, it } from 'node:test';
import { prettyDOM } from '@testing-library/dom';
import { render } from '@testing-library/react'; // 任何框架 (例如 svelte)
import { SomeComponent } from './SomeComponent.jsx';
describe('<SomeComponent>', () => {
// 对于更喜欢“胖箭头”语法的人来说,以下内容可能更利于保持一致性
it('should render defaults when no props are provided', (t) => {
const component = render(<SomeComponent />).container.firstChild;
t.assert.snapshot(prettyDOM(component));
});
it('should consume `foo` when provided', function() {
const component = render(<SomeComponent foo="bar" />).container.firstChild;
this.assert.snapshot(prettyDOM(component));
// `this` 只在使用 `function` 时有效(不是“胖箭头”)。
});
});
WARNING
assert.snapshot
来自测试上下文 (t 或 this),而不是 node:assert
。这是必要的,因为测试上下文可以访问 node:assert
无法访问的范围(您必须在每次使用 assert.snapshot
时手动提供它,例如 snapshot (this, value)
,这将相当繁琐)。
单元测试
单元测试是最简单的测试,通常不需要什么特别的准备。你的大部分测试都可能是单元测试,因此保持设置简洁非常重要,因为设置性能的少量下降会放大并层层累加。
import { register } from 'node:module';
import './setup.mjs'; // 💡
register('some-plaintext-loader');
// 现在可以导入纯文本文件,例如 graphql:
// import GET_ME from 'get-me.gql'; GET_ME = '
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { Cat } from './Cat.js';
import { Fish } from './Fish.js';
import { Plastic } from './Plastic.js';
describe('Cat', () => {
it('应该吃鱼', () => {
const cat = new Cat();
const fish = new Fish();
assert.doesNotThrow(() => cat.eat(fish));
});
it('不应该吃塑料', () => {
const cat = new Cat();
const plastic = new Plastic();
assert.throws(() => cat.eat(plastic));
});
});
用户界面测试
UI 测试通常需要 DOM,以及其他特定于浏览器的 API(例如下面使用的 IndexedDb
)。这些测试的设置通常非常复杂且成本高昂。
如果您使用像 IndexedDb
这样的 API,但它非常独立,那么像下面这样的全局模拟可能不是最佳方案。相反,也许可以将这个 beforeEach
移到将访问 IndexedDb
的特定测试中。请注意,如果访问 IndexedDb
(或其他内容)的模块本身被广泛访问,则模拟该模块(可能是更好的选择),或者保留在这里。
import { register } from 'node:module';
// ⚠️ 确保只实例化 1 个 JSDom 实例;多个实例会导致许多 🤬
import jsdom from 'global-jsdom';
import './setup.units.mjs'; // 💡
import { IndexedDb } from './globals/IndexedDb.js';
register('some-css-modules-loader');
jsdom(undefined, {
url: 'https://test.example.com', // ⚠️ 未指定此项可能会导致许多 🤬
});
// 全局装饰示例。
// JSDOM 的 `history` 不处理导航;以下内容处理大多数情况。
const pushState = globalThis.history.pushState.bind(globalThis.history);
globalThis.history.pushState = function mock_pushState(data, unused, url) {
pushState(data, unused, url);
globalThis.location.assign(url);
};
beforeEach(globalUIBeforeEach);
function globalUIBeforeEach() {
globalThis.indexedDb = new IndexedDb();
}
您可以拥有两个不同级别的 UI 测试:单元级(其中外部依赖项被模拟)和更端到端的测试(其中只有像 IndexedDb 这样的外部依赖项被模拟,而其余链是真实的)。前者通常是更纯粹的选择,后者通常被推迟到通过 Playwright 或 Puppeteer 等工具进行的完全端到端的自动化可用性测试。以下是前者的示例。
import { before, describe, mock, it } from 'node:test';
import { screen } from '@testing-library/dom';
import { render } from '@testing-library/react'; // 任何框架(例如 svelte)
// ⚠️ 请注意,SomeOtherComponent 不是静态导入;
// 这是为了便于模拟其自身的导入而必需的。
describe('<SomeOtherComponent>', () => {
let SomeOtherComponent;
let calcSomeValue;
before(async () => {
// ⚠️ 顺序很重要:必须在导入其使用者之前设置模拟。
// 需要设置 `--experimental-test-module-mocks`。
calcSomeValue = mock.module('./calcSomeValue.js', { calcSomeValue: mock.fn() });
({ SomeOtherComponent } = await import('./SomeOtherComponent.jsx'));
});
describe('当 calcSomeValue 失败时', () => {
// 您不希望使用快照处理此问题,因为这会很脆弱:
// 当对错误消息进行无关紧要的更新时,
// 快照测试会错误地失败
// (并且需要更新快照,而没有实际价值)。
it('应该通过显示漂亮的错误来优雅地失败', () => {
calcSomeValue.mockImplementation(function mock__calcSomeValue() { return null });
render(<SomeOtherComponent>);
const errorMessage = screen.queryByText('unable');
assert.ok(errorMessage);
});
});
});