Node 18 に Test Runner なるものがあったので試してみた（Experimental）

Devon Govett さんのツイートに「おおっ！😲」となったので Test Runner を試してみました。

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Whoa, just found out that Node has a builtin testing framework now! 😲 <a href="https://t.co/JZhtIDMzS7">https://t.co/JZhtIDMzS7</a> <a href="https://t.co/JLGGhDl5oi">pic.twitter.com/JLGGhDl5oi</a></p>&mdash; Devon Govett (@devongovett) <a href="https://twitter.com/devongovett/status/1522244779962163200?ref_src=twsrc%5Etfw">May 5, 2022</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## リリース状況

Test Runner は Node 18 で導入された新機能のようです！
https://nodejs.org/api/test.html#test-runner

機能が Experimental であること、また Node 18 のアクティブ LTS がスタートしていないことから（2022/05時点）、運用中のアプリケーションでの利用は控えておいたほうが良さそうです。
https://nodejs.org/ja/about/releases/

**2022/05/08 追記**
この記事は2022/05の最新バージョンである Node v18.1.0 で検証したコードを掲載しています。

## Test Runner とは何か

Node ビルトインのテスティングユーティリティです。
追加のパッケージなしでテストが実行できます。

そこそこの大きさのアプリケーションであれば、スタートアップと同時にテスティングフレームワークを導入するケースが多いと思われます。それとは別に小さなスクリプトを書いた場合、たった 1 スクリプトだけのためにテスティングフレームワークを入れてセットアップ作業をするのが面倒に思うこともありますよね。

そんな時にビルトインで使える Test Runner がもってこいではないでしょうか！

## 覗いてみよう

Testing Runner は `node:` スキーマのパッケージをインポートして使います。
アサーションとランナーのパッケージがあるようです。

```js
import assert from 'node:assert';
import test from 'node:test';
```

インポートによって `test()` 関数と、アサーションの `assert` オブジェクトが使えるようになります。

```js
test('it passes', () => {
  assert.equal(1, 1);
});
```

テストファイルは `test/{任意の名前}.mjs` として保存します。
同じディレクトリで node を叩くとレポートが出力されます。
色付けされていない素朴なレポートですが、そこは良しとします。

```sh
node --test

> TAP version 13
> ok 1 - /Users/ringtail003/test/foo.mjs
>  ---
>  duration_ms: 0.068508347
>  ...
> 1..1
> # tests 1
> # pass 1
> # fail 0
> # skipped 0
> # todo 0
> # duration_ms 0.10698865
```

テストの終了ステータスは、成功が `0` で失敗は `1` です。
終了ステータスでテストの通過を判定する CI で `node --test` が使えますね。

```sh
node --test
> ok 1 - /Users/ringtail003/test/foo.mjs
> ...

echo $?
> 0
```

以降は、公式ドキュメントを見ながら試してみたことをゆるゆると書いていきます。

## ファイルの拡張子と置き場所

- `test` ディレクトリ配下のファイル
- `test-` プレフィクスのファイル
- `test` サフィックスのファイル

この 3 つがテストファイルとみなされます。
拡張子は `.js` `.cjs` `.mjs` をサポートします。

```sh
# test ディレクトリ配下
test/foo.js
test/foo.mjs
test/foo.cjs
test/foo/bar/hoge.js
```

```sh
# test- プレフィクス
test-foo.js
test-foo-bar.js
```

```sh
# test サフィックス
foo-test.js
bar_test.js
```

テストファイルの中でテスト対象をインポートしている場合、拡張子は `.mjs` でないと実行時エラーが発生します。

```js
// foo-test.js
import { func } from "foo.js";

// foo.js
export const func = () => {};
```

```sh
node --test

>  stderr: |-
>    (node:58302) Warning: To load an ES module, set "type": "module" 
>    in the package.json or use the .mjs extension.
>    ...
>    import assert from 'node:assert';
>    ^^^^^^
```

`.js` を使いたい場合は `package.json` で `type:module` を指定すればディレクトリまるごと ES Module（`.mjs`）扱いにできます。

```js
// package.json
// プロジェクトのファイル全てに影響するので注意
{
  "name": "foo",
  "version": "1.0.0",
  "type": "module", // <=== 追加
  ...
}
```

