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.*'"
}
}
NOTE
globにはNode v21以降が必要です。また、glob自体を引用符で囲む必要があります(囲まないと、期待した動作とは異なる動作になり、最初は機能しているように見える場合がありますが、実際には機能していません)。
常に必要なものがあるので、次のようないくつかの基本的な設定ファイルに入れておきましょう。このファイルは、他のより特化した設定ファイルによってインポートされます。
一般的な設定
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によって普及しました。現在、多くのライブラリがそのような機能を実装しており、Node.jsもv22.3.0から実装しています。コンポーネントのレンダリング出力の検証やInfrastructure as Codeの設定など、いくつかのユースケースがあります。ユースケースに関係なく、概念は同じです。
--experimental-test-snapshots
を介して機能を有効にすることを除いて、特別な設定は必要ありません。しかし、オプションの設定を示すために、既存のテスト設定ファイルの1つに次のようなものを追加するでしょう。
デフォルトでは、ノードは構文ハイライト検出と互換性のないファイル名.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コンポーネントのtesting libraryを使用したスナップショットテストを示しています。assert.snapshot
にアクセスする2つの異なる方法に注意してください。
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
はnode:assert
ではなく、テストのコンテキスト(tまたはthis)から取得されます。これは、テストコンテキストが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('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を使用する場合でも、それが非常に孤立している場合は、下記のようなグローバルモックを使用しない方が良いかもしれません。代わりに、IndexedDb
にアクセスする具体的なテストにこのbeforeEach
を移動することを検討してください。IndexedDb
(またはその他)にアクセスするモジュール自体が広くアクセスされている場合は、そのモジュールをモックするか(おそらくより良い選択肢です)、ここに保持してください。
import { register } from 'node:module';
// ⚠️ JSDomのインスタンスは1つだけインスタンス化してください。複数インスタンス化すると多くの🤬が発生します。
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のような外部のみがモックされているが、残りのチェーンは現実のもの)の2つの異なるレベルがあります。前者は一般的により純粋な選択肢であり、後者は一般的に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('when calcSomeValue fails', () => {
// スナップショットで処理したくないものです。それは壊れやすいからです。
// エラーメッセージに取るに足りない更新が行われると、
// スナップショットテストは誤って失敗します
// (そして、スナップショットは実際には何も価値がないのに更新する必要があります)。
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);
});
});
});