使用 Node.js 的测试运行器

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

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

注意:glob 模式需要 node v21+,并且 glob 模式本身必须用引号包裹(否则,您将得到与预期不同的行为,它可能看起来能工作,但实际上并非如此)。

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

通用设置

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 + 性能:您设置的很多东西可能是特定于环境的模拟/桩(mocks/stubs),这些设置可能非常昂贵,并且会减慢测试运行速度。当您不需要它们时,您希望避免这些成本(您为 CI 支付的实际金钱、等待测试完成的时间等)。

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

动态生成测试用例

有时,您可能希望动态生成测试用例。例如,您想对一堆文件进行相同的测试。这是可能的,尽管有些晦涩。您必须使用 test(不能使用 describe)+ testContext.test

简单示例

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 ) {
    .(, () => .((), ));
  }
});

高级示例

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 测试

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

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

快照测试

这些测试由 Jest 推广开来;现在,许多库都实现了此功能,包括从 v22.3.0 开始的 Node.js。它有多种用例,例如验证组件渲染输出和 基础设施即代码(Infrastructure as Code)配置。无论用例如何,其概念都是相同的。

需要通过 --experimental-test-snapshots 启用该功能,但没有特定的配置要求。但为了演示可选配置,您可能会在现有的测试配置文件中添加类似以下内容。

默认情况下,node 生成的文件名与语法高亮检测不兼容:.js.snapshot。生成的文件实际上是 CJS 文件,所以更合适的文件名应以 .snapshot.cjs 结尾(或者像下面那样更简洁地写成 .snap.cjs);这在 ESM 项目中也能更好地处理。

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

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  = (< />).container.firstChild;

    ..(());
  });

  ('should consume `foo` when provided', function () {
    const  = (< ="bar" />).container.firstChild;

    this.assert.snapshot(());
    // `this` works only when `function` is used (not "fat arrow").
  });
});

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

单元测试

单元测试是最简单的测试,通常不需要任何特殊设置。您的大多数测试可能都是单元测试,因此保持此设置的最小化非常重要,因为设置性能的微小下降会放大并产生连锁反应。

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

用户界面测试

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

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

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 这样的外部项被模拟,而其余的调用链都是真实的)。前者通常是更纯粹的选择,而后者通常会推迟到通过像 PlaywrightPuppeteer 这样的工具进行全端到端的自动化可用性测试。下面是前者的一个示例。

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