```sh
# ES Module として読み込むのでインポート文がエラーにならない
node --test
```

## test 関数の引数

第一引数はテストスイート名です。

```js
test('it passes', () => {});
```

テストが失敗すると、テストスイート名がひっそりと出てきます。

```sh
node --test

> TAP version 13
> not ok 1 - /Users/ringtail003/foo-test.js
> ...
>  stdout: |-
>    TAP version 13
>    not ok 1 - it passes # <===== これ
```

第二引数はオプション指定です。
特に指定がなければオブジェクトごと省略可能です。

```js
test('it passes', { 
  concurrency?: number; // 実行の並列数
  only?: boolean; // このテストスイートのみ実行
  skip?: boolean | string; // このテストスイートをスキップ
  todo?: boolean | string; // TODO コメントを出力してスキップ
}?, () => {});
```

`only` を指定しても他のテストスイートが影響してテストが失敗したり、`todo` コメントを指定してもレポートには出力されなかったりしました。またテスト結果のサマリは、ファイル数をカウントしているようです。`skip` を指定したテストスイートはカウントされませんでした。

このあたりはまだ Experimental なことが起因しているかもしれないですね。

```sh
node --test

# skip や todo がカウントされていないような 🤔
> ...
> # tests 1
> # pass 1
> # fail 0
> # skipped 0
> # todo 0
```


第三引数にはアサーションを含む関数を渡します。

```js
test('it passes', (t, done) => {});
```

この関数は、テストコンテキストを受け取ります。
テストコンテキストは `.todo()` や `.skip()` などのテストスイートに関する操作をもっています。

```js
test('it passes', (t) => {
  t.todo('not implemented.');
  t.skip('not implemented.');
});
```

テストコンテキストの `.test()` を使うとテストを入れ子にできます。

```js
// Test Runner がテストスイートの実行を非同期で待機するため、async / await する
test('it passes', async (t) => {
  await t.test('it ok', () => {
    assert.equal(1, 1);
  });
  
  await t.test('it ok', () => {
    assert.equal(1, 1);
  });
});
```

おなじみの `done` がありました。
n 秒後に終了するテストなど、テスティングフレームワークの非同期テストでもお世話になっているやつです。

```js
test('it passes', (_, done) => {
  // 1 秒後にテストする
  setTimeout(() => {
    assert.equal(1, 1);

    // テストの終了を宣言する
    done();
  }, 1000);
});
```

## それっぽいテストを書いてみる

サンプルとして小さなスクリプトと、そのテストを書いてみます。

