T-CREATOR

Node.js 標準テストランナー完全理解:`node:test` がもたらす新しい DX

Node.js 標準テストランナー完全理解:`node:test` がもたらす新しい DX

Node.js でテストを書く際、これまで Jest や Mocha といったサードパーティのテストフレームワークを導入することが当たり前でした。しかし、Node.js 18 から正式に安定版となった標準テストランナーnode:testにより、外部依存なしでテストを実行できる時代が到来しています。追加のパッケージインストールなしで、すぐにテスト駆動開発を始められる――この手軽さが、モダンな Node.js 開発に新しい DX(Developer Experience)をもたらしているのです。

本記事では、node:testの基本から実践的な活用方法まで、段階的に解説していきますね。

背景

Node.js におけるテスト環境の変遷

Node.js のエコシステムでは、長らく Mocha、Jest、AVA などのサードパーティ製テストフレームワークが主流でした。これらは強力な機能を持つ一方で、以下のような課題も抱えていました。

#課題詳細
1依存関係の増加テストフレームワーク本体に加え、アサーションライブラリやモックライブラリなど複数のパッケージが必要
2設定の複雑さbabel 設定や TypeScript 設定、カバレッジツールの設定など、初期セットアップに時間がかかる
3バージョン管理Node.js のバージョンアップ時に、テストフレームワーク側の互換性を確認する必要がある
4パフォーマンス多機能ゆえに起動時間が長く、小規模プロジェクトでは過剰になることも

こうした背景から、Node.js コアチームは標準機能としてのテストランナーを開発することを決定しました。

node:testの登場と安定化プロセス

node:testは以下のバージョンで段階的に導入されました。

#バージョンステータスリリース時期
1Node.js 18.0.0Experimental(実験的機能)2022 年 4 月
2Node.js 18.13.0Stable(安定版)2023 年 1 月
3Node.js 20.0.0機能拡張版2023 年 4 月

Node.js 18.13.0 以降では、追加フラグなしで本番環境でも安心して利用できるようになりました。

以下の図は、Node.js のテスト環境がどのように変化してきたかを示しています。

mermaidflowchart TB
    subgraph old["従来のアプローチ"]
        app1["Node.js アプリ"]
        jest["Jest / Mocha"]
        assert["アサーション<br/>ライブラリ"]
        mock["モック<br/>ライブラリ"]

        app1 --> jest
        jest --> assert
        jest --> mock
    end

    subgraph new["node:test のアプローチ"]
        app2["Node.js アプリ"]
        nodetest["node:test<br/>(標準搭載)"]
        builtin["組み込み<br/>アサーション"]

        app2 --> nodetest
        nodetest --> builtin
    end

    old -.->|シンプル化| new

従来は複数の外部パッケージに依存していましたが、node:testにより Node.js 本体だけでテストが完結するようになりました。

課題

サードパーティフレームワークの依存問題

多くの開発者が直面していた課題は、テストを書くだけのために大量の依存関係を追加しなければならないという点でした。

依存関係の肥大化

典型的な Jest ベースのプロジェクトでは、以下のようなパッケージが必要です。

javascript// package.json の例
{
  "devDependencies": {
    "jest": "^29.7.0",
    "@types/jest": "^29.5.0",
    "ts-jest": "^29.1.0",
    "@jest/globals": "^29.7.0",
    "jest-mock-extended": "^3.0.5"
  }
}

上記の依存関係だけで、node_modulesのサイズは数百 MB に達することも珍しくありません。

バージョン互換性の維持コスト

Node.js をアップグレードする際、テストフレームワーク側の対応を待つ必要があり、最新機能の恩恵を即座に受けられないケースがありました。

以下の表は、各フレームワークの対応状況を示しています。

#フレームワークNode.js 新バージョン対応設定ファイル数学習コスト
1Jestリリースから 1〜2 ヶ月後2〜3 個★★★
2Mochaリリースから 1〜2 週間後1〜2 個★★
3AVAリリースから 1 ヶ月後1 個★★
4node:test即座(標準機能)不要

