kaedeasset記事 · Notes霧走町 流氷に眠る

測った結果を台帳に書いた瞬間、その測定が「未再テスト」になった

The moment a result was written into the ledger, that measurement became "not retested"

 D28-3-T55-fragile-test-over-invalidation・技術・日本語入力でプレイする仕組み

失敗の名前Fragile TestContext Sensitivityover-invalidationover-approximated dependenciesminimality(Build Systems à la Carte)claude自然言語処理

測定が古くなったら「未再テスト」と出す見張りを作りました。
見張る範囲を広く取りすぎて、測定の記録そのものまで見張っていました。
台帳に結果を書くと、書いた行を含めて全部が古くなりました。
今日、同じ形がもう一度見つかりました。

この回の位置と、前後の回#

区切り: T50〜T57 失敗の名前(8 回のうち 6 回目)
先に読むとよい回: T47(合格の証拠を測った版に縛る)・T52(古い焼き物が回帰を隠す)
次に読む回: T56(作り込みすぎと終わりの基準)

名前 — Fragile Test#

正式名
Fragile Test(壊れやすいテスト)
初出
Gerard Meszaros, "xUnit Test Patterns: Refactoring Test Code", Addison-Wesley, 2007(test smell の目録の behavior smells)
下位の原因の名前
Context Sensitivity・Data Sensitivity・Interface Sensitivity・Behavior Sensitivity
ビルドの側の名前
over-invalidation(過剰な無効化)・over-approximated dependencies(依存の取りすぎ)
関連の論文
Sebastian Erdweg, Moritz Lichter, Manuel Weiel, "A Sound and Optimal Incremental Build System with Dynamic Dependencies", OOPSLA 2015
定義の側
minimality(Mokhov, Mitchell, Peyton Jones, "Build Systems à la Carte: Theory and Practice")

Fragile Test とは#

Fragile Test(壊れやすいテスト)とは、本物の欠陥を入れていない変更で落ちるテストです。
Meszaros は原因をいくつかに分けています。

  • Interface Sensitivity: テストが使っている被検査物のインタフェースが変わると落ちる(画面越しに業務の論理を試すと起きやすい)
  • Behavior Sensitivity: 確かめたい所とは別の振る舞いが変わると落ちる
  • Data Sensitivity: テストが頼っているデータが変わると落ちる
  • Context Sensitivity: 被検査物の外の状態(日付、ほかの部品、環境)が変わると落ちる

落ちるたびに調べて「本物ではなかった」が続くと、人は落ちても見なくなります。
Fragile Test の害は、落ちること自体より、本物の失敗まで見なくなることです。
対処は「その検査を消す」ではありません。
消すと、見逃しに変わるだけです。
何に依存しているかを絞ります。

ビルドシステムには、同じ現象の名前があります。
依存を実際より広く取ると、関係ない変更でも作り直しが走る。
Erdweg らは、既存のビルドシステムが依存を over-approximate しがちで、それが不要な再ビルドを起こすことを指摘し、細かく依存を追う pluto を作りました。
Build Systems à la Carte の言葉で言えば、minimality(依存が変わった作業だけを走らせる)が破れた状態です。
T52 の stale artifact が correctness の破れ(作り直すべきものを作り直さない)なら、こちらはその反対側です。

依存を広く取るのは安全側に見えます。
けれど広すぎる依存は、狭すぎる依存と同じくらい検査を無力にします。
狭すぎれば見逃し、広すぎれば無視される。

うちの見張り: NOT-RUN と bake の鮮度#

うちには「測った結果を、測った版に縛る」仕組みがあります(仕組みの説明は T47)。
テスト対象のどれかのファイルが、測った後に変わったら、その結果は現在の版のものではない。
そのときは PASS でも FAIL でもなく NOT-RUN と出します。
古い緑を持ち回らないための見張りです。

もう 1 つは T52 の bake の鮮度です。
作品フォルダの .json と .jsonl のうち、いちばん新しい更新時刻が、焼いたベクトルより新しければ「古い」と出します。

どちらも「何が変わったら古いとみなすか」の範囲を持ちます。
その範囲が、今回の主役です。

うちで起きたこと(2026-08-18) — Fragile Test#

