第15章:一度も走らなかったテスト
15件、実機で初めて赤くなった
2026年7月25日、crates/awase-windowsのcargo test --libが、Windows実機の上で初めて実行されました。それまでこのテストは、Linux上でのクロスコンパイルチェック(--no-run、コンパイルが通るかどうかだけを確認し、実行はしない方式)しか通っていませんでした。GCP(Google Cloud Platform)上にSpot(低価格だが強制停止されうる)インスタンスを立て、GitHub ActionsのセルフホストランナーとしてWindows実機を用意したことで、この状況が初めて変わりました。
結果は15件の失敗でした。この初回実行は、mutants-windowsというジョブの一部として行われました。cargo mutants(コードの一部をわざと壊した「ミュータント」を大量に作り、テストがそれを検出できるかを測るツール)のbaselineフェーズ、つまりミュータントを何も入れない元のコードでテストを走らせる最初の段階が、実質的に初の実機テスト実行でした。それまで積み重ねてきた「クロスコンパイルは通る」「clippyの警告はゼロ」という確認が、実行時の正しさをほとんど保証していなかったことを、この数字がそのまま示していました。コンパイルが通ることと、実行して正しく振る舞うことは、別の性質です。この章はその区別が、具体的にどこでどうねじれていたかをたどります。
15件の失敗は一様ではありませんでした。原因を洗い出すと、性質の異なる複数のグループに分かれていました。
| 分類 | 件数 | 中身 |
|---|---|---|
| 実装の欠陥(真のバグ) | 1件 | decide_alt_impersonationのKeyUp処理(後述) |
| テストの時刻設計の不備 | 3件 | 実sleep+実時刻で「過去」を作ろうとし、tick解像度(約15.6ミリ秒)に対してマージンが無かった |
| テストの入力値が古い | 1件 | 300msの判定窓の外にある値を使い続けていた |
| グローバル状態を守るロックの不備 | 複数件 | 3ファイルが同名だが別インスタンスのMutexを定義しており、排他になっていなかった |
| 古い実装を前提にしたアサーション | 1件 | 別のバグ(BUG-40)で仕様が変わった後、アサーションだけ取り残されていた |
この時点では、どれが実装そのものの欠陥で、どれがテストコード側の不備なのかは、まだ切り分けられていませんでした。「実機固有の何かがおかしいのだろう」という漠然とした見立ては、原因を一つずつ確認していく過程で大きく書き換えられることになります。
時刻設計の不備と300msの判定窓の不備は、どちらも実装ではなくテストコード側の見落としであり、実機で初めて走ったことで、ようやく可視化された不備でした。
書かれていたのに、一度も走っていなかった
15件のうち、実装そのものの欠陥だったのは1件だけでした。hook::alt_impersonation_tests::keyup_uses_the_decision_recorded_at_keydownというテストが、実機で初めて失敗し、decide_alt_impersonationという関数の欠陥を明らかにしました。
Left Altを親指キーとしてなりすまし設定している間、この関数はKeyDownとKeyUpの両方で呼ばれます。原因は、「今回のキー変換に使う判定」と「以後も保持しておくべき状態」を、同じ1つの戻り値で兼用していたことでした。KeyUpの時点でも、この関数は直前の判定(was_impersonating)をそのまま持ち越していました。しかしKeyUpの瞬間には、物理キーはすでに離れています。以後保持する状態は、この時点で必ずfalseに戻すべきでした。
この欠陥には実害がありました。判定結果はALT_L_IMPERSONATINGというフラグに格納され、3箇所のコードがmodifiers.altを強制的にfalseへ補正するかどうかの判断に使っていました。フラグがKeyUp後もtrueのまま残ると、なりすまし設定の無いRight Altを押す、あるいはAlt+Tabを行うといった、本来は無関係な操作まで誤って補正の対象になり得ました。修正は戻り値を二つの意味に分け、KeyUpの場合は「以後保持する状態」を常にfalseへ戻すという、ごく小さな変更でした。修正後、GCP Spot self-hosted runner上での実cargo test --lib -p awase-windows実行でパスすることが確認されています。
ここで押さえておくべきなのは、この欠陥を検出したテスト自体は、新しく書かれたものではなかったという点です。docs/known-bugs.mdの記録には「既存のテストがそのまま回帰テストになる(新規追加ではなく、既存テストが正しく通るようになった)」とあります。つまりこのテストは、以前からcrates/awase-windowsのソースツリーに存在していました。書かれてはいたのに、実行できる実機環境が無かったために、一度も走ったことが無かったのです。書かれた規律と、実行された規律の間には、実行環境という不可欠な一段があります。この段差を図にすると、次のようになります。

