This article was translated from Japanese by AI and may contain inaccuracies. For the most accurate content, please refer to the original Japanese version.
Node.js

Node.js の標準 API にテストランナーが追加された

JavaScript のテストランナーといえば、Jest がデファクトスタンダードと言えるでしょう。最近は Vitest と呼ばれる新たなテストランナーも登場していますが、しばらくは Jest が使われ続けられることでしょう。 そんな中、Node.js 18 から標準 API としてテストランナーが組み込まれました。まだ実験的な機能ではありますが、サードパーティのライブラリを使用せずに扱えることもあって注目を集めています。

Note

この機能は現在(2022/05/08)実験的な機能です。

JavaScript のテストランナーといえば、Jest がデファクトスタンダードと言えるでしょう。All In One の機能を持っており、React、Vue などのプロジェクトを作成した際にデフォルトで組み込まれていることもあって広く使われています。最近は Vitest と呼ばれる新たなテストランナーも登場していますが、しばらくは Jest が使われ続けられることでしょう。

そんな中、Node.js 18 から標準 API としてテストランナーが組み込まれました。まだ実験的な機能ではありますが、サードパーティのライブラリを使用せずに扱えることもあって注目を集めています。

基本的な使い方

Node.js のテストランナーの基本的な使い方を見ていきましょう。もっとも簡単なコード例は以下のとおりです。

import test from "node:test"; // ①
import assert from "node:assert";
 
const add = (a, b) => a * b;
 
test("add", (t) => { // ②
  t.test("adds 1 + 2 to equal 3", () => { // ③
    assert.strictEqual(add(1, 2), 3); // ④
  });
});

①で node:test モジュールからテストランナーをインポートします。Node.js のコアモジュールなので node: プレフィックスが付与されていますが、その他のコアモジュールのように node: を外してインポートすると動作が変わることに注意してください。例えば、fs モジュールは import fs from node:fsimport fs from fs どちらでも動作は変わりません。しかしながら、import test from test と書くとユーザーランドの test モジュールを探索します。

続いて②の行で test 関数を呼び出してテストの内容を記述します。これは Jest の describe に相当するものなのでわかりやすいでしょう。1 つ異なる点として、関数の引数として TestContext オブジェクトを受け取ります。この引数 t はサブテスト内でテストを記述する際に使用されます。③ではこの引数 t を利用して t.test とテストを記述しています。t.test 関数はトップレベルで使用している test 関数と同一のものです。

最後に④では assert モジュールを使用してアサーションを実行します。strictEqualは 2 つの引数が厳密に等しいか検査します。

テストを実行するには --test フラグを付与して実行します。

$ node --test index.mjs

どうやらテストが失敗しているようですので、以下のように出力されました。

