321 件のアサーション、テストフレームワークなし:純粋なロジックを import ゼロに保つ

このサイトの自検は node scripts/selftest.ts を直接実行するだけ。フレームワークも依存もありません。代償は検証対象のモジュールが相対 import を 1 つも持てないこと、そしてその代償はコードの形を買いました。

自検はこうやって走ります。

npm run test
# = node scripts/selftest.ts

Vitest も Jest も、テスト用の依存もありません。321 件のアサーションを直接実行します。これが成立する理由と、その代償は同じものです。検証対象のモジュールは相対 import を 1 つも持てない。

なぜ import ゼロが必須なのか

Node は TypeScript を直接実行できますが、やっているのは型の除去であってモジュール解決ではありません。

import { parse } from './parse';     // ✗ ERR_MODULE_NOT_FOUND
import { parse } from './parse.ts';  // ✓ 拡張子が必要

テストファイル自身は毎回 .ts を書けば済みます。しかし検証される側のモジュール同士が import し合うと、そこにも拡張子が必要になり、しかもそれらは Astro / Vite にもバンドルされるので、.ts 拡張子は別の厄介ごとを連れてきます。

そこでルールは一番覚えやすい形に潰れました。

games/*/logic/*tools/*/logic/*lib/i18n.tslib/detect.ts —— これらのモジュールは import ゼロ

得られるもの

① 保守不要のテスト基盤。 フレームワークが無ければ、バージョン更新も設定もプラグイン互換もありません。ゲームを足すとは logic/ を足して selftest.ts に数個のアサーションを書くことです。

② アサーションはただのコード。 describe / it の入れ子は無く、小さな関数だけ。

function eq(name: string, actual: unknown, expected: unknown) {
  if (Object.is(actual, expected)) { passed++; return; }
  failed++;
  console.log('FAIL: ' + name + '\n  期待 ' + expected + '\n  実際 ' + actual);
}

出力は passed: 321, failed: 0 の 1 行。失敗時は期待値と実際値を出します。

③ ビルドの手前に置ける速さ。 全体が 1 秒未満で終わるので、CI の結果を待つものではなく、ビルド前のゲートにできます。

代償は意図的な重複

import ゼロは共通モジュールを切り出せないという意味です。実例:3 行の bytesToHex はハッシュツールとコーデックツールに 1 つずつあります。lib/bytes.ts に切り出すと 2 つの logic/ が import を持ち、自検が即死します。

重複は結合に優先する —— ここでは標語ではなく、ツールチェーンが強制する選択です。

3 行の関数が 2 つあるのは受け入れます。本当に警戒すべきはルールの重複、たとえば「どの文字列を言語タグと見なすか」です。これはコピペでは済みません。コンパイル時のガードが要ります。

// lib/lang.ts
// コンパイル時ガード:detect.ts が判定できる言語は i18n.ts の Locale と完全に一致する
// 必要がある。どちらかがずれたらこの 2 行がコンパイルエラーになる(双方向)。
export const DETECTABLE_IS_LOCALE: readonly Locale[] = DETECTABLE;
export const LOCALE_IS_DETECTABLE: DetectLocale[] = [...LOCALES];

detect.ts は import ゼロなので判定可能な言語の一覧を自分で宣言し、i18n.ts も import ゼロなので Locale を自分で宣言します。互いを代入させておけば、どちらが増減しても双方向でコンパイルが通りません。重複と一貫性を同時に成立させる、私が知る唯一の方法です。

本当の見返りはコードの形

一番価値があるのはテストではなく、このルールが層を強制することです。

  • 自検は素の Node で走る:DOM も canvas も localStorage も無い。
  • したがってそれらに触るコードは logic/沈められないApp.vue に留まる。
  • 結果として、各ゲーム/ツールは必然的に「純粋なロジック + 薄い殻」になります。

この分層はもともと意図していたものですが、「べき」はたいてい「一緒に書いたほうが早い」に負けます。依存ゼロの自検があると、これはツールが止める制約になります。canvas のロジックを logic/ に書けば、次の実行で爆発します。

測れないもの

境界は正直に書きます。

測れる 測れない
ゲームのルール、スコア、衝突判定 canvas が実際に描くもの
ツールの純粋関数すべて クリップボード、ドラッグ&ドロップ、ファイル選択
言語判定、タイムゾーンのフォールバック テーマ切替、リップル、スクロール連動のハイライト
waka テキストの解析、時間の換算 レイアウト、レスポンシブ、あらゆる視覚表現

DOM に触る半分は自動化しません。代わりに薄くします。ロジックは logic/ の中で徹底的に検証し、殻はイベントを繋いで結果を描くだけにします。残る面積は、一度クリックして確かめられる程度になります。

測れないものへの最善の対処は、測る価値のあるものを残さないことです。

321 件のおおよその内訳は、5 つのゲームのルール、10 個のツールの関数、言語と初回訪問の判定、GitHub プロフィールのコーディング活動ブロックの解析です。これらが 1 回の node 呼び出しで終わるのは、依存しているものが「無い」からです。

← 記事一覧に戻る

コメント