Node/Deno でソースコードにテストを書く
tl;dr
- ファイルをそれ単独で単体テストとして実行するボイラープレートを編み出した
 - そのヘルパとして mizchi/test という実装を作った
 
なぜソースコードにテストを書きたいか
Rust や Python の doctest ではソースコードにテストを書く方法があります。
ソースコードにテストを書けると、コードとテストの心理的な距離が近くなってテストが書きやすくなる、という肌感があります。(諸説あります)
実装とテストが混ざって汚れるのが嫌という意見も理解できますが、それはありつつ認めた上で、あとでリファクタする前提で最初の一歩をその実装に書けると嬉しい、という気持ちがあります。
現状の Node だととりあえず assert するだけという単純なテストを書くことは可能ですが、構造化する方法がないので、簡単なスクラッチの時ぐらいしか行われません。
// test.js
import assert from "assert";
function add(a, b) { return a + b; }
assert.equal(add(1, 2), 3);
他の言語での実装例
いくつか例をみてみましょう。
Rust の例
Unit testing - Rust By Example
// example.
#[allow(dead_code)]
fn bad_add(a: i32, b: i32) -> i32 {
    a - b
}
#[cfg(test)]
mod tests {
    // Note this useful idiom: importing names from outer (for mod tests) scope.
    use super::*;
    #[test]
    fn test_add() {
        assert_eq!(add(1, 2), 3);
    }
    #[test]
    fn test_bad_add() {
        // This assert would fire and test will fail.
        // Please note, that private functions can be tested too!
        assert_eq!(bad_add(1, 2), 3);
    }
}
#[cfg(test)] の下は cargo test のときだけ実行され、 release build のときは消えます。
Python の例
doctest --- 対話的な実行例をテストする — Python 3.9.4 ドキュメント
"""
This is the "example" module.
The example module supplies one function, factorial().  For example,
>>> factorial(5)
120
"""
def factorial(n):
    """Return the factorial of n, an exact integer >= 0.
    >>> [factorial(n) for n in range(6)]
    [1, 1, 2, 6, 24, 120]
    >>> factorial(30)
    265252859812191058636308480000000
    >>> factorial(-1)
    Traceback (most recent call last):
        ...
    ValueError: n must be >= 0
    Factorials of floats are OK, but the float must be an exact integer:
    >>> factorial(30.1)
    Traceback (most recent call last):
        ...
    ValueError: n must be exact integer
    >>> factorial(30.0)
    265252859812191058636308480000000
    It must also not be ridiculously large:
    >>> factorial(1e100)
    Traceback (most recent call last):
        ...
    OverflowError: n too large
    """
    import math
    if not n >= 0:
        raise ValueError("n must be >= 0")
    if math.floor(n) != n:
        raise ValueError("n must be exact integer")
    if n+1 == n:  # catch a value like 1e300
        raise OverflowError("n too large")
    result = 1
    factor = 2
    while factor <= n:
        result *= factor
        factor += 1
    return result
if __name__ == "__main__":
    import doctest
    doctest.testmod()
__name__ == "__main__" でエントリポイントの検知ができます。
要件を整理する
- エントリポイントによって個別にテストを実行できる
 - テストをランタイムに含まない方法を用意する
 