HTTP で json を取ってきて出力する小さなスクリプトです。
ダミーのデータは [{JSON} Placeholder](https://jsonplaceholder.typicode.com/) を使用します。


```js
// fetch-users.js

export const fetchUsers = () => {
  return fetch('https://jsonplaceholder.typicode.com/users')
    .then(response => response.json())
    .then(json => json.map(({ id, name }) => ({ id, name })))
  ;
}

const users = await fetchUsers();

console.table(users);
```

このスクリプトはユーザーのリストを 10 件出力します。

```sh
node fetch-users.js

> (node:59260) ExperimentalWarning: The Fetch API is an experimental feature. 
> This feature could change at any time
> (Use `node --trace-warnings ...` to show where the warning was created)

> ┌─────────┬────┬────────────────────────────┐
> │ (index) │ id │            name            │
> ├─────────┼────┼────────────────────────────┤
> │    0    │ 1  │      'Leanne Graham'       │
> │    1    │ 2  │       'Ervin Howell'       │
> │    2    │ 3  │     'Clementine Bauch'     │
> │    3    │ 4  │     'Patricia Lebsack'     │
> │    4    │ 5  │     'Chelsey Dietrich'     │
> │    5    │ 6  │   'Mrs. Dennis Schulist'   │
> │    6    │ 7  │     'Kurtis Weissnat'      │
> │    7    │ 8  │ 'Nicholas Runolfsdottir V' │
> │    8    │ 9  │     'Glenna Reichert'      │
> │    9    │ 10 │    'Clementina DuBuque'    │
> └─────────┴────┴────────────────────────────┘
```

リストが 10 件であること、期待するデータ型であること、先頭のデータが期待する値であること、をテストしてみます。

```js
import assert from 'node:assert';
import test from 'node:test';
import { fetchUsers } from './fetch-users.js';

test('fetchUsers', async () => {
  const users = await fetchUsers();

  await t.test("リストが 10 件であること", () => {
    assert.strictEqual(users.length, 10);
  });

  await t.test("期待するデータ型であること", () => {
    users.forEach(user => {
      assert.strictEqual(typeof user.id, "number");
      assert.strictEqual(typeof user.name, "string");
    });
  });

  await t.test("先頭のデータが期待する値であること", () => {
    assert.strictEqual(users[0].id, 1);
    assert.strictEqual(users[0].name, "Leanne Graham");
  });
});
```

シンプルですね！

ちょっとしたスクリプトだけどテスト書きたい、でもテスティングフレームワークを導入するとメンテナンスコストかかるし面倒、なんて時にピッタリじゃないでしょうか？

## 困ったこと：アサーションが不十分

たとえば Jest では [.toBe](https://jestjs.io/ja/docs/expect#tobevalue) と [.toEqual](https://jestjs.io/ja/docs/expect#toequalvalue) を使い分けることでオブジェクトの等価性を [Same value](https://developer.mozilla.org/ja/docs/Web/JavaScript/Equality_comparisons_and_sameness#abstract_equality_strict_equality_and_same_value_in_the_specification) で検査するか否かを分離できます。Jasmine もしかりです。ところが Test Runner のアサーションには toEqual に該当するものがなさそうです。

また [.toBeGreaterThan](https://jestjs.io/ja/docs/expect#tobegreaterthannumber--bigint) などちょっと便利に使えるアサーションもなく、拡張性がいまひとつでした。

型ファイルの `assert.d.ts` を見たところ `@deprecated` としてマークされているものが多く、アサーションについては変更や拡張の入る可能性が高そうです。

## 困ったこと：console.log したい

個人的に発展途上のコードをテストしながら完成させていくスタイルが多く、まだ完成していない処理を確認するために `console.log` はめちゃくちゃ使っています。

それが Test Runner ではレポートに出力され..ません....！！

```js
test('fetchUsers', async () => {
  const users = await fetchUsers();

  console.log(users); // <=== 見たい！ 🙄
```

正攻法ではなさそうですが `console.log` できないのが痛すぎるので、わざとテストを失敗させてレポートに出力する方法をとりました。

```js
test('fetchUsers', async () => {
  const users = await fetchUsers();

  console.log(users);
  ...

  throw new Error(); // <=== わざと失敗させる
});
```

Test Runner は `test` 関数の中で例外がスローされるか、Reject な Promise が返されるか、done に例外が渡されることでテストを失敗とみなします。

```js
// 例外をスローする
test('failing test', () => {
  throw new Error("");
});

// Reject な Promise を返す
test('failing test', () => {
  return Promise.reject();
});

// done に例外を渡す
test('failing test', (_, done) => {
  done(new Error());
});
```

いずれかの方法を使えば「わざと失敗」することができ、テストファイルに書いた `console.log` と、テスト対象ファイルに書いた `console.table` の両方ともが出力されます。

```sh
node --test

> TAP version 13
> not ok 1 - /Users/ringtail003/fetch-users.test.js
>  ...
> 
>  # テスト対象ファイルに書いた console.table による出力
>  stdout: |-
>    ┌─────────┬────┬────────────────────────────┐
>    │ (index) │ id │            name            │
>    ├─────────┼────┼────────────────────────────┤
>    │    0    │ 1  │      'Leanne Graham'       │
>    │    1    │ 2  │       'Ervin Howell'       │
>    │    2    │ 3  │     'Clementine Bauch'     │
>     ...
>
> TAP version 13
>  # テスト対象ファイルに書いた console.log による出力
>    [
>      { id: 1, name: 'Leanne Graham' },
>      { id: 2, name: 'Ervin Howell' },
>      { id: 3, name: 'Clementine Bauch' },
>      ...
```

## おわりに

Experimental なため破壊的変更が入る可能性もありますが、追加のパッケージなしで実行できるミニマムなテストはそれにまさる大きな魅力があると思っています。

最後まで読んでいただきありがとうございました。
Test Runner で良きテストライフを！😉