初心者の参入障壁

Node.js を学び始めた初心者にとって、「テストを書くためにまず設定を理解しなければならない」という状況は大きな障壁でした。

javascript// Jest の設定例 - 初心者には難解
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
  collectCoverageFrom: [
    'src/**/*.{js,ts}',
    '!src/**/*.d.ts',
  ],
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
};

上記のような設定ファイルを理解する前に、まずテストそのものの書き方を学びたいというニーズがありました。

以下の図は、従来のテスト環境構築に必要な学習ステップを示しています。

mermaidflowchart LR
    start["テストを<br/>書きたい"] --> step1["フレームワーク<br/>選定"]
    step1 --> step2["インストール"]
    step2 --> step3["設定ファイル<br/>作成"]
    step3 --> step4["型定義<br/>インストール"]
    step4 --> step5["初めて<br/>テストを実行"]

    style start fill:#e1f5ff
    style step5 fill:#c8e6c9

テストを実行するまでに多くのステップを踏む必要があり、初心者には負担が大きかったのです。

解決策

node:testによる標準化

node:testは、上記の課題を根本的に解決する標準テストランナーとして設計されました。

ゼロ設定でテストが実行可能

node:testの最大の特徴は、追加のパッケージインストールや設定ファイルが一切不要という点です。

javascript// test.js - 最小限のテストコード
import { test } from 'node:test';
import assert from 'node:assert';

test('足し算のテスト', () => {
  assert.strictEqual(1 + 1, 2);
});

上記のコードを保存して、以下のコマンドを実行するだけです。

bashnode --test test.js

設定ファイルは不要で、すぐにテストが実行されます。

組み込みアサーション

Node.js の標準モジュールnode:assertを使用することで、別途アサーションライブラリを追加する必要がありません。

javascriptimport assert from 'node:assert';

// 厳密な等価性チェック
assert.strictEqual(actual, expected);

// オブジェクトの深い比較
assert.deepStrictEqual(actualObj, expectedObj);

// 例外のテスト
assert.throws(() => {
  throw new Error('エラー');
}, /エラー/);

これにより、依存関係を最小限に保ちながら、十分な検証機能が利用できますね。

並列実行と高速化

node:testは、デフォルトでテストを並列実行する設計になっており、大規模なテストスイートでも高速に実行できます。

bash# 自動的に並列実行される
node --test tests/

# 並列数を指定することも可能
node --test --test-concurrency=4 tests/

以下の図は、node:testの実行フローを示しています。

mermaidflowchart TB
    start["テスト実行<br/>コマンド"] --> discover["テストファイル<br/>検出"]
    discover --> parallel["並列実行<br/>スケジューラー"]

    parallel --> test1["テスト1"]
    parallel --> test2["テスト2"]
    parallel --> test3["テスト3"]

    test1 --> collect["結果収集"]
    test2 --> collect
    test3 --> collect

    collect --> report["レポート<br/>出力"]

並列実行により、従来のテストフレームワークと比較して実行時間を大幅に短縮できます。

ネイティブ TypeScript サポート

Node.js 18 以降では、--loaderフラグを使用することで、TypeScript ファイルを直接テストできます。

typescript// test.ts - TypeScript でテストを記述
import { test } from 'node:test';
import assert from 'node:assert';

test('型安全な足し算', () => {
  const add = (a: number, b: number): number => a + b;
  assert.strictEqual(add(1, 2), 3);
});

実行方法も簡単です。

bash# ts-node やトランスパイラ不要
node --loader tsx --test test.ts

外部トランスパイラを設定する必要がなく、TypeScript での開発体験も向上しました。

モックとスパイ機能

Node.js 20 以降では、モック機能も標準で提供されています。

javascriptimport { test, mock } from 'node:test';
import assert from 'node:assert';

