使用 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+,并且 glob 本身必须用引号括起来(如果没有,你将获得与预期不同的行为,其中它可能首先看起来在工作但实际上不是)。

¥Note: globs require node v21+, and the globs must themselves be wrapped in quotes (without, you'll get different behaviour than expected, wherein it may first appear to be working but isn't).

有些东西你总是想要,所以把它们放在一个基本的设置文件中,如下所示。此文件将由其他更定制的设置导入。

¥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 { register } from 'node:module';

register('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.

ServiceWorker 测试

¥ServiceWorker tests

ServiceWorkerGlobalScope 包含其他环境中不存在的非常具体的 API,并且它的一些 API 似乎与其他 API(例如 fetch)相似,但具有增强的行为。你不希望这些测试溢出到不相关的测试中。

¥ServiceWorkerGlobalScope contains very specific APIs that don't exist in other environments, and some of its APIs are seemingly similar to others (ex fetch) but have augmented behaviour. You do not want these to spill into unrelated tests.

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);
  });
});

快照测试

¥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 { 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) 的两种不同方式:

¥The example below demonstrates snapshot testing with testing library for UI components; note the two different ways of accessing assert.snapshot):

import { describe, it } from 'node:test';

import { prettyDOM } from '@testing-library/dom';
import { render } from '@testing-library/react'; // Any framework (ex svelte)

import { SomeComponent } from './SomeComponent.jsx';


describe('<SomeComponent>', () => {
  // For people preferring "fat-arrow" syntax, the following is probably better for consistency
  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` works only when `function` is used (not "fat arrow").
  });
});

⚠️ assert.snapshot 来自测试的上下文(tthis),而不是 node:assert。这是必要的,因为测试上下文可以访问 node:assert 无法访问的范围(每次使用 assert.snapshot 时,你都必须手动提供它,例如 snapshot(this, value),这会相当繁琐)。

¥⚠️ assert.snapshot comes from the test's context (t or this), not node:assert. This is necessary because the test context has access to scope that is impossible for node:assert (you would have to manually provide it every time assert.snapshot is used, like snapshot(this, value), which would be rather tedious).

单元测试

¥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 { register } from 'node:module';

import './setup.mjs'; // 💡

register('some-plaintext-loader');
// plain-text files like graphql can now be imported:
// 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('should eat fish', () => {
    const cat = new Cat();
    const fish = new Fish();

    assert.doesNotThrow(() => cat.eat(fish));
  });

  it('should NOT eat plastic', () => {
    const cat = new Cat();
    const plastic = new Plastic();

    assert.throws(() => cat.eat(plastic));
  });
});

用户界面测试

¥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(或其他任何内容)的模块本身被广泛访问,请模拟该模块(可能是更好的选择),或者将其保留在此处。

¥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 { register } from 'node:module';

// ⚠️ Ensure only 1 instance of JSDom is instantiated; multiples will lead to many 🤬
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', // ⚠️ 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 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();
}

你可以拥有 2 个不同级别的 UI 测试:一个单元式(其中模拟外部和依赖)和一个更端到端(其中只有像 IndexedDb 这样的外部被模拟,但链的其余部分是真实的)。前者通常是更纯粹的选择,而后者通常通过 PlaywrightPuppeteer 之类的东西推迟到完全端到端的自动化可用性测试。下面是前者的一个例子。

¥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 { before, describe, mock, it } from 'node:test';

import { screen } from '@testing-library/dom';
import { render } 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.


describe('<SomeOtherComponent>', () => {
  let SomeOtherComponent;
  let calcSomeValue;

  before(async () => {
    // ⚠️ Sequence matters: the mock must be set up BEFORE its consumer is imported.

    // Requires the `--experimental-test-module-mocks` be set.
    calcSomeValue = mock.module('./calcSomeValue.js', { calcSomeValue: mock.fn() });

    ({ SomeOtherComponent } = await import('./SomeOtherComponent.jsx'));
  });

  describe('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).

    it('should fail gracefully by displaying a pretty error', () => {
      calcSomeValue.mockImplementation(function mock__calcSomeValue() { return null });

      render(<SomeOtherComponent>);

      const errorMessage = screen.queryByText('unable');

      assert.ok(errorMessage);
    });
  });
});