$ node --test index.mjs
TAP version 13
not ok 1 - /work/node-test/index.mjs
  ---
  duration_ms: 0.120620079
  failureType: 'subtestsFailed'
  exitCode: 1
  stdout: |-
    TAP version 13
        not ok 1 - adds 1 + 2 to equal 3
          ---
          duration_ms: 0.004594414
          failureType: 'testCodeFailure'
          error: |-
            Expected values to be strictly equal:
 
            2 !== 3
 
          code: 'ERR_ASSERTION'
          stack: |-
            TestContext.<anonymous> (file:///Users/asaiippei/work/node-test/index.mjs:8:12)
            Test.runInAsyncScope (node:async_hooks:202:9)
            Test.run (node:internal/test_runner/test:340:20)
            Test.start (node:internal/test_runner/test:292:17)
            TestContext.test (node:internal/test_runner/test:63:20)
            TestContext.<anonymous> (file:///Users/asaiippei/work/node-test/index.mjs:7:5)
            Test.runInAsyncScope (node:async_hooks:202:9)
            Test.run (node:internal/test_runner/test:340:20)
            Test.start (node:internal/test_runner/test:292:17)
            Test.test (node:internal/test_runner/harness:126:18)
          ...
        1..1
    not ok 1 - add
      ---
      duration_ms: 0.010138586
      failureType: 'subtestsFailed'
      error: '1 subtest failed'
      code: 'ERR_TEST_FAILURE'
      ...
    1..1
    # tests 1
    # pass 0
    # fail 1
    # skipped 0
    # todo 0
    # duration_ms 0.093816419
 
  stderr: |-
    (node:79982) ExperimentalWarning: The test runner is an experimental feature. This feature could change at any time
    (Use `node --trace-warnings ...` to show where the warning was created)
 
  error: 'test failed'
  code: 'ERR_TEST_FAILURE'
  ...
1..1
# tests 1
# pass 0
# fail 1
# skipped 0
# todo 0
# duration_ms 0.179024336
test:node-test asaiippei$ node --test index.mjs
TAP version 13
not ok 1 - /Users/asaiippei/work/node-test/index.mjs
  ---
  duration_ms: 0.096359842
  failureType: 'subtestsFailed'
  exitCode: 1
  stdout: |-
    TAP version 13
        not ok 1 - adds 1 + 2 to equal 3
          ---
          duration_ms: 0.001862723
          failureType: 'testCodeFailure'
          error: |-
            Expected values to be strictly equal:
 
            2 !== 3
 
          code: 'ERR_ASSERTION'
          stack: |-
            TestContext.<anonymous> (file:///Users/asaiippei/work/node-test/index.mjs:8:12)
            Test.runInAsyncScope (node:async_hooks:202:9)
            Test.run (node:internal/test_runner/test:340:20)
            Test.start (node:internal/test_runner/test:292:17)
            TestContext.test (node:internal/test_runner/test:63:20)
            TestContext.<anonymous> (file:///Users/asaiippei/work/node-test/index.mjs:7:5)
            Test.runInAsyncScope (node:async_hooks:202:9)
            Test.run (node:internal/test_runner/test:340:20)
            Test.start (node:internal/test_runner/test:292:17)
            Test.test (node:internal/test_runner/harness:126:18)
          ...
        1..1
    not ok 1 - add
      ---
      duration_ms: 0.003572907
      failureType: 'subtestsFailed'
      error: '1 subtest failed'
      code: 'ERR_TEST_FAILURE'
      ...
    1..1
    # tests 1
    # pass 0
    # fail 1
    # skipped 0
    # todo 0
    # duration_ms 0.075152248
 
  stderr: |-
    (node:79992) ExperimentalWarning: The test runner is an experimental feature. This feature could change at any time
    (Use `node --trace-warnings ...` to show where the warning was created)
 
  error: 'test failed'
  code: 'ERR_TEST_FAILURE'
  ...
1..1
# tests 1
# pass 0
# fail 1
# skipped 0
# todo 0
# duration_ms 0.184647765

期待結果は 3 ですが実際には 2 が返されていると報告されています。add 関数の実装が間違っているのでこれを修正しましょう。

- const add = (a, b) => a * b;
+ const add = (a, b) => a + b;

再度テストを実行してみます。

$ node --test index.mjs
TAP version 13
ok 1 - /work/node-test/index.mjs
  ---
  duration_ms: 0.110990585
  ...
1..1
# tests 1
# pass 1
# fail 0
# skipped 0
# todo 0
# duration_ms 0.306012913

すべてのテストがパスしていることが確認できました。この例では特定のファイル index.mjs を指定してテストを実行しましたが、コマンドの引数を指定しなかった場合には以下の命名規則に従うことでそのファイルを対象にテストを実行します。

  • test ディレクトリ配下に存在する .js.cjs.mjs ファイルはすべてテストファイルとして扱われ再帰的に実行される
  • その他のディレクトリにおいて以下にマッチする .js.cjs.mjs ファイル
    • ^test$ - 例:test.js, test.cjs, test.mjs
    • ^test-.+ - 'test-' で始まるファイル名。例: test-example.jstest-another-example.mjs
    • .+[\.\-\_]test$ - .test-test_test で終わるファイル名。例:

また node_modules 配下のファイルはテストの対象となりません。

複数のサブテストの注意点

テストブロックに以下のように複数のサブテストを配置するとテストが失敗していまいます。

import test from "node:test";
import assert from "node:assert";
 
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;
 
test("math tests", (t) => {
  t.test("adds 1 + 2 to equal 3", () => {
    assert.strictEqual(add(1, 2), 3);
  });
 
  t.test("substracts 1 - 2 to equal -1", () => {
    assert.strictEqual(subtract(1, 2), -1);
  });
 
  t.test("multiplys 1 * 2 to equal 2", () => {
    assert.strictEqual(multiply(1, 2), 2);
  });
 
  t.test("divide 1 / 2 to equal 0.5", () => {
    assert.strictEqual(divide(1, 2), 0.5);
  });
});
$ node --test 
TAP version 13
not ok 1 - /work/node-test/index.test.mjs
  ---
  duration_ms: 0.079692407
  failureType: 'subtestsFailed'
  exitCode: 1
  stdout: |-
    TAP version 13
        ok 1 - substracts 1 - 2 to equal -1
          ---
          duration_ms: 0.000273135
          ...
        not ok 2 - multiplys 1 * 2 to equal 2
          ---
          duration_ms: 0.000352485
          failureType: 'cancelledByParent'
          error: 'test did not finish before its parent and was cancelled'
          code: 'ERR_TEST_FAILURE'
          ...
        not ok 3 - divide 1 / 2 to equal 0.5
          ---
          duration_ms: 0.000080198
          failureType: 'cancelledByParent'
          error: 'test did not finish before its parent and was cancelled'
          code: 'ERR_TEST_FAILURE'
          ...
        1..3
    not ok 1 - math tests
      ---
      duration_ms: 0.002949251
      failureType: 'subtestsFailed'
      error: '2 subtests failed'
      code: 'ERR_TEST_FAILURE'
      ...
    ok 2 - adds 1 + 2 to equal 3
      ---
      duration_ms: 0.000177918
      ...
    1..2
    # tests 2
    # pass 1
    # fail 1
    # skipped 0
    # todo 0
    # duration_ms 0.056472885
 
  stderr: |-
    (node:80739) ExperimentalWarning: The test runner is an experimental feature. This feature could change at any time
    (Use `node --trace-warnings ...` to show where the warning was created)
 
  error: 'test failed'
  code: 'ERR_TEST_FAILURE'
  ...
1..1
# tests 1
# pass 0
# fail 1
# skipped 0
# todo 0
# duration_ms 0.136012341

失敗した理由には cancelledByParent となっており「test did not finish before its parent and was cancelled」つまり「テストは親より先に終了せず、キャンセルされた」とエラーメッセージが出力されています。

これはデフォルトでは親のテストは子のテストの終了を待たずに終了してしまうためです。親のテストが終了すると子のテストはキャンセルされ失敗扱いとなります。この挙動を回避する他には子テストを await する必要があります。

test("math tests", async (t) => {
  await t.test("adds 1 + 2 to equal 3", () => {
    assert.strictEqual(add(1, 2), 3);
  });
 
  await t.test("substracts 1 - 2 to equal -1", () => {
    assert.strictEqual(subtract(1, 2), -1);
  });
 
  await t.test("multiplys 1 * 2 to equal 2", () => {
    assert.strictEqual(multiply(1, 2), 2);
  });
 
  await t.test("divide 1 / 2 to equal 0.5", () => {
    assert.strictEqual(divide(1, 2), 0.5);
  });
});

テストのスキップ

skiptodo オプションにより特定のテストの実行をスキップできます。

import test from "node:test";
import assert from "node:assert";
 
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;
 
// test 関数の第2引数に { skip: true } を指定すると、このブロックのテストがスキップされます。
test("adds 1 + 2 to equal 3", { skip: true }, () => {
  assert.strictEqual(add(1, 2), 4);
});
 
// `true` を指定する代わりにメッセージを渡すことができます。
test("substracts 1 - 2 to equal -1", { skip: "skip test!" }, () => {
  assert.strictEqual(subtract(1, 2), -1);
});
 
test("multiply", (t) => {
  // TestContext によりテストをスキップすることもできます。
  t.skip("skip by TextContext");
  t.test("multiplies 1 * 2 to equal 2", () => {
    assert.strictEqual(multiply(1, 2), 2);
  });
});
 
test("divide", (t) => {
  // todo も同じようにテストをスキップします。
  t.todo("not implemented");
});

特定のテストのみを実行

only オプションを設定したうえで --test-only オプションを付与して実行することで特定のテストのみを実行できます。

import test from "node:test";
import assert from "node:assert";
 
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
const multiply = (a, b) => a * b;
const divide = (a, b) => a / b;
 
// test 関数で `only` オプションを指定します
test("adds 1 + 2 to equal 3", { only: true }, () => {
  assert.strictEqual(add(1, 2), 3);
});
 
test("substracts 1 - 2 to equal -1", () => {
  assert.strictEqual(subtract(1, 2), 999);
});
 
test("multiplys 1 * 2 to equal 2", () => {
  assert.strictEqual(multiply(1, 2), 999);
});
 
test("divide 1 / 2 to equal 0.5", (t) => {
  // `TextContext` の `runOnly` により指定することもできます
  test("divide 1 / 2 to equal 0.5", (t) => {
  t.runOnly(true);
  assert.strictEqual(divide(1, 2), 0.5);
 
  // `false` を指定すると次から実行されなくなります。
  t.runOnly(false);
  assert.strictEqual(divide(1, 0), 999);
});
$ node --test-only --test
TAP version 13
ok 1 - /work/node-test/index.test.mjs
  ---
  duration_ms: 0.148089037
  ...
1..1
# tests 1
# pass 1
# fail 0
# skipped 0
# todo 0
# duration_ms 0.45669191

感想

Node.js 標準のテストランナーはまだ実験的な機能であり、機能も十分とはいえないので Jest から乗り換えるのは時期尚早と言えるでしょう。とはいえサードパーティのパッケージを必要とせずテストを実行できるというのは魅力的ですので、将来に期待したいですね。