test('関数のモック', () => {
  // モック関数の作成
  const mockFn = mock.fn();

  mockFn('テスト');

  // 呼び出し回数の確認
  assert.strictEqual(mockFn.mock.calls.length, 1);
  // 引数の確認
  assert.strictEqual(
    mockFn.mock.calls[0].arguments[0],
    'テスト'
  );
});

上記のように、Jest スタイルのモック機能が標準で利用できるようになりました。

具体例

基本的なテストの書き方

ここからは、実際のプロジェクトでnode:testを活用する具体例を見ていきましょう。

シンプルな関数のテスト

まず、テスト対象となる関数を定義します。

javascript// src/math.js - テスト対象の関数
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}

export function divide(a, b) {
  if (b === 0) {
    throw new Error('0で割ることはできません');
  }
  return a / b;
}

次に、これらの関数をテストするコードを作成します。

javascript// tests/math.test.js - テストコード
import { test, describe } from 'node:test';
import assert from 'node:assert';
import { add, multiply, divide } from '../src/math.js';

describeを使ってテストをグループ化します。

javascriptdescribe('算術関数のテスト', () => {
  test('add 関数:正の数の足し算', () => {
    assert.strictEqual(add(2, 3), 5);
  });

  test('add 関数:負の数の足し算', () => {
    assert.strictEqual(add(-1, -1), -2);
  });
});

次に、multiply 関数のテストを追加します。

javascriptdescribe('算術関数のテスト', () => {
  // ... 前述のテスト

  test('multiply 関数:正の数の掛け算', () => {
    assert.strictEqual(multiply(3, 4), 12);
  });

  test('multiply 関数:0との掛け算', () => {
    assert.strictEqual(multiply(5, 0), 0);
  });
});

例外処理のテストも記述できます。

javascriptdescribe('算術関数のテスト', () => {
  // ... 前述のテスト

  test('divide 関数:正常な除算', () => {
    assert.strictEqual(divide(10, 2), 5);
  });

  test('divide 関数:0除算でエラー', () => {
    assert.throws(
      () => divide(10, 0),
      /0で割ることはできません/
    );
  });
});

テストを実行します。

bashnode --test tests/math.test.js

非同期処理のテスト

非同期関数のテスト対象を定義します。

javascript// src/api.js - 非同期処理の例
export async function fetchUser(userId) {
  // 模擬的なAPI呼び出し
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({ id: userId, name: 'テストユーザー' });
    }, 100);
  });
}

非同期関数のテストは、async​/​awaitを使用して記述します。

javascript// tests/api.test.js
import { test } from 'node:test';
import assert from 'node:assert';
import { fetchUser } from '../src/api.js';

test('fetchUser:ユーザー情報の取得', async () => {
  const user = await fetchUser(1);

  assert.strictEqual(user.id, 1);
  assert.strictEqual(user.name, 'テストユーザー');
});

複数の非同期処理をテストする場合も同様です。

javascripttest('fetchUser:複数ユーザーの同時取得', async () => {
  const [user1, user2] = await Promise.all([
    fetchUser(1),
    fetchUser(2),
  ]);

  assert.strictEqual(user1.id, 1);
  assert.strictEqual(user2.id, 2);
});

以下の図は、非同期テストの実行フローを示しています。

mermaidsequenceDiagram
    participant test as テストランナー
    participant func as fetchUser
    participant timer as setTimeout

    test->>func: fetchUser(1) 呼び出し
    func->>timer: 100ms 待機開始
    Note over timer: 非同期処理中
    timer-->>func: 完了
    func-->>test: ユーザー情報返却
    test->>test: アサーション実行

node:testは、Promise ベースの非同期処理を自然に扱うことができます。

モックを使った外部依存のテスト

実際のアプリケーションでは、外部 API やデータベースへの依存を持つコードをテストする必要があります。

外部 API 呼び出しのモック

外部 API を呼び出すモジュールを定義します。

javascript// src/userService.js
import https from 'https';