前日の 2026-08-17、作品に属するものを作品フォルダ(Game/adv/)へ集める整理をしました。
スクリプトの置き場に散っていた台本、作品専用の検査、合格の台帳を、全部 Game/adv/ の中へ。
「作品のものは作品のフォルダに」という規約としては正しい整理です。

ところが失効の見張りは、Game/adv を丸ごと見ていました。
翌朝の commit(f92decdf・2026-08-18 09:08)の本文が、何が入ったかを並べています。

Game/adv/runs/ 台本=検査の入力
Game/adv/checks/ 作品固有の検査スクリプト
Game/adv/acceptance.json 台帳そのもの(08/17 まで Scripts/ にあり監視の外だった)
Game/adv/golden/holdout-usage.jsonl ゲートの参照記録
Game/adv/probes-archive/ 使い切って退避したプローブ

実害はこうでした。

  • 検査の台本を直しただけで、測定が失効する(ゲームの応答は 1 文字も変わっていない)
  • 台帳に測定結果を書くと、その行自身を含めて全部が失効する
  • bake の鮮度にも同じ型があり、台帳を更新すると「bake が古い」と言われる

見張りのスクリプトの注記には「文書やスクリプトの変更では失効させない」と書いてありました。
宣言と実装がずれていたのです。

直し方は、git の pathspec の :(exclude) で、被検査物だけに絞ることでした。
同じ起点で、失効が 30 件から 28 件になりました(応答を変えない 2 つの commit が正しく外れた)。

この commit には、もう 1 つの話が入っています。
最初、この現象を名前の対照表の「名前が見つかっていないもの」に登録しました。
1 回の検索に語を詰め込み(test artifacts inside system under test と self-invalidating と provenance staleness と anti-pattern)、出てこないので「無い」と結論していたのです。
分野を変えて 2 回引き直したら、ビルドの側で over-invalidation、テストの側で Fragile Test が出ました。
対照表の §8 には、いまも規則が残っています。
「1 クエリで出なかったことを『名前が無い』と書かない。少なくとも、どの分野の言葉かを変えて 2〜3 回引く」。

同じ型の再発#

除外を足すたびに、別の記録ファイルで同じことが起きました。
ソースの注記に日付が並んでいます(Tools/adv-sim/main.cpp の NewestWriteTime)。

2026-08-19(944bed2d)。
bake の鮮度をゲートの一番先に繋ごうとしたら、繋ぐ前から章 1 が「古い」になっていました。
原因は、判定の設定の記録(judge.json・playgate.json。前日に作品フォルダへ移ってきて、除外を足し忘れていた)と、攻略地図の生成物(ui/route-map-*.json。git の管理外なので失効の見張りには入らないのに、時刻を見る鮮度だけが数えていた)。
commit の本文は「そのまま繋ぐと、波を回して記録を書いた瞬間に赤になる」。

2026-09-23。
統合試行の台帳(test-attempts.json)を新しく作ったら、結果を書いた直後に bake が古いと言われた。

2026-10-05。
ブラウザ版の案内ページ(web/)を作品フォルダに置いたら、同じく鮮度に数えられた。

そして今日、2026-10-29。
この worktree で adv-sim metrics を流すと、鮮度は「古い」と出ました。
いちばん新しいファイルとして名指しされたのは Game/adv/trailer/voiced-01.json。
10/26〜28 に足した、予告編と声つきのプレイ動画を撮るための台本です。
焼いたベクトル(10/27 13:20)より新しいのは、trailer/ の 3 ファイルだけでした。
言い換えと台本の最後の変更は 10/10 で、bake は中身としては新しいままです。
テストの集計を回せば、回帰テストの bake の行が FAIL になるはずです。
鮮度の見張りが、映像を撮る台本の変更で鳴っています。
trailer/ は、失効の見張りの除外にもありません。
予告編の台本を直すと、探索的テストの結果も NOT-RUN に戻るはずです(ここはコードを読んだ推論で、実行していません)。

いまの仕組み — Fragile Test#

失効の見張りの範囲は、いまは作品ごとの設定ファイル(Game/adv/exploratory-test.json)の watch だけが持ちます。

