使用 Node.js 的测试运行器

Node.js 有一个灵活而强大的内置测试运行器。本指南将向您展示如何设置和使用它。

example/
  ├ …
  ├ src/
    ├ app/…
    └ sw/…
  └ test/
    ├ globals/
      ├ …
      ├ IndexedDb.js
      └ ServiceWorkerGlobalScope.js
    ├ setup.mjs
    ├ setup.units.mjs
    └ setup.ui.mjs

注意:globs 需要 node v21+,并且 globs 本身必须用引号括起来(如果没有,您将获得与预期不同的行为,它可能首先看起来是有效的,但实际上并非如此)。

有些东西你总是需要的,所以把它们放在一个像下面这样的基本设置文件中。这个文件将被其他更定制的设置文件导入。

通用设置

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 的实际资金、等待测试完成的时间等)。

以下每个示例均取自真实世界的项目;它们可能不适合/不适用于您的项目,但每个示例都展示了广泛适用的通用概念。

动态生成测试用例

有时,您可能想要动态生成测试用例。例如,您想要在一堆文件中测试相同的内容。这是可能的,尽管有点神秘。您必须使用 test(您不能使用 describe)+ testContext.test

简单示例

import assert from 'node:assert/strict';
import { test } from 'node:test';

import { detectOsInUserAgent } from '';

const userAgents = [
  {
    ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.3',
    os: 'WIN',
  },
  // …
];

test('Detect OS via user-agent', { concurrency: true }, t => {
  for (const { os, ua } of userAgents) {
    t.test(ua, () => assert.equal(detectOsInUserAgent(ua), os));
  }
});

高级示例

import assert from 'node:assert/strict';
import { test } from 'node:test';

import { getWorkspacePJSONs } from './getWorkspacePJSONs.mjs';

const requiredKeywords = ['node.js', 'sliced bread'];

test('Check package.jsons', { concurrency: true }, async t => {
  const pjsons = await getWorkspacePJSONs();

  for (const pjson of pjsons) {
    // ⚠️ `t.test`, NOT `test`
    t.test(`Ensure fields are properly set: ${pjson.name}`, () => {
      assert.partialDeepStrictEqual(pjson.keywords, requiredKeywords);
    });
  }
});

注意:在 23.8.0 版本之前,设置非常不同,因为 testContext.test 不会自动等待。

ServiceWorker 测试

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

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 推广;现在,许多库都实现了这种功能,包括 Node.js v22.3.0。 有几种用例,例如验证组件渲染输出和 基础设施即代码 配置。 无论用例如何,概念都是相同的。

除非通过 --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`);
}

以下示例演示了使用 testing library 进行 UI 组件的快照测试;请注意访问 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),这将非常乏味)。

单元测试

单元测试是最简单的测试,通常不需要任何特殊的东西。您的绝大多数测试很可能是单元测试,因此保持此设置的最小化非常重要,因为设置性能的微小降低会放大和级联。

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

用户界面测试

UI 测试通常需要 DOM,并且可能需要其他特定于浏览器的 API(例如下面使用的 IndexedDb)。这些往往非常复杂且设置成本高昂。

如果您使用像 IndexedDb 这样的 API 但它非常孤立,那么像下面这样的全局模拟可能不是可行的方法。相反,也许可以将此 beforeEach 移动到将访问 IndexedDb 的特定测试中。请注意,如果访问 IndexedDb(或其他)的模块本身被广泛访问,则要么模拟该模块(可能是更好的选择),要么确实将其保留在此处。

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 进行完全端到端的自动化可用性测试。以下是前者的一个例子。

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