テストのtypoにテスト実行後に気付くのってあほくさいじゃないですか?
ES moduleのコードでは「関数名や変数名の誤記」をESLintなどで容易に静的に検出できます。なので、JSで書ける物は片っ端からなんでもES moduleにしたくなるのですが、いわゆる自動テストのコードでは、この性質がかえって邪魔になることがあります。
たとえばMochaのテストケースには、何の前触れもなくdescribe()
やit()
などの関数が登場します。これらが「未定義の関数呼び出し」としてエラーにならないようにするには、テストと実装でESLintのルールを切り替えて警告条件を緩和したり、テスティングフレームワークの関数やオブジェクトを警告の例外に明示したりといった対策が必要になります。
しかし、警告を甘くすればテストだけ静的な検証が甘くなりますし、警告の例外を指定するのもテストの作成やメンテナンスが煩雑になります。テストの書きやすさ・維持しやすさと静的な検証の完全性とを両立しにくいのは、JSで物を作る時に個人的にずっと気になっていた点でした。
Pure ES modulesなテスティングフレームワークつくった
というわけで、このような難点がないPure ES modulesなテスティングフレームワークを作ってみました。
Node.js v13以上の環境で、npm install tiny-esm-test-runner
でインストールできます。
元々は、特定プロジェクトでのCI用に簡易的なテストランナーを書いて使い捨てにしていたのですが、それを複数プロジェクトで使い回すうちに、さすがメンテナンスが面倒だと感じるようになってきたため、この度一念発起してきちんと整備したという次第です。
基本的な使い方
テストはES moduleのファイルとして記述します。以下に例を示します。
// テストしたい実装をインポート。
import { myMethod, myAsyncMethod } from './my-module.js';
// アサーションをインポート。
import { assert } from 'tiny-esm-test-runner';
const { is, isNot, ok, ng } = assert;
// 名前が`test`で始まる関数をエクスポートすると、
// テスト関数として自動認識されます。
export function testMyMethod() {
const expected = 'AAA';
const actual = myMethod('aaa');
is(expected, actual);
}
// 非同期処理のテストを書きたい時は、ふつうに
// async functionで書いて下さい。
export async function testMyAsyncMethod() {
const expected = 'AAA';
const actual = await myAsyncMethod('aaa');
is(expected, actual);
}
ファイルを保存したら、run-tiny-esm-test-runner
に引数としてそのファイルを指定して実行します。
$ run-tiny-esm-test-runner ./test-*.js
すべてのテストが成功すればステータスコードとして0
を、1つでも失敗したりエラーが発生したりしたら1
を返します。
自作のnpmモジュールのテストに使うときは、以下の要領で登録しておけばnpm test
でテストを実行できます。
{
"scripts": {
...
"test": "run-tiny-esm-test-runner test/test-*.js"
},
"devDependencies": {
...
"tiny-esm-test-runner": "^1.1.0"
}
}
アサーション
is()
、isNot()
、ok()
、ng()
の4つがあります。
-
is(期待値, 実測値, メッセージ)
期待値と実測値を===
で比較した結果がtrue
、またはJSON.stringify()
した結果を===
で比較した結果がtrue
であればアサーションに成功します。第3引数を渡しておくと、アサーション失敗時のメッセージに第3引数の内容が使われるようになります。 -
isNot(期待値, 実測値, メッセージ)
期待値と実測値を!==
で比較した結果がtrue
、またはJSON.stringify()
した結果を!==
で比較した結果がtrue
であればアサーションに成功します。is()
の逆バージョンです。第3引数を渡しておくと、アサーション失敗時のメッセージに第3引数の内容が使われるようになります。 -
ok(実測値, メッセージ)
実測値がJS的にtrue
と見なせる値であれば、アサーションに成功します。第2引数を渡しておくと、アサーション失敗時のメッセージに第2引数の内容が使われるようになります。 -
ng(実測値, メッセージ)
実測値がJS的にfalse
と見なせる値であれば、アサーションに成功します。ok()
の逆バージョンです。第2引数を渡しておくと、アサーション失敗時のメッセージに第2引数の内容が使われるようになります。
共通の初期化処理とか終了処理とか
setUp()
、tearDown()
の名前で関数をエクスポートしておくと、各テスト関数の実行前にsetUp()
が、テスト関数の実行後にtearDown()
が実行されます。
import { myLoadData } from './my-module.js';
...
let data;
export async function setUp() {
data = await myLoadData();
}
export function tearDown() {
data.unload();
}
また、shutDown()
の名前で関数をエクスポートしておくと、すべてのテスト関数の実行後にshutDown()
が終了処理として実行されます。
import { myCleanupData } from './my-module.js';
...
export async function shutDown() {
await myCleanupData();
}
引数を色々変えてテストする
データ駆動テストにも対応しています。テスト関数のオブジェクトのparameters
プロパティに配列かオブジェクトを設定すると、その内容を引数としてテスト関数を何度も実行するようになります。
// 配列で定義する場合、配列の要素が
// テスト関数の引数になります。
testSuccess.parameters = [
['AAA', 'aaa'],
['BBB', 'bbb']
];
export function testUpperCase([expected, data]) {
is(expected, data.toUppwerCase());
}
// オブジェクトで定義する場合、キーに対応する値が
// テスト関数の引数になります。
testSuccess.parameters = {
a: ['AAA', 'aaa'],
b: ['BBB', 'bbb']
};
export function testUpperCase([expected, data]) {
is(expected, data.toUppwerCase());
}
なお、この時の引数はsetUp()
とtearDown()
にも渡されます。
テストがfailしたとき
失敗したテストに対応する実装を手直ししながら何度もテストを実行する、ということをやりたくなる場面はあると思います。こういう時は、他のテストまで実行されると時間がかかりすぎるので、失敗したテストだけ実行したくなるものでしょう。
そのような場合、テスト関数のオブジェクトのrunnable
プロパティにtrue
を設定しておくと、runnable
がtrue
のテストだけが実行され、それ以外はスキップされるようになります。
// このテストはスキップされる
export function testSuccess() {
const expected = 'AAA';
const actual = 'aaa'.toUpperCase();
is(expected, actual);
}
// このテストは実行される
testFail.runnable = true;
export function testFail() {
const expected = 'AAA';
const actual = 'aaa'.toLowerCase();
is(expected, actual);
}
採用事例
実際に以下のプロジェクトで使ってます。
- https://github.com/piroor/tiny-esm-test-runner/tree/master/tests tiny-esm-test-runner自体の自己テスト
- https://github.com/piroor/webextensions-lib-dom-updater/tree/master/test DOM操作のテスト
- https://github.com/piroor/copy-selected-tabs-to-clipboard/tree/master/test 独自記法のパーサーのテスト
- https://github.com/piroor/xulmigemo/tree/master/webextensions/test 文字列操作のテスト
まとめ
自作のPure ES modulesなテスティングフレームワークのtiny-esm-test-runner
の使い方を簡単に紹介しました。
もうES modulesが使えるようになってずいぶん経ってるし、有名どころのテスティングフレームワークもいっぱいあるし、同じような事をやってる人が5000兆人くらいいるんだろうなあ……と思っていたのですが、Node.js界隈に詳しい方によると、今のところメジャーどころの一般向けのリリースではES modulesのテストにネイティブ対応している物はまだないそうで、大変意外に感じました。
Node.jsって、ES modulesとかの仕様がまとまるよりもはるかに昔から存在していて、JSの言語仕様が貧弱なのをみんなで頑張って運用でカバーしてきたからなのか、今Node.jsでスクラッチで何か書こうと思うと、gulpだのgruntだのbabelだのwebpackだのと分厚い技術スタックが一気にガガガって積み上がってく感じで、Node.jsに疎いFirefoxアドオン専門のJS書きの僕には目眩がしてしまいます。
そういうレガシーの極みみたいな分厚いスタックを今から改めて勉強するのツラいっす、もう時代は変わったんですよ、JSだけでimport
とかclass
とかasync
/await
とか使えるようになってんですよ、それベースでスッキリ書いてサクッと終わらせたいじゃないすか……というのが、tiny-esm-test-runner
を書いた一番大きな動機なのでした。
npmのページを見ると分かりますが、テストランナー自体の依存パッケージは0なので、Denoとかでももしかしたら使えるかもしれませんね。
……と書いていましたが、Denoでも動くようになりました(2020年6月4日追記)。以下のようにして使えます。
$ git clone https://github.com/piroor/tiny-esm-test-runner.git
$ deno install --allow-read --allow-write --allow-net tiny-esm-test-runner/bin/run-tiny-esm-test-runner.deno
$ tiny-esm-test-runner test-*.js
この場合、アサーションは以下のようにして読み込む必要があります。
import { is, ok, ng } from 'https://github.com/piroor/tiny-esm-test-runner/raw/master/lib/assert.js';
Deno用にテストを書くならDeno標準のテストモジュールを使うのが正解だと思いますが、テストを簡潔に書き散らかしたい人は、試してみて頂けると幸いです。