コミットされていることも、コンパイルが通ることも、実機で実行されて結果を確認できることの代わりにはなりません。この章の主題は、まさにこの一段でした。
残る14件のうち、最も手間のかかった原因は、ロックの不備でした。TSF_OBSというプロセス全体のグローバル状態を保護するはずのMutexが、observer.rs・probe.rs・literal_detect_fsm.rsという3つのファイルで、それぞれ別々のstaticとして定義されていました。TEST_LOCKという同じ名前が2ファイルに、VETO_TEST_LOCKという別名がもう1ファイルに存在していましたが、名前が同じでも実体は別のMutexインスタンスです。cargo testのデフォルトの並列実行の下で、あるファイルのテストが、別ファイルのテストによるTSF_OBSの書き換えに巻き込まれていました。3つの定義を図にすると、次のようになります。

observer.rs・probe.rs・literal_detect_fsm.rsはそれぞれ独立にMutexをstatic定義しており、コンパイラはファイルをまたいだ名前の重複を検出してくれません。GJI(Google Japanese Input、Google 日本語入力)は、TSF(Text Services Framework、Windowsの入力方式を仲介する仕組み)を介した状態確認(probe)によって、いま入力を受け付けられる状態かどうかを判定しています。この確認に関するgji_last_write_msが意図せず0にリセットされ、本来StaleConfirm(古い確認情報)になるはずの判定が、CompositionConfirmed(確定済み)に化けていたのです。
修正は一度では終わりませんでした。observer.rsにTSF_OBS_TEST_LOCKを1つだけ定義し、3ファイルともこれを共有する形に統一した後、再実行すると新たな失敗が現れました。ロックを一切持たずTSF_OBSを直接操作していたprobe_fsm.rsのテスト3件が、この統一作業そのものから見落とされていたのです。統一によって他のテストが正しく排他されるようになった結果、逆にこの3件の無防備さが表に出てきました。literal_detect_fsm.rs側のsleep依存の不備も同様に、ロック統一後の再検証で初めて顕在化しています。残る1件、decide_plan_nc_fired_enables_literal_when_gji_activeは、以前のバグ(BUG-40)で仕様が変わった後も古い意図を前提にしたアサーションのまま取り残されていたテストで、名前とアサーションを更新することで解消しました。
最終的に、計4コミット・3回の実機再実行を経て、15件の失敗はすべて解消しました。docs/known-bugs.mdは、この経過から次の教訓を導いています。
クロスファイルでグローバル状態を共有するテスト群は、1箇所直すたびに実機で再実行し、マスクされていた別の失敗が露出しないか確認するまで「直った」と判断しないこと。
一度に全部を見通せなかったのは、注意不足だったからではありません。ある失敗が、別の失敗によって隠されていたのです。隠していた側を直すまで、隠されていた側は姿を現しませんでした。
この一連の経過は、修正作業をAIエージェントに任せる場面での指示術としても読み替えられます。エージェントは1箇所を直し、テストが通ったことを確認すると、「直りました」と報告してきます。この報告は、その1件に関しては事実ですが、隠れていた別の失敗が存在しないことまでは保証しません。ロック統一によって初めて排他されるようになったprobe_fsm.rsの3件は、それまで別の不備の陰に隠れており、統一という1回の修正ではまだ検知されていませんでした。確認すべきは指摘した1件が直ったかどうかではなく、グローバル状態を共有する範囲全体を、直すたびに実機で再実行したかどうかです。1件のPASSは、その1件が直った証拠にしかなりません。
文書と、警告と、型と
decide_alt_impersonationの欠陥そのものは、修正すれば終わりです。しかし、なぜこの欠陥に気づくまでにこれほど時間がかかったのかという問いには、修正だけでは答えられません。rust-nicolaのリポジトリには、.claude/rules/fix-requires-evidence.mdという規律が、この事件より前から存在していました。warmup・focus遷移・IMEのbelief・conv modeへ・キー選択という、繰り返しバグが再燃してきた領域に触れるfixコミットは、回帰テストの追加か、docs/known-bugs.mdへの症状・再現手順の記録のどちらか一方を、同じコミットか直後の追随コミットに含めることを求めています。
この規律の背景には、実機の組み合わせ依存が強い領域では「直したつもり」が別の環境で再発しやすい、という経験があります。テストは機械可読な再発防止、docs/known-bugs.mdは人間可読な再発防止であり、どちらか一方を必ず添えることで、次に同じ領域を触る担当者が過去の知見にすぐたどり着けるようにする狙いでした。
この規律を補強する仕組みとして、pre-pushフックに軽量なチェックが入っています。対象ファイルが変更されているのにテストにもknown-bugs.mdにも差分が無いpushに対し警告を出しますが、ブロックはしません。純粋なリファクタなど意図的に不要な変更は、そのまま押し通せます。BUG-41のテストが一度も走らなかった事件は、この仕組みの外側で起きました。テストはコミットに含まれ--no-runのチェックも通っており、欠けていたのは規律を破る変更を止める仕組みではなく、書かれたテストを実際に実行する環境そのものだったのです。
この事件と同じ時期、rust-nicolaにはもう一つ、規律をより強く機械へ委ねようとする提案——ADR-082という設計文書——が持ち上がっていました。まだ提案段階にある構想であることを断っておきます。ADR-082が扱うのはjournal.rsという既存の仕組みで、ホットキー操作で直近2048件のイベントをダンプし、一部の純粋関数についてはこのダンプを再生して回帰テストとして固定化する基盤(journalリプレイ)が、この事件より前から用意されていました。ADR-082は、この基盤の対象を広げ、非同期に届く確認情報がどの世代の要求への応答かを取り違えるという繰り返されてきた欠陥を、個別に直すのではなくEventOriginという一つの型に集約し、「出所・世代の規律」をコンパイラに強制させることを提案しています。
このADRは、提案にとどまらず一部を実際に検証しています。EventOriginの最小実装(既存コードへの配線はしない)と、過去のバグ(第10章のBUG-43、675ミリ秒の間に同じ補正コマンドが16回連続送信された不具合)を題材にしたjournalリプレイのフィクスチャがLinux上のユニットテストとして書かれ、16回の連続検知のうち実際に送信されるのは上限5回までで残り11回は諦めに回るという有界終端を検証し、通ることが確認されました。ただし、この検証には明記された限界があります。用いたフィクスチャは実機のjournalダンプの転記ではなく、BUG-43発生当時actuation(実際にOSへ作用を送る処理)の呼び出しをjournalに記録する仕組み自体が存在しなかったため、docs/known-bugs.mdの記述から時刻を手作業で近似復元したものです。この限界は、ADR自身が「限界(重要)」として明記しています。
続く追記(「Phase 0.5」)では、actuation呼び出しを記録する専用のデータ型ActuationRecordが追加され、EventOriginが実行時の状態の一部に配線されています(cargo test -p awase-windows --libは169件すべてgreen)。ただしこれもLinux上のユニットテストの範囲にとどまり、本番ドライバへの配線は「ADR-081 Phase 1dへの申し送り事項」として未着手のまま明記されています。
つまりADR-082が示しているのは、規律を機械へ委ねる方向が有望だというところまでです。Windows実機での本番配線・実機ソークという段階はまだ通っておらず、BUG-41のテストと同じく、この提案自体も実機という最後の関門をまだ通過していません。
この限界には、後日、もう一段深い実例が見つかっています。IME状態の軸分解を検討したADR-088は、修飾キー押下中に何が起こるかという設計(トラックB)を5ラウンドのpre-mortemにかけても収束させられず、実機での計測(トラックD)も、合成キー入力がAPIとしては成功を返すのに実際には届かないという原因不明の現象で中断しました。純粋関数として切り出せる規律は網羅テストで機械的に守れますが、「修飾キー押下中にOSが何をするか」は原理的に純粋関数へ切り出せません。実機をまだ通っていないという段階の違いではなく、実機を通さなければ検証しようがない領域が存在するという記録です。
この区別は、この章の設計原則(規律は機械が検証するまで規律にならない)をもう一歩具体化します。検証が済んでいない箇所を洗い出すときは、「まだ検証していないだけ」と「その場所は原理的にそこでしか検証できない」を分けて記録すべきです。前者はスケジュールの問題ですが、後者は実環境へのアクセスを先に確保しない限り、いつまで経っても消えない負債になります。
他分野への転用
チェックリストの隣に、機械的なインターロックを置く
紙の作業手順書に「この工程の後に必ずこの点検を行うこと」と書いてあっても、それだけでは遵守は保証されません。忙しい現場では読み飛ばされ、省略されます。製造業の現場は、この弱さに対して、ポカヨケ(poka-yoke、うっかりミスを構造的に防ぐ工夫)と呼ばれる対策を発展させてきました。手順を守らなければ次の工程へ物理的に進めない治具や、部品の向きを間違えたら組み込めない形状のはめ込み穴などです。規律は、それを守ってくださいという呼びかけから、守らなければ機械が止まる・進まないという構造そのものへ、置き場所を移されます。
同名だが別実体だったMutexの話も、この対比に重ねられます。作業手順書に「共通のロックを使うこと」と書いてあっても、複数の担当者がそれぞれ別の場所に「共通のロック」を作ってしまえば、手順書の文言は守られたように見えて、実際には守られていません。名前が一致していることと、指している実体が一致していることは、文書を読むだけでは区別できません。実体が一致しているかどうかを機械に確認させて初めて、この種のすれ違いは検出できます。
設計原則
原則: 規律は、それを検証する機械が実際に動くまでは、まだ規律になっていません。
適用条件: テスト・型・自動チェックのように、規律を機械的に強制する仕組みを導入する場面すべてに当てはまります。仕組みを書いた時点と、その仕組みが実際に想定した環境で実行された時点は、別の達成として扱う必要があります。
実装の形: テストはコンパイルが通ることではなく、対象とする実行環境で実際に走り、結果を確認できることをもって「効いている」と判断します。文書化された規律には、pre-pushのような弱い機械チェックを添え、可能ならば型による強制へ段階的に格上げします。
限界: この原則自体、規律を機械へ委ねる仕組みを一度作れば十分だ、とは主張しません。ADR-082のように、型による強制を提案し、その一部を検証しても、実機という最後の環境を通るまでは、その提案もまだ「書かれただけ」の段階にとどまります。