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.ts、lib/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 呼び出しで終わるのは、依存しているものが「無い」からです。

コメント
…