"watch": [
"Game/adv",
"Tools/adv-sim",
"Plugins/KaedeAdv/Source/KaedeAdv",
":(exclude)Game/adv/runs",
":(exclude)Game/adv/checks",
":(exclude)Game/adv/probes-archive",
":(exclude)Game/adv/acceptance.json",
":(exclude)Game/adv/judge.json",
":(exclude)Game/adv/exploratory-test.json",
":(exclude)Game/adv/golden/holdout-usage.jsonl",
":(exclude)Game/adv/test-attempts.json"
]

隣の注記にはこうあります。
「被検査物(system under test)だけを見る。検査の入力(runs/)・検査の道具(checks/)・測定の記録を入れると、測定結果を書いた瞬間に自分自身が未再テストする」。

版の判定は、commit の番号ではなく、この範囲のファイルの中身から作るダイジェストです(Scripts/adv-test-evidence.ps1)。

function Get-AdvTestItemDigest([string]$Root, [string[]]$Watch) {
$lines = @(Invoke-AdvGit $Root (@('ls-files', '-s', '--') + $Watch))
...
$canon = ($lines | ForEach-Object { ($_ -replace '\s+', ' ').Trim() } | Sort-Object) -join "`n"
return (Get-AdvSha256 $canon).Substring(0, 16)
}

文書だけの commit では変わらず、テスト対象のどのファイルが変わっても変わります。
時刻ではなく中身のハッシュ(git の blob の番号)なので、こちらは checkout で時刻が動いても揺れません。

bake の鮮度の側は、C++ の除外の一覧を別に持っています。
注記には、なぜ写しのままにしたかが書いてあります。
「3 ファイル名なので二重に持つ。共有の設定ファイルに出すと、C++ が suite.json を読む配線が増えて、足し算のほうが高くつく」。

名前どおりでない所・まだ残っている弱さ — Fragile Test#

名前どおりの over-invalidation です。
依存(何が変わったら古いか)を広く取りすぎ、無関係な変更で無効になりました。
Fragile Test の分類でいえば、被検査物の外(記録ファイル、検査の台本)の変化で落ちる Context Sensitivity に近い形です。
ただ、うちの場合「落ちる」のは個々のテストではなく、結果の有効期限です。
テストそのものは同じ結果を出すのに、出した結果が無効と表示される。
xUnit の Fragile Test より、ビルドシステムの不要な再ビルドに近い現象です。

残っている弱さは、除外の一覧という作り方そのものです。

一つ目、一覧は「入れないもの」を名前で並べています。
作品フォルダに新しい種類のファイルを置くたびに、それが焼く入力か、被検査物か、どちらでもないかを誰かが決めて足す必要があります。
2026-08-18 から今日まで、5 回足し忘れました(記録ファイル、判定の設定、統合試行の台帳、案内ページ、予告編の台本)。
足し忘れは毎回、無関係な変更で鳴る形で見つかっています。
見逃しの側に倒れないのは救いですが、鳴りすぎる見張りは T53 の赤と同じ道をたどります。

二つ目、除外が 2 か所にあります。
失効の側(exploratory-test.json の watch)と、鮮度の側(main.cpp の NewestWriteTime)。
judge.json を足した日も、片方は 08/18、片方は 08/19 と注記されていて、別々に足されています。
一つの契約を一か所に置く、というプロジェクトの原則 6 から見ると、ここは意図して二重にしている所で、二重にしたぶんの足し忘れが起きています。

逆の作り方(「入れるもの」を並べる)もあります。
焼く入力は言い換えと検査の入力(golden と probe の jsonl)だ、と注記自身が書いています。
それなら、入力の一覧を正にして、それ以外を数えない方が、置き場所が増えても鳴りません。
今日のところは、その作り方には変えていません(この回は読むだけで書いています)。

検索語#

Fragile Test xUnit Test Patterns
test smell context sensitivity
over-approximated dependencies unnecessary rebuild
build systems minimality

予告編の台本を何度直しても、町の人の答えは 1 文字も変わりません。その答えを、『霧走町 流氷に眠る』の第一章でブラウザから聞いてみてください。プロフィールのリンクから。

#claude #自然言語処理