node:test で jsdoc `@example` に記述したコードを使ってテストする
はじめに
私は vitest を使ってテストコードを実装することが多い。vitest は vite と統合されていることで変換処理に vite と同じプラグインが使えるため、開発時に vite を使っていれば追加で必要な設定が少ないという利点があるのだが、私は In-Source Testing という機能が気に入っていることが大きな理由だ。
これは他の言語でもよくある、実装コードと同じファイル内にテストが記述できるというものだ。
これにはいくつか利点があるが、その中でも、テストを実装の近くに置くことでテストが実装の説明するという役割を強めるという点がある。
このテストが実装の説明する点に注目したときにドキュメント内にテストを書くというアプローチもある。これはドキュメンテーションテストと呼ばれることがあり、いくつかの言語では実装されて用いられている。
vitest においてはプラグインの実装があり、それを用いることで実現できる。
vite plugin として実装されており、ドキュメントの内容を前述の In-Source Testing の形式に変換するというものだ。
先に述べた通り、私は vitest はよく使っているし、一般的にもよく使われているようだが、UI ライブラリなどに関係のない ロジックのライブラリを実装する場合などは vite plugin が不要なことも多く必ずしも vitest を選択する必要がない。
このようなときは node に含まれる node:test で十分なことが多い。
TypeScript を使う場合も最近実装された型情報を取り除く機能や tsx を使えば十分だ。
今回は node:test でドキュメンテーションテストが実現できるコードを書くことで、個人的に node:test を使えるシーンを増やしていきたいと思う。
実装例は以下
ゴールの定義
ドキュメントを書く際は JSDoc の example タグにテストコードを書く。
テストコードは node:assert の assert
を使ってアサーションを記述する。
例えば以下のような感じだ。
/**
* Adds two numbers.
*
* @example
* ```ts:[test] 正の数の足し算
* assert.strictEqual(add(2, 3), 5);
* ```
*
* @example
* ```ts:[test]
* assert.strictEqual(add(10, -5), 5);
* assert.strictEqual(add(10, -3), 7);
* ```
*/
export function add(a: number, b: number): number {
return a + b;
}
ここで、ドキュメントブロックには [test]
というキーワードを含める。
これはテストしなくても良いコード例もテストしようとすることを回避するためだ。
あとは、このコードブロックの内容を取得し、もともとのソースの末尾にテストコードを追加すればいい。
今回の場合は以下のようなコードにしたい。
// :
// 元のコード
// :
import { describe, test } from "node:test"
import assert from "node:assert"
describe("add", ()=> {
test("正の数の足し算", async ()=> {
assert.strictEqual(add(2, 3), 5);
})
test("テストタイトルなし", async ()=> {
assert.strictEqual(add(10, -5), 5);
assert.strictEqual(add(10, -3), 7);
})
})
このような変換が行われる仕組みがあれば、あとはこのコメントを含む実装コードファイルを指定してテストランナーを動かせば良い。
node --test --import tsx example.ts
実装
何にせよコードの変換が必要になるが、 node:module
から公開されている register
を使って、モジュールのロード時の挙動を操作することができる。
ここで、入力ファイルを解析し、前述のような変換を行って返せば目的を達することができる。
例えば以下のようなファイルを用意し
import { register } from 'node:module';
register('./transform.js', import.meta.url);
以下のようにして実行をすると transform.js
に記述されたロジックに基づいてファイルが処理される。
node --test --import ./loader.js --import tsx example.ts
このとき、 --import
オプションは文字通り指定したファイルを import してからプログラムを実行するというオプションだ。
上記の例だと ./loader.js
を読み込み、その後 tsx
のメインファイルを読み込んだ後でプログラムを実行する。
ファイルを読み込んだ際に副作用として register
による登録が行われるが、登録された処理の順番は登録順によってきまる。
つまり、上記の例だと transform.js
の処理が行われた後に、tsx
の処理が行われる。 --import
の順所が逆なら処理順も逆になる。
register
で登録したモジュールには、処理を行いたいタイミングに応じて関数を実装し公開する。今回は、プログラムを読み込んだタイミングで処理を行いたいので load
を実装して公開すれば良い。
これは以下のような関数だ。
/** @type {import('node:module').LoadHook} */
export async function load(url, context, nextLoad) {
const { source, format } = await nextLoad(url, context)
return { source, format }
}
この実装だと何も行わないが、望む変換を行い値を返却すれば良い。
後はプログラムをどうやって変換するかという話になるが、これは好きな方法でやればよいので深く言及しない。今回は使い慣れている ts-morph を使った。
具体的なコードは以下だ。
注意するべき点として load はモジュールがロードされるたびに呼び出されるが、テストコードを追加したいのは、最初に読み込まれたプロジェクト内のモジュールであるという点だ。
例えば、node:test などは処理する必要がないし、node_module 以下のモジュールも無視するべきだろう。
プロジェクト内のモジュールについても、a.ts
をテストする際に a.ts
が依存している b.ts
にもテストコードを足してしまうと、 a.ts
に対するテスト実行時に b.ts
に対するテストも実行されてしまう。
node --test
コマンドでの対象の指定の仕方にもよるが、a.ts
のテストと同様に b.ts
に対するテストも実行されるだろうから、この場合だと b.ts
に対するテストが二回行われてしまう。
このようなことが発生しないように必要なファイルだけを変換する必要がある。
実行例
js を対象にするなら以下だ。
$ node --test --import ./src/loader.js "./src/*.js"
✔ src/loader.js (357.134829ms)
▶ toTestTitle
✔ 空文字ならデフォルトタイトル (0.692923ms)
✔ 空白文字のみならデフォルトタイトル (0.131656ms)
✔ 文字列があればタイトルにする (0.182772ms)
▶ toTestTitle (1.943809ms)
ℹ tests 4
ℹ suites 1
ℹ pass 4
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1073.075558
ts に対してテストするなら tsx を用いる。
tsx での変換時にコメントが消えてしまうため、テストコードが追加されてから tsx の変換が行われるように --import
の順序に注意する
$ node --test --import ./src/loader.js --import tsx "./src/*.ts"
▶ add
✔ 正の数の足し算 (0.796768ms)
✔ タイトルなし (0.134021ms)
▶ add (1.823283ms)
▶ mul
✔ 正の数の掛け算 (0.683015ms)
▶ mul (1.557848ms)
ℹ tests 3
ℹ suites 2
ℹ pass 3
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 1057.953312
終わりに
プログラムを実行するときにファイルの変換が必要な場合はそれなりに大げさな準備が必要だと認識していた事もあったが、それほど大変ではないことがわかった。
モジュールの変換も ts-morph をはじめ使いやすいものがいくつかあるのでそれほど苦労せずに実現できるだろう。
おそらく、同じような仕組みで In-Source Testing も node:test で実現できるだろうと思う (工夫が必要そうな点はあるが)。
Discussion