Devon Govett さんのツイートに「おおっ!😲」となったので Test Runner を試してみました。
Whoa, just found out that Node has a builtin testing framework now! 😲 https://t.co/JZhtIDMzS7 pic.twitter.com/JLGGhDl5oi
— Devon Govett (@devongovett) May 5, 2022
リリース状況
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:
スキーマのパッケージをインポートして使います。
アサーションとランナーのパッケージがあるようです。
import assert from 'node:assert';
import test from 'node:test';
インポートによって test()
関数と、アサーションの assert
オブジェクトが使えるようになります。
test('it passes', () => {
assert.equal(1, 1);
});
テストファイルは test/{任意の名前}.mjs
として保存します。
同じディレクトリで node を叩くとレポートが出力されます。
色付けされていない素朴なレポートですが、そこは良しとします。
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
が使えますね。
node --test
> ok 1 - /Users/ringtail003/test/foo.mjs
> ...
echo $?
> 0
以降は、公式ドキュメントを見ながら試してみたことをゆるゆると書いていきます。
ファイルの拡張子と置き場所
-
test
ディレクトリ配下のファイル -
test-
プレフィクスのファイル -
test
サフィックスのファイル
この 3 つがテストファイルとみなされます。
拡張子は .js
.cjs
.mjs
をサポートします。
# test ディレクトリ配下
test/foo.js
test/foo.mjs
test/foo.cjs
test/foo/bar/hoge.js
# test- プレフィクス
test-foo.js
test-foo-bar.js
# test サフィックス
foo-test.js
bar_test.js
テストファイルの中でテスト対象をインポートしている場合、拡張子は .mjs
でないと実行時エラーが発生します。
// foo-test.js
import { func } from "foo.js";
// foo.js
export const func = () => {};
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
)扱いにできます。
// package.json
// プロジェクトのファイル全てに影響するので注意
{
"name": "foo",
"version": "1.0.0",
"type": "module", // <=== 追加
...
}
# ES Module として読み込むのでインポート文がエラーにならない
node --test
test 関数の引数
第一引数はテストスイート名です。
test('it passes', () => {});
テストが失敗すると、テストスイート名がひっそりと出てきます。
node --test
> TAP version 13
> not ok 1 - /Users/ringtail003/foo-test.js
> ...
> stdout: |-
> TAP version 13
> not ok 1 - it passes # <===== これ
第二引数はオプション指定です。
特に指定がなければオブジェクトごと省略可能です。
test('it passes', {
concurrency?: number; // 実行の並列数
only?: boolean; // このテストスイートのみ実行
skip?: boolean | string; // このテストスイートをスキップ
todo?: boolean | string; // TODO コメントを出力してスキップ
}?, () => {});
only
を指定しても他のテストスイートが影響してテストが失敗したり、todo
コメントを指定してもレポートには出力されなかったりしました。またテスト結果のサマリは、ファイル数をカウントしているようです。skip
を指定したテストスイートはカウントされませんでした。
このあたりはまだ Experimental なことが起因しているかもしれないですね。
node --test
# skip や todo がカウントされていないような 🤔
> ...
> # tests 1
> # pass 1
> # fail 0
> # skipped 0
> # todo 0
第三引数にはアサーションを含む関数を渡します。
test('it passes', (t, done) => {});
この関数は、テストコンテキストを受け取ります。
テストコンテキストは .todo()
や .skip()
などのテストスイートに関する操作をもっています。
test('it passes', (t) => {
t.todo('not implemented.');
t.skip('not implemented.');
});
テストコンテキストの .test()
を使うとテストを入れ子にできます。
// 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 秒後に終了するテストなど、テスティングフレームワークの非同期テストでもお世話になっているやつです。
test('it passes', (_, done) => {
// 1 秒後にテストする
setTimeout(() => {
assert.equal(1, 1);
// テストの終了を宣言する
done();
}, 1000);
});
それっぽいテストを書いてみる
サンプルとして小さなスクリプトと、そのテストを書いてみます。
HTTP で json を取ってきて出力する小さなスクリプトです。
ダミーのデータは {JSON} Placeholder を使用します。
// 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 件出力します。
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 件であること、期待するデータ型であること、先頭のデータが期待する値であること、をテストしてみます。
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 と .toEqual を使い分けることでオブジェクトの等価性を Same value で検査するか否かを分離できます。Jasmine もしかりです。ところが Test Runner のアサーションには toEqual に該当するものがなさそうです。
また .toBeGreaterThan などちょっと便利に使えるアサーションもなく、拡張性がいまひとつでした。
型ファイルの assert.d.ts
を見たところ @deprecated
としてマークされているものが多く、アサーションについては変更や拡張の入る可能性が高そうです。
困ったこと:console.log したい
個人的に発展途上のコードをテストしながら完成させていくスタイルが多く、まだ完成していない処理を確認するために console.log
はめちゃくちゃ使っています。
それが Test Runner ではレポートに出力され..ません....!!
test('fetchUsers', async () => {
const users = await fetchUsers();
console.log(users); // <=== 見たい! 🙄
正攻法ではなさそうですが console.log
できないのが痛すぎるので、わざとテストを失敗させてレポートに出力する方法をとりました。
test('fetchUsers', async () => {
const users = await fetchUsers();
console.log(users);
...
throw new Error(); // <=== わざと失敗させる
});
Test Runner は test
関数の中で例外がスローされるか、Reject な Promise が返されるか、done に例外が渡されることでテストを失敗とみなします。
// 例外をスローする
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
の両方ともが出力されます。
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 で良きテストライフを!😉