エントリポイント(main)を検知する
Node
const isMain = require.main === module;
require.main がエントリポイントで、それを自分と比較することで自身がエントリポイントかどうかわかります。
これを試す過程で色々試していたのですが、node -r esbuild-register は require.main が残っていたのですが、esbuild-node は vm で評価して渡す都合なのか require.main が維持されていませんでした。
Browser
ESM でない場合、 document.currentScript に自身を呼び出した script エレメントの参照が入っています。これと webpack 等のバンドラによってコード内に展開された __filename を比較するとなんかできそうな気がします。(試してない)
native ESM の場合、import.meta.url に自身のパスが入っています。
Deno
const isMain = import.meta.main;
そのままの import.meta.main に入っています。
テストコードを最適化時に落とす
現代フロントエンドで主流の TypeScript は、Deno 以外ではそのままで実行できないので、何らかの変換を行うことを前提にします。テストコードの削除をここに混ぜてしまいます。
前提知識として、 terser は絶対に通らないパスを解析してコードを削除します。
if (false) console.log('do not run');
これと、バンドラによる環境変数の指定を合わせます。
if (process.env.NODE_ENV === "test") {
  console.log("run only test");
}
これを vite の定数宣言を使ってビルドすると、すべてのコードが消えます。
import { defineConfig } from "vite";
export default defineConfig({
  define: {
    "process.env.NODE_ENV": JSON.stringify("production"),
  },
});
完成形
import { sub } from "./sub"; // sub は isMain が false になるのでテストが実行されない
console.log("runtime code!", sub); // ここはランタイムに残る
// test code
const isMain = require.main === module;
if (process.env.NODE_ENV === "test" && isMain) {
  const assert = require("assert");
  assert.ok(true);
}
export const sub = "subtext";
const isMain = require.main === module;
if (process.env.NODE_ENV === "test" && isMain) {
  const assert = require("assert");
  assert.strictEqual(sub, "subtext");
}
これらは esbuild の ts 変形と合わせて、 NODE_ENV=test node -r esbuild-register index.ts や NODE_ENV=test node -r esbuild-register sub.ts で実行できます。
テストコードがランタイムに残らないことを確認しましょう。vite でビルドします。
import { defineConfig } from "vite";
export default defineConfig({
  define: {
    "process.env.NODE_ENV": JSON.stringify("production"),
    "require.main === module": JSON.stringify(false),
  },
  build: {
    target: "es2019",
    lib: {
      entry: "main",
      formats: ["es"],
    },
  },
});
ビルドして確認
$ yarn vite build
yarn run v1.22.11
$ /Users/mizchi/gh/github.com/mizchi/zenn/_test/node_modules/.bin/vite build
vite v2.6.2 building for production...
✓ 2 modules transformed.
dist/test.es.js   0.06 KiB / gzip: 0.07 KiB
✨  Done in 0.57s.
# 確認する
$ cat dist/test.es.js
const sub = "subtext";
console.log("runtime code!", sub);
vite の定数置換と terser の DCE によって、 if (false) ... に折りたたまれたブロックの中が消えています。これは今回の件に関係なく、覚えておくと便利なパターンです。
require.main === module も置換しているのですが、これをやらなかった場合、コード中に module; という変数参照が残ってしまい、環境によっては未定義変数のランタイムエラーになります。表記ゆれに弱いですが、 prettier なら勝手にこうなるし、これでいいでしょう。
import した assert がトップレベルにあって消えないように見えますが、 rollup の treeshake で DCE されたコードでしか使われてないのが判明するので、依存解析フェーズで tree shake されて assert も消えます。
追記: 実はここは嘘で、 require("assert") が sideEffect 持ちの判定が rollup されてしまい、コードが残ってしまっていたので、inline の require に変更しました。
@mizchi/test
先行事例として、このコンセプトで使えるライブラリはすでにあります。
volument/baretest: An extremely fast and simple JavaScript test runner.
こんな感じ + 自分がほしい機能を詰め込んでスタンドアロンで使えるテストランナーを自作しました。
こんな感じで使います。
export function add(a: number, b: number) {
  return a + b;
}
/* === test start === */
import { test, run, is, err } from "@mizchi/test";
const isMain = require.main === module;
if (process.env.NODE_ENV === "test") {
  test("test1", () => {
    console.log("do not show this message on success");
    is(add(1, 1), 2);
    err(() => is(add(1, -1), 3));
  });
  run({ isMain });
}
実行。
$ NODE_ENV=test node -r esbuild-register simple.ts
=== PASS: ok
✨  Done in 0.58s.
@mizchi/test 自体の実装がこれになっています。
test/index.ts at main · mizchi/test
おわり
ファイル単位ごとのボイラープレートが多く、もうちょっと言語サポートが欲しいところですが、自分がやりたいことはこれで実現できたので良しとします。
Discussion