Open8

Node.jsのTest runnerを使ってみた

tkktkk

Test Runnerの実行方法

node --test コマンドで実行できる。 npm init した直後のプロジェクトで実行すると以下のように出力される。

なにやら出力された。綺麗だ。

$ node --test

ℹ tests 0
ℹ suites 0
ℹ pass 0
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 3.725192
tkktkk

今回テストするサンプルコード

入力した関数を指定回数実行してからエラーをthrowするシンプルなリトライ関数をテストする。最近自前で書いてお世話になったので。

lib.js
const retry = async (someFunction, maxRetryCount) => {
  let currentCount = 0;
  let err = null;
  while (maxRetryCount >= currentCount) {
    try {
      return await someFunction();
    } catch (e) {
      currentCount++;
      err = e;
      continue;
    }
  }
  throw err;
};

module.exports = {
  retry,
};
tkktkk

まずは簡単にテストを書いてみる

テストコード

ドキュメントを読みながらまずはシンプルなテストを。

lib.test.js

// node:testパッケージが今回メインテーマのTest Runner
// assert関連の関数も標準搭載されているのでこちらを使う
// assertは実装されてから結構長い標準パッケージのようだ
const test = require("node:test");
const assert = require("node:assert");

const lib = require("./lib");

// 簡単な数値比較
test("Success test", async (t) => {
  assert.strictEqual(1, 1);
});

// async関数もテストできる
// バージョンアップのたびに状況が良くなっているとはいえNode.jsで気軽にasyncな関数を検証できるのは便利
test("Success async retry.", async (t) => {
  const res = await lib.retry(() => {
    return 123;
  }, 2);

  assert.strictEqual(res, 123);
});

実行

*.test.js というファイル名でテストファイルを作成すると、ファイル名を指定せずともTest Runnerが自動実行してくれる。便利。

以下のような感じでテストケースの実行結果を出力してくれる。

$ node --test lib.test.js

✔ Success test (0.947609ms)
✔ Success async retry. (0.173258ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 49.756683
tkktkk

ネストしたテストケース

テストのなかにテストも定義できる。

lib2.test.js
const test = require("node:test");
const assert = require("node:assert");

const lib = require("./lib");

// リトライが動作しているかテストしたい
test("Retry function.", async (t) => {
  let loopCount = 0;
  try {
    const res = await lib.retry(async () => {
      loopCount++;
      throw new Error("test");
    }, 2);
  } catch {}

  assert.strictEqual(loopCount, 3);
});

// ネストしたテスト
test("Retry function. Nested test.", async (t) => {
  let loopCount = 0;
  try {
    const res = await lib.retry(async () => {
      loopCount++;

      // testのなかにtestが
      await t.test(`checkpoint ${loopCount}`, (t) => {
        assert.ok(loopCount);
      });

      throw new Error("test");
    }, 2);
  } catch {}

  assert.strictEqual(loopCount, 3);
});

実行

Retry function. Nested test.の中にテスト結果が出力されている。

$ node --test lib2.test.js

✔ Retry function. (1.133513ms)
▶ Retry function. Nested test.
  ✔ checkpoint 1 (0.163339ms)
  ✔ checkpoint 2 (0.097078ms)
  ✔ checkpoint 3 (0.077512ms)
▶ Retry function. Nested test. (0.918553ms)

ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 55.539431
tkktkk

Mocking

オブジェクトの一部のメソッドの挙動を差し替えるなど、モック関連の機能も備えている。関連機能が便利だったので少し紹介。

https://nodejs.org/api/test.html#mocking

テストコード

mocktimer.test.js
const assert = require("node:assert");
const { test } = require("node:test");

test("Function call count", (context) => {
  // fnが実行されるたびにfn.mock.callCount()がインクリメントされる
  // mock.fnに入力された引数なんかも追跡できる
  // 対象のコードにmock.fnを差し込んで関数の挙動を追跡するテストなんかも場合によっては便利そう
  const fn = context.mock.fn(() => 1);
  assert.strictEqual(fn.mock.callCount(), 0);

  fn();
  assert.strictEqual(fn.mock.callCount(), 1);

  fn();
  assert.strictEqual(fn.mock.callCount(), 2);
});

test("setTimeout", (context) => {
  // mock timerのセットアップ setTimeoutに対してtimersを有効にする
  const fn = context.mock.fn();
  context.mock.timers.enable(["setTimeout"]);

  // 有効にしている間はsetTimeoutの時間が進まない
  setTimeout(fn, 9999);
  assert.strictEqual(fn.mock.callCount(), 0);

  // 時間を1進める
  context.mock.timers.tick(1);
  assert.strictEqual(fn.mock.callCount(), 0);

  // 時間を9997進める 1 + 9997 = 9998
  context.mock.timers.tick(9997);
  assert.strictEqual(fn.mock.callCount(), 0);

  // 時間を1進める 9998 + 1 = 9999
  context.mock.timers.tick(1);
  assert.strictEqual(fn.mock.callCount(), 1);
});

実行

setTimeoutを使ったテストがちゃんと通過した。v20.8.1時点では実験的なAPIだよと警告が表示される。

(node:760115) ExperimentalWarning: The MockTimers API is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
✔ Function call count (1.052185ms)
✔ setTimeout (0.663746ms)
ℹ tests 2
ℹ suites 0
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 54.301783
tkktkk

テストカバレッジ

v20.8.1時点では実験的機能だがカバレッジなんかも出力できる。

node --test --experimental-test-coverage で出力可能。

$ node --test --experimental-test-coverage

✔ Success test (1.315008ms)
✔ Success async retry. (0.210672ms)
✔ Retry function. (1.96745ms)
▶ Retry function. Nested test.
  ✔ checkpoint 1 (0.265709ms)
  ✔ checkpoint 2 (0.089615ms)
  ✔ checkpoint 3 (0.099715ms)
▶ Retry function. Nested test. (1.153531ms)

▶ Retry function
  ✔ should success (0.92665ms)
  ▶ should fail
    ✔ checkpoint 1 (0.221682ms)
    ✔ checkpoint 2 (0.131076ms)
    ✔ checkpoint 3 (0.079023ms)
  ▶ should fail (1.222688ms)

▶ Retry function (5.024167ms)

(node:760630) ExperimentalWarning: The MockTimers API is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
✔ Function call count (1.982019ms)
✔ setTimeout (1.384543ms)
ℹ tests 14
ℹ suites 1
ℹ pass 14
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 93.680134
ℹ start of coverage report
ℹ ------------------------------------------------------------------
ℹ file              | line % | branch % | funcs % | uncovered lines
ℹ ------------------------------------------------------------------
ℹ lib.js            | 100.00 |   100.00 |  100.00 |
ℹ lib.test.js       | 100.00 |   100.00 |  100.00 |
ℹ lib2.test.js      | 100.00 |    75.00 |  100.00 |
ℹ lib3.test.js      | 100.00 |    77.78 |  100.00 |
ℹ mocktimer.test.js | 100.00 |   100.00 |  100.00 |
ℹ ------------------------------------------------------------------
ℹ all files         | 100.00 |    85.71 |  100.00 |
ℹ ------------------------------------------------------------------
ℹ end of coverage report
tkktkk

ひとまずここまで。ちょっと遅れてのキャッチアップだったけれども知れてよかった。

Rustのcargo testなんかを使っていると思うが、やっぱり言語が標準でテストツールを準備してくれるのは悩む時間を減らすことにつながるので良い。Jestセットアップ面倒だなと思うこともあったので個人的には刺さった機能だ。