export async function getUserFromAPI(userId) {
  return new Promise((resolve, reject) => {
    https
      .get(
        `https://api.example.com/users/${userId}`,
        (res) => {
          let data = '';
          res.on('data', (chunk) => {
            data += chunk;
          });
          res.on('end', () => {
            resolve(JSON.parse(data));
          });
        }
      )
      .on('error', reject);
  });
}

このモジュールをテストする際、実際の API を呼び出すのは望ましくありません。モックを使用します。

javascript// tests/userService.test.js
import { test, mock } from 'node:test';
import assert from 'node:assert';
import * as userService from '../src/userService.js';

test('getUserFromAPI:モックを使用したテスト', async () => {
  // getUserFromAPI 関数をモック
  const mockGetUser = mock.method(
    userService,
    'getUserFromAPI',
    async (userId) => {
      return { id: userId, name: 'モックユーザー' };
    }
  );

  const user = await userService.getUserFromAPI(1);

  assert.strictEqual(user.name, 'モックユーザー');
  assert.strictEqual(mockGetUser.mock.calls.length, 1);
});

上記のコードでは、実際の HTTP リクエストは発生せず、モック関数が代わりに実行されます。

タイマーのモック

setTimeoutsetIntervalを使うコードも、モックを使えば高速にテストできます。

javascript// src/scheduler.js
export function delayedGreeting(name, delay) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(`こんにちは、${name}さん`);
    }, delay);
  });
}

Node.js 20 以降では、タイマーのモック機能が標準搭載されています。

javascript// tests/scheduler.test.js
import { test, mock } from 'node:test';
import assert from 'node:assert';
import { delayedGreeting } from '../src/scheduler.js';

test('delayedGreeting:タイマーのモック', async () => {
  // タイマーをモック化
  mock.timers.enable({ apis: ['setTimeout'] });

  const promise = delayedGreeting('太郎', 5000);

  // 時間を進める
  mock.timers.tick(5000);

  const result = await promise;
  assert.strictEqual(result, 'こんにちは、太郎さん');

  mock.timers.reset();
});

上記のテストは、実際には 5 秒待機せずに即座に完了します。

テストフック(setup/teardown)

テストの前後で共通の処理を実行したい場合、フック機能を使用できます。

javascript// tests/database.test.js
import {
  test,
  describe,
  before,
  after,
  beforeEach,
  afterEach,
} from 'node:test';
import assert from 'node:assert';

describe('データベーステスト', () => {
  let db;

  // 全テスト実行前に1回だけ実行
  before(async () => {
    db = await connectDatabase();
    console.log('データベース接続完了');
  });

  // 各テスト実行前に毎回実行
  beforeEach(async () => {
    await db.query('BEGIN TRANSACTION');
  });

  // 各テスト実行後に毎回実行
  afterEach(async () => {
    await db.query('ROLLBACK');
  });

  // 全テスト実行後に1回だけ実行
  after(async () => {
    await db.close();
    console.log('データベース接続解除');
  });

  test('ユーザーの作成', async () => {
    const user = await db.createUser({ name: '太郎' });
    assert.strictEqual(user.name, '太郎');
  });

  test('ユーザーの取得', async () => {
    await db.createUser({ name: '花子' });
    const user = await db.findUser({ name: '花子' });
    assert.strictEqual(user.name, '花子');
  });
});

上記のようにフックを活用することで、各テストを独立した状態で実行できます。

以下の図は、フックの実行順序を示しています。

mermaidflowchart TB
    start["テストスイート<br/>開始"] --> before["before<br/>(初期化)"]
    before --> test1start["テスト1 開始"]

    test1start --> beforeEach1["beforeEach"]
    beforeEach1 --> test1["テスト1 実行"]
    test1 --> afterEach1["afterEach"]

    afterEach1 --> test2start["テスト2 開始"]
    test2start --> beforeEach2["beforeEach"]
    beforeEach2 --> test2["テスト2 実行"]
    test2 --> afterEach2["afterEach"]

    afterEach2 --> after["after<br/>(クリーンアップ)"]
    after --> done["テストスイート<br/>終了"]

    style before fill:#e3f2fd
    style after fill:#e3f2fd
    style beforeEach1 fill:#fff3e0
    style afterEach1 fill:#fff3e0
    style beforeEach2 fill:#fff3e0
    style afterEach2 fill:#fff3e0

