Jest をやめて Node 組み込みのテストランナーへ
Node test runnerとJestを比較。安定機能、watch mode、snapshot、fake timers、coverage、TypeScript対応、移行時の制約を解説。
Node 組み込みのテストランナーは、サーバーサイドのテストスイートの大半で Jest を置き換えられます。ランナー本体は Node 20.0.0 以降 Stable であり、watch モード、スナップショットテスト、フェイクタイマーもすべて現時点で利用可能です。古い移行ガイドではいまだに「未対応」として挙げられていますが、実際には存在します。
この移行を後押しするフラストレーションは、多くの人にとって身に覚えのあるものでしょう。サービスは素の ESM なのに、テストコマンドを実行するとトランスフォームのパイプライン、設定ファイル、そしてメジャーアップグレードのたびに壊れる依存関係ツリーを引きずり込む。やりたいことは、関数を実行して結果をアサートするだけなのに。問題は node:test が存在するかどうかではなく、そのどの部分が CI に組み込めるほど安定しているかです。以下では機能の全体像を順に見ていき、Node テストランナーのドキュメントに基づいて各機能の安定度を示し、Jest や Vitest と比べて何を失うのかを整理します。
要点
- Node のテストランナーは v20.0.0 以降 Stable だが、カバレッジは依然として実験的な
--experimental-test-coverageフラグが必要で、watch モードも experimental のままである。 - スナップショットテストは v22.3.0 で導入され、v23.4.0 で Stable になった。
mock.timersによるフェイクタイマーは v23.1.0 以降 Stable で、Dateもモックできる。 - ES モジュールのエクスポートは凍結されているため、
mock.methodで named export を差し替えることはできない。代わりにオブジェクトをエクスポートするか、--experimental-test-module-mocks付きで実験的なmock.module()を使う。 - 型ストリッピングがデフォルトで有効(v24.12.0 以降 Stable)なため、
.tsのテストファイルはローダーなしで実行できる。 - Jest を離れて失うのは機能ではなくエルゴノミクスである。マッチャーの語彙、jsdom 環境、
mockResolvedValueのような 1 行で書けるスタブヘルパーがそれにあたる。
依存ゼロで何が手に入るのか
依存ゼロのベースラインは、構造を作る node:test とアサーションを行う node:assert を node --test で実行する構成です。これだけで describe/it(suite/test のエイリアス)、before/after/beforeEach/afterEach フック、サブテスト、skip と todo、そして失敗時の非ゼロ終了コードが手に入ります。
// math.test.js
import { describe, it } from 'node:test';
import assert from 'node:assert';
describe('add', () => {
it('sums two numbers', () => {
assert.strictEqual(1 + 2, 3);
});
});
node --test
安定度はモジュール単位ではなく機能単位で決まっており、パイプラインを固める前にこの区別を押さえておくことが重要です。各機能の現状は次のとおりです。
| 機能 | フラグ / API | ステータス | バージョン |
|---|---|---|---|
| ランナー本体 | node --test | Stable | v20.0.0 以降 Stable |
| watch モード | --watch | Experimental | v19.2.0 で追加 |
| スナップショット | t.assert.snapshot() | Stable | v22.3.0 で追加、v23.4.0 で Stable |
| フェイクタイマー | mock.timers | Stable | v23.1.0 以降 Stable |
| カバレッジ | --experimental-test-coverage | Experimental | - |
| モジュールモック | mock.module() | Early development | v22.3.0 / v20.18.0 で追加 |
| テストタグ | --experimental-test-tag-filter | Early development | v26.2.0 で追加、v24.19.0 にバックポート |
| TypeScript 型ストリッピング | デフォルトで有効 | Stable | v24.12.0 以降 Stable |
Node テストランナーでの実行とフィルタリング
引数なしの場合、node --test は次のパターンに一致するファイルを検出します:**/*.test.{cjs,mjs,js}、**/*-test.{cjs,mjs,js}、**/*_test.{cjs,mjs,js}、**/test-*.{cjs,mjs,js}、**/test.{cjs,mjs,js}、**/test/**/*.{cjs,mjs,js}。さらに --no-strip-types で型ストリッピングを無効にしない限り、同じ 6 パターンの {cts,mts,ts} 版も対象になります。明示的な glob を引数として渡すこともできます。
フィルタリングは Jest の使い慣れた作法にそのまま対応します。
node --test --test-name-pattern="parses headers" # like jest -t
node --test --test-skip-pattern="integration" # inverse filter
node --test --test-only # honor { only: true }
Jest ユーザーが最初に戸惑うのが --test-only です。テストに { only: true } を付けても、このフラグを渡さない限り何も起きません。テストタグは v26.2.0 で --experimental-test-tag-filter として登場し、v24.19.0 で LTS ラインにバックポートされましたが、いずれも early development の安定度です。フィルタ構文は 2 つのラインで同一ではなく、v26 は真偽値式とワイルドカードを受け付けるのに対し、24.x はタグ名のリテラル一致のみです。いずれにせよ、early development はパイプラインの成否を委ねるには時期尚早です。
watch モード
watch モードは存在し、node --test --watch で起動します。テストファイルとそれが読み込むモジュールを監視し、変更の影響を受けるものを再実行します。ドキュメント上、watch モードは依然として Stability 1(Experimental)で、v19.2.0 で追加されたままです。実務的には、ローカルの開発ループとしては問題ないが、CI スクリプトからは外しておくべき、ということになります。そもそも CI に watch モードは不要です。
{
"scripts": {
"test": "node --test",
"test:watch": "node --test --watch"
}
}
カバレッジはいまだフラグの向こう側
コードカバレッジは依然として --experimental-test-coverage を必要とするため、カバレッジをゲートにしたパイプラインは不安定な領域に踏み込むことを意味します。計測対象は --test-coverage-include と --test-coverage-exclude の glob で絞り込み、CI 向けには lcov レポーターで機械可読な出力を生成します。
node --test --experimental-test-coverage \
--test-coverage-include='src/**' \
--test-reporter=lcov --test-reporter-destination=lcov.info
しきい値の強制も可能で、--test-coverage-lines、--test-coverage-branches、--test-coverage-functions、あるいはプログラマティックな run() API における同等のオプション lineCoverage、branchCoverage、functionCoverage を使います。その他の組み込みレポーターは spec(デフォルト)、tap、dot、junit です。
モック:スパイ、タイマー、そして凍結エクスポートの壁
node:test の mock オブジェクトは、スパイ(mock.fn)、メソッドスタブ(mock.method)、フェイクタイマー(mock.timers)をカバーします。mockResolvedValue は存在せず、非同期の結果は async な mockImplementation でスタブします。アサーションはマッチャーではなく mock.callCount() と mock.calls[n].arguments から読み取ります。
import { test } from 'node:test';
import assert from 'node:assert';
test('spy records calls', (t) => {
const fn = t.mock.fn();
fn('a');
assert.strictEqual(fn.mock.callCount(), 1);
assert.deepStrictEqual(fn.mock.calls[0].arguments, ['a']);
});
フェイクタイマーは v23.1.0 以降 Stable で、setTimeout、setInterval、setImmediate、Date をモックし、tick() や runAll() で時間を進めます。ひとつ知っておくべき落とし穴があります。import { setTimeout } from 'node:timers' のように分割代入でモジュールからタイマーを取り出すと、モックが適用されません。
test('advances mocked time and Date together', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100 });
const fn = t.mock.fn();
setTimeout(fn, 200);
t.mock.timers.tick(200);
assert.strictEqual(fn.mock.callCount(), 1);
assert.strictEqual(Date.now(), 300);
});
本当の制約はモジュールモックです。ES モジュールのエクスポートは凍結されているため、mock.method では named export を差し替えられません。息の長い回避策は、オブジェクトをエクスポートしてそのメソッドをモックすることです。
// before: cannot be stubbed
export function fetchUser(id) { /* ... */ }
// after: stubbable with mock.method(api, 'fetchUser')
export const api = {
fetchUser(id) { /* ... */ },
};
Node は公式の代替手段として mock.module() を提供しており、ESM、CJS、JSON、組み込みモジュールをモックできます。ただしこれは --experimental-test-module-mocks の背後にあり、安定度は early development です。実験には使えても、CI スイートの土台にするべきではありません。
ローダーなしの TypeScript
Node は .ts、.mts、.cts のテストファイルを型ストリッピングによって直接実行します。これはデフォルトで有効(v23.6.0 および v22.18.0 以降)で、v24.12.0 以降 Stable、つまり 24.x LTS ラインで Stable です。--no-strip-types を渡さない限り、テストランナーは TypeScript のファイルパターンも自動的に対象にします。Mehul Kar による Node 20 時代の移行記事にあるような tsx などのローダーを組み込む従来の手順は、テスト実行に関してはもはや過去のものです。ただし型ストリッピングは型を消すだけなので、enum などランタイムに影響する TS 構文には依然としてトランスフォームが必要です。
Jest や Vitest と比べて何を失うのか
正直なところ、トレードオフの対象は機能ではなくエルゴノミクスです。実際に失うものは 3 つあります。1 つ目はマッチャーのエコシステムです。Jest の expect は toHaveBeenNthCalledWith をはじめ数百のコミュニティ製マッチャーを提供しますが、node:assert では deepStrictEqual と mock.calls を組み合わせてアサーションを自作することになります。拡張ポイントは v23.7.0 および v22.14.0 で追加された assert.register() で、テストコンテキスト上にカスタムアサーションを定義できます。2 つ目はブラウザ的な環境です。jsdom や happy-dom に相当するものはないため、DOM に触れるコンポーネントテストは Vitest か Jest に残すべきです。3 つ目はスタブの利便性です。mockResolvedValue はなく、test.each もありません(for...of ループで代用します)。呼び出しごとのスタブはチェーン可能なヘルパーではなく mockImplementationOnce を通します。Erick Wendel の移行ガイドはこれらの対応関係を 1 対 1 でマッピングしていますが、フェイクタイマーの節は mock.timers API の実装より前の内容で、提案段階のドラフトとして読む必要があります。
移行するスイートにとって、結局どうなのか
DOM に一切触れない Node のサービス、CLI、ライブラリであれば、組み込みランナーは Jest が担っていたことの安定した中核を、依存ゼロ・トランスフォーム層なしでカバーします。残る実験的な部分はカバレッジ、watch モード、モジュールモック、タグです。リスクの低い進め方は、パッケージを 1 つ変換し、フラグが外れるまでカバレッジのゲートは既存のツールに任せ、マッチャーに依存したアサーションは触れたついでに書き換えることです。まずは変換したファイル 1 つに対して node --test を実行し、設定ディレクトリのうちどれだけを削除できるか確かめてみてください。
FAQ
node --test はテストファイルを並列実行しますか?
はい。プロセス分離がデフォルトなので、各テストファイルはそれぞれ独自の子プロセスで実行され、--test-concurrency で同時に実行できる数を指定します。単一ファイル内では、test や describe に concurrency オプションを設定しない限り、テストは順番に実行されます。スイートがデータベース、ポート、グローバル状態を共有している場合は、--test-concurrency=1 で一度に 1 ファイルに抑えられます。
移行中に Jest と node:test を併用できますか?
はい。両ランナーは独立しているため、npm スクリプトを分けてファイル単位で移行できます。注意点は検出パターンの重複です。どちらもデフォルトで *.test.js のようなファイルに一致するため、明示的な glob、ディレクトリの分離、あるいは Jest の testMatch 設定で各ランナーの範囲を限定し、変換済みのファイルが二重に実行されたり、未変換のファイルが node --test で失敗したりしないようにしてください。
node:test は CommonJS プロジェクトでも動きますか?
はい。ランナーはモジュールシステムに依存しません。CommonJS ファイルでも require('node:test') と require('node:assert') が動作し、デフォルトの検出パターンには .mjs や .js と並んで .cjs が明示的に含まれています。唯一の要件は node: スキームで、require('test') や import test from 'test' は失敗します。ESM と CJS のテストファイルが混在するコードベースでも、同一の node --test 実行でまとめて動かせます。
CI で node:test を採用するにはどの Node バージョンを対象にすべきですか?
Node 24 LTS が安定した中核をカバーします。ランナー本体(v20.0.0 以降 Stable)、スナップショットテスト、mock.timers によるフェイクタイマー、デフォルトの TypeScript 型ストリッピングが含まれます。カバレッジと watch モードは、どのリリースラインでも experimental のままです。より新しいテストランナー機能のうち 2 つ、v24.19.0 のテストタグと v24.16.0 の実行順ランダム化は、current ラインに留まらずバックポートで 24.x に到達していますが、いずれも early development の段階なので、まだ CI のゲートを組むべきではありません。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k