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 + パフォーマンスです。設定の多くは環境固有のモックやスタブであり、これらは非常に高コストでテストの実行を遅くする可能性があります。それらが不要な場合は、これらのコスト(CIに支払う実際の費用、テストの終了を待つ時間など)を避けたいはずです。

以下の各例は実際のプロジェクトから引用したものです。あなたのプロジェクトには適切でない、または適用できないかもしれませんが、それぞれが広く適用可能な一般的な概念を示しています。

テストケースの動的生成

テストケースを動的に生成したい場合があります。例えば、多数のファイルに対して同じテストを実行したい場合などです。これは可能ですが、少し複雑です。testdescribeは使えません)と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が自動的にawaitされなかったため、設定がかなり異なります。

ServiceWorkerのテスト

ServiceWorkerGlobalScopeには、他の環境には存在しない非常に特殊な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`);
}

以下の例は、UIコンポーネントに対してtesting libraryを使用したスナップショットテストを示しています。assert.snapshotにアクセスする2つの異なる方法に注意してください。

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は、node:assertからではなく、テストのコンテキスト(tまたはthis)から取得します。これは、テストコンテキストが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など)も必要とします。これらは設定が非常に複雑で高コストになる傾向があります。

IndexedDbのようなAPIを使用しても、それが非常に限定的な範囲でしか使われない場合、以下のようなグローバルなモックは適切ではないかもしれません。代わりに、このbeforeEachIndexedDbがアクセスされる特定のテストに移動することを検討してください。ただし、IndexedDb(またはその他のAPI)にアクセスするモジュール自体が広くアクセスされる場合は、そのモジュールをモックするか(おそらくこちらが良い選択肢です)、あるいはこの設定をそのままにしておくべきです。

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テストには2つの異なるレベルがあります。ひとつは単体テストのようなもの(外部モジュールや依存関係がモックされる)で、もうひとつはよりエンドツーエンドに近いもの(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();
    });
  });
});