この仕組みにより、テストごとに環境をリセットでき、テスト間の干渉を防げます。

カバレッジレポートの取得

Node.js 20 以降では、コードカバレッジ機能も標準搭載されています。

bash# カバレッジを有効にしてテスト実行
node --test --experimental-test-coverage tests/

実行結果の例は以下のようになります。

bash# 出力例
✔ tests/math.test.js (4 tests) 12ms
✔ tests/api.test.js (2 tests) 115ms

Coverage report:
File         | Line % | Branch % | Func % | Uncov. Lines
-------------|--------|----------|--------|-------------
src/math.js  | 100.00 |   100.00 | 100.00 |
src/api.js   |  85.71 |    50.00 |  66.67 | 15-17
-------------|--------|----------|--------|-------------
All files    |  92.86 |    75.00 |  83.33 |

上記のレポートにより、どの部分がテストされていないかが一目でわかります。

CI/CD 環境での活用

GitHub Actions などの CI 環境でも、node:testはシンプルに統合できます。

yaml# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [18.x, 20.x, 22.x]

    steps:
      - uses: actions/checkout@v4

      - name: Node.js のセットアップ
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}

      - name: 依存関係のインストール
        run: yarn install

      - name: テスト実行
        run: node --test tests/

      - name: カバレッジレポート生成
        run: node --test --experimental-test-coverage tests/

追加のテストライブラリをインストールする必要がないため、CI の実行時間も短縮されます。

以下の図は、CI/CD パイプラインにおけるnode:testの位置づけを示しています。

mermaidflowchart LR
    push["コード<br/>プッシュ"] --> ci["CI 起動"]
    ci --> setup["Node.js<br/>セットアップ"]
    setup --> install["依存関係<br/>インストール"]
    install --> test["node --test<br/>実行"]
    test --> coverage["カバレッジ<br/>計測"]
    coverage --> result{"テスト<br/>結果"}
    result -->|成功| deploy["デプロイ"]
    result -->|失敗| notify["通知"]

    style test fill:#c8e6c9
    style deploy fill:#bbdefb
    style notify fill:#ffccbc

node:testを使うことで、CI 環境でのセットアップが簡素化され、テストの実行速度も向上しますね。

まとめ

Node.js 標準テストランナーnode:testは、以下のような革新的な価値を開発者にもたらしています。

技術的メリット

  • ゼロ依存: 外部パッケージのインストールが不要で、package.jsondevDependenciesをスリム化できる
  • 高速実行: 並列実行がデフォルトで有効化されており、大規模テストスイートでもパフォーマンスが良好
  • 即座の互換性: Node.js のアップデートと同時に最新機能が利用可能

開発体験の向上

  • 学習コストの低減: 設定不要で即座にテストを書き始められるため、初心者でも取り組みやすい
  • 統一されたエコシステム: Node.js コアチームによる公式サポートで、長期的な安定性が保証される
  • モダンな機能: モック、スパイ、カバレッジなど、必要な機能が標準で揃っている

プロジェクト運用面

  • メンテナンスコストの削減: テストフレームワークのバージョン管理や互換性確認の手間が不要
  • CI/CD の簡素化: セットアップステップが少なく、実行時間も短縮される
  • チーム内の統一: 標準機能のため、プロジェクト間で設定を統一しやすい

Node.js 18 以降を採用している、あるいは採用予定のプロジェクトでは、node:testへの移行を検討する価値が十分にあります。既存の Jest や Mocha からの移行も、API の類似性により比較的スムーズに進められるでしょう。

これから Node.js でテストを書く方にとって、node:testは「まず試してみるべき第一選択肢」となることは間違いありません。シンプルで強力、そして標準という安心感――この新しい DX を、ぜひ体験してみてください。

関連リンク