はじめに
網羅的な UT/結合テストの仕様を厚く書いても、「一覧の金額と PDF の金額が一致する」「桁・単位が正しい」「i18n の変数が置換され切っている」といった成果物をまたいだ「表示の意味」の不変条件は、誰もオラクル(検証基準)を書かないまま人的ミスで見落とされます。潤沢な予算があっても漏れますし、小規模・個人開発ではそもそもその網羅仕様を作れません。この記事は、その隙間を安く塞ぐ話です。
自己ホスト型の書類保管 OSS NeNe Vault の README にスクリーンショットを載せようと、本番構成のデモ画面を撮って回っていたら、画面の欠陥が 2 件出ました。ひとつは実ユーザーの画面に出る i18n バグ(#137)、もうひとつはデモ用シーダーの金額バグ(#136。本番の業務ロジックではなくデモ用シードの不具合で、性質が違います)。そのときバックエンドのテストは 308 本すべて green。とはいえ、バックエンドの単体/結合テストが「一覧画面の金額と PDF 本文の金額が一致するか」のような成果物をまたいだ表示の意味を検証しないのは、設計上むしろ当然です。308 という数字は「裏切り」ではなく、単にそのオラクルがどこにも無かったというだけの話です。
この記事の本題は「営業スクショQAすごい」ではありません。スクショQAは安く一次で欠陥を刈る探索層(scout)にすぎず、耐久的に価値があるのは「どこにオラクルを置くか」の判断です。実際この一件で、僕は自分で「正しい」と名指ししたオラクルを、いったん気休めの帯アサーションで代用していました。その帯がなぜ穴だらけで、真の不変条件へどう昇格させたか——実コードと、実際に走らせた検証で書きます。
この記事で分かること:
- 金額の回帰テストを「¥33,000〜¥3,300,000 の帯」で済ませてしまうと、少額の ×100 がなぜすり抜けるか(実測つき)
- メタ(一覧)と実体(PDF)が別経路で生成される継ぎ目に、非循環な「一覧==PDF」等値オラクルを置く方法(実コード)
- 単体/i18n lint/視覚回帰/等値pin/スクショ+AI を、コストと決定性で並べた多層防御のどこに何が座るか
撮っていたら出た 2 件
1. 一覧の金額が PDF と 100 倍ズレていた(#136)
一覧のスクショと生成 PDF を並べたら、金額が合いません。一覧は ¥149,300,000、PDF 本文は ¥1,493,000。ちょうど 100 倍です。原因は、デモシーダーが DB へ金額を渡すときに 100 を掛けていたこと(amountCents: $total * 100)でした。円に補助単位は無いので amount_cents には「円そのもの」を入れる規約です。
ここで効いたのは、メタデータ(一覧の金額)と実体(PDF の総額)が、別々のシンクへ独立に流れていたことです(DemoDataSeeder)。
// src/Demo/DemoDataSeeder.php — 2 つのシンクは別経路
$pdf = DemoInvoicePdf::build(/* ... */ totalYen: $total); // → PDF 本文へ印字
// ...
$this->upload->execute(new UploadDocumentInput(
// ...
amountCents: $total, // → DB(一覧が読む)。バグ時はここだけ $total * 100 だった
));
totalYen(PDF)と amountCents(DB)は別の道です。片方だけ間違えると、並べた瞬間に人間の目には一発。テストは一覧の値も PDF の値も「存在する」ことは見ていましたが、両者が一致するかを検証するアサーションは、どこにもありませんでした。
2. アップロード画面に生の {{max_size_mb}} が出ていた(#137)
アップロードダイアログのヒント欄がこうでした。
PDF・JPEG・PNG(最大 {{max_size_mb}} MB)
i18n の補間変数が置換されずそのまま出ています。翻訳ファイルには変数付き文字列があるのに、呼び出し側が引数を渡していませんでした。
// 修正前 → 修正後(権威ある上限=バックエンド env をフロント config にミラー)
- hint={t('document.upload.file_hint')}
+ hint={t('document.upload.file_hint', { max_size_mb: env.uploadMaxFileSizeMb })}
念のため両ロケールの {{…}} を含む全キーを t() の呼び出しと突き合わせたところ、引数漏れは file_hint の 1 件だけ。ほか(件数表示・削除確認など)はきちんと引数が渡っていました。この種の欠陥は本来 i18n の lint で機械的に落とせるもので、それは後述の多層防御の話につながります。ただ正直に言えば、今回この #137 に実際に打ったのは「描画に {{ を含まない」というコンポーネントの pin までで、i18n lint(i18next-parser 等)の CI 導入はまだです。最善手を名指ししながら、そこは次の一手として残しています。
なぜ緑をすり抜けたのか — オラクルの外側
2 件とも、コードは「動いて」います。金額は保存され表示され、ヒント文字列も DOM に載る。壊れていたのは「人間が読んだときの意味」だけでした。
| バグ | 機械の視点 | 人間の視点 |
|---|---|---|
| 金額 ×100(#136) | 数値が保存され表示される ✅ | PDF と一覧が 100 倍ズレている ❌ |
生の {{max_size_mb}}(#137) |
文字列がレンダリングされる ✅ | 「最大 {{max_size_mb}} MB」は意味不明 ❌ |
要するに、我々の統合テストに「一覧==PDF」「変数が置換され切ったか」を見るオラクルが無かった。テストは「値が存在するか」は確かめますが、「その値が正しく見えるか」「桁が生活実感と合うか」までは、アサーションを書いていない限り見ません。
正直に言うと、これらの検証はバグを見つけた後で足したものです。撮影より前には存在しませんでした。裏を返せば、撮影という探索で「どこを見るべきか」を当て、その検証点を機械の回帰へ昇格させた——探索的に見つけて回帰で固定する、という普通の順序です。問題は、その昇格の一手目を、僕はしくじりかけたことです。
記事の心臓:弱いオラクルに逃げた話
金額の再混入を止めるテストとして、僕が最初に書いたのはこれでした。シードした金額が現実的な帯に収まるか、を見るだけの 2 行です。
// tests/Demo/DemoSeedLifecycleTest.php — 最初に書いた「弱いオラクル」
$amounts = /* SELECT MIN(amount_cents) AS lo, MAX(amount_cents) AS hi ... */;
self::assertGreaterThanOrEqual(33_000, (int) $amounts['lo']);
self::assertLessThanOrEqual(3_300_000, (int) $amounts['hi']);
一見それらしい。実際、全行がまとめて ×100 されれば最大額が上限を突き破って落ちます。でもこれは**「桁がだいたい正気か」を見るヒューリスティック**であって、僕が本文で「正しい」と名指しした「一覧==PDF」のオラクルではありません。しかも二重に穴があります。
-
上限ちょうどをすり抜ける:シードの最小行は ¥33,000。これが ×100 されると ¥3,300,000 で、上限にちょうど乗って
<=を通過します。 -
集計だから局所ズレが見えない:
MIN/MAXしか見ないので、帯の内側に収まる 1 行だけの ×100(例:¥20,000 相当が別経路で ¥2,000,000 に化ける)は、集計値を動かさず完全に不可視です。
机上の話ではありません。デモシードの 1 書類だけ、DB 側の金額を ×100(¥33,000 → ¥3,300,000。帯の内側)にして、PDF はそのまま ¥33,000 を印字させて回帰テストを実行してみました。結果はこうです。
(帯アサーション)……… 通過(3,300,000 は上限 3,300,000 以下)
(一覧==PDF 等値)…… Failed asserting that 33000 is identical to 3300000
帯は素通り、等値だけが捕まえた。 帯は「正しいオラクルを書けたのに、たまたま値が上限を超えることに依存する近似で妥協した」典型でした。
そこで、真の不変条件——各書類の amount_cents が、その書類の PDF に印字された総額と一致する——を pin し直しました。肝は非循環であること。同じ $total 変数を二度読んで比べるのは芝居なので、実際に保存された PDF のバイトから印字済みの総額を抽出して、DB の amount_cents と突き合わせます。PDF は totalYen 経由・DB は amountCents 経由と実際に別シンクなので、これで #136 を掴めます(DemoInvoicePdf はライブラリ非依存の素の PDF なので、総額は圧縮されず正規表現で取れます)。
// tests/Demo/DemoSeedLifecycleTest.php — 真の不変条件(list == PDF)
$rows = $this->query()->fetchAll(
'SELECT d.amount_cents AS amount, v.file_path AS path
FROM vault_documents d
JOIN document_versions v ON v.vault_document_id = d.id
WHERE d.organization_id = ?',
[$org->id],
);
foreach ($rows as $row) {
$pdf = (string) file_get_contents($this->storageRoot() . '/' . (string) $row['path']);
if (preg_match('/TOTAL\s+JPY\s+([\d,]+)/', $pdf, $m) !== 1) {
self::fail('demo PDF is missing its printed TOTAL');
}
self::assertSame(
(int) $row['amount'],
(int) str_replace(',', '', $m[1]),
'amount_cents must equal the total printed in the document PDF (list == PDF)',
);
}
これで ×100 は桁の大小によらず該当行で落ちます。帯は「最初に逃げた弱いオラクル」として残しつつ、限界をコメントに明記して降格しました(元コメントの「would blow past the upper bound on every row(全行で上限突破)」も、上で見たとおり誇張なので直しました)。修正は nene-vault の PR #187 です。
本番への一般化について正直に補足します。この正規表現抽出が効くのは DemoInvoicePdf が非圧縮の素 PDF だからで、本番の圧縮ストリーム PDF には直接は効きません。実プロダクトなら、テキストレイヤを取り出すか、そもそも PDF を組む手前——totalYen を渡す生成境界——で amount_cents と突き合わせるのが本筋です。ここでデモの実バイトから抽出したのは、「別シンクを跨ぐ」非循環性を、いちばん愚直な形で見せたかったからです。
一般化:継ぎ目に等値pinを置く
この一件の持ち帰りは「スクショを撮ろう」ではなく、オラクルの選び方です。
- 継ぎ目を探す:メタ(一覧の金額)と実体(PDF の総額)のように、同じ意味の値が別経路で生成される場所は、片方だけ壊れて食い違う定番の事故現場です。そこに cross-artifact の等値pin(control) を置く。
-
帯 ≠ 真のオラクル:「もっともらしさの帯」は安いスモークで、あれば無いよりまし。ですが真の不変条件(
==)と混同しないこと。帯で満足すると、帯の内側の乖離は永遠に見えません。 - 検出と予防は別レイヤー:等値pin は検出。根本の予防は「一方のシンクだけ変換しない」=金額の single source of truth 化です。両方要ります。
多層防御のどこに座るか
「潤沢な仕様でも漏れる/中小はそもそも作れない」なら、安い層を何枚か重ねるしかありません。大事なのは「多層にしよう」という概念ではなく、どの欠陥がどの層の隙間に落ちるかを、コストと決定性で並べることです。
| 層 | 主に捕る欠陥 | コスト | 決定性 | 役割 |
|---|---|---|---|---|
| 単体/結合(バックエンド論理) | ロジック・境界値 | 中 | 決定的 | control。ただし成果物をまたいだ表示意味は守備範囲外 |
| i18n lint(未使用/欠落補間の検出。i18next-parser 等) |
{{}} 引数漏れ(#137 の正解) |
低 | 決定的 | control。#137 はここが最善手 |
| スナップショット/視覚回帰(Playwright・Chromatic 等) | レイアウト崩れ・意図しない差分 | 中〜高 | 決定的 | control。ただし後述の盲点あり |
| cross-artifact 等値pin(本記事の list==PDF) | メタ⇔実体の食い違い(#136) | 低 | 決定的 | control/gate |
| スクショ+人/AI の探索 | 上のどれも張っていない「見て変」全般 | ほぼ0 | 非決定 | scout(発見器) |
視覚回帰について 1 点だけ強調します。今回の ×100 は初回のスクショから既に間違っていました。つまり「正しいゴールデン画像」が存在しないので、ピクセル差分の視覚回帰は原理的に沈黙します(差分の基準そのものが誤っている)。この「ベースライン自体が意味的に誤っている」欠陥を、安く捕れる標準層は実は多くありません。スクショQA(+後述の AI)の固有の価値は、ゼロ設定・別作業のついで・別目線・ゴールデン不要の意味検査という、この隙間にちょうど効くところにあります。
AI に一次スイープ(scout)をやらせる — 仮定ではなく、実際に起きたこと
白状すると、この ×100 に最初に気づいたのは私(人間)ではありません。README 用のスクショを撮って一覧と PDF を突き合わせていた AI アシスタントが、「一覧は ¥149,300,000、PDF は ¥1,493,000 で桁が合わない」と指摘してきたのが発端でした。つまり「AI にスクショの一次スイープをやらせる」は仮定の話ではなく、現にこの欠陥を見つけた経路です。
だとすれば、再現性も持たせられます。毎リリース、主要画面のスクショと対応する生成物(PDF など)を VLM に渡して「食い違いは無いか」を尋ねる——これは自動化できる反復可能な scout です。人間が「たまたま撮って気づく」より、はるかに安定して回せます。
ただし、何を渡すかで拾えるものが変わることと、決定性の限界は、実際に食わせて確かめました。
- 一覧を 1 枚だけ渡すと、モデルは各行の金額(¥303,000〜¥2,974,000)を正確に読みますが、どれが 100 倍ズレているかは指摘できません。1 枚には「正しい金額はいくらか」の基準が無いからです。桁の正誤は、単独の画像からは人にも AI にも分かりません。
-
一覧と PDF を並べて渡すと、
¥149,300,000と¥1,493,000の桁の不一致は見えます(発端がこれ)。ただし——この「2 つを突き合わせる」検査こそ、我々がコードで pin したlist==PDFオラクルそのものです。値が機械抽出できるなら、非決定なモデルにやらせるより決定的な pin に昇格させるほうが安く確実で、だから ×100 の最終的な守りは AI ではなくコードの pin にしました。 - 生の
{{max_size_mb}}のような「明らかに壊れた文字列」は、参照すら要らず 1 枚でも「変だ」と映ります。基準画像の要らない意味の異常検知で、ここは AI の scout が素直に効きます。
線引きはこうです。AI-scout は反復可能な一次スイープとして本物に効く——特に「まだ pin を書いていない未知の"変"」を毎リリース自動で拾える。だが VLM は非決定的で、同じ入力でも今日は通り明日は落ちうるし、誤検知もハルシネーションもする。だから決定的な回帰ゲート(control)にはならない。 AI が拾った候補は、人間の探索と同じく決定的な pin(list==PDF・{{ 混入不可)へ昇格させて初めて守りになります。値が機械抽出できる検査を確率的モデルのゲートに載せるのは、コスト的にも信頼性的にも損です。
まとめ:scout と control を分ける
| 役割 | 何をする | 誰が | 決定性 |
|---|---|---|---|
| scout(探索・発見) | 本番構成で全画面を撮り「客に見せて変でないか」を通しで見る。人でも VLM でも | 人/AI | 非決定 |
| control(決定的ゲート) | 当てた検証点を回帰へ昇格。#136→list==PDF 等値pin、#137→i18n lint/{{ 非包含pin |
CI | 決定的 |
- 緑のテストと壊れた画面は両立する。 308 green は「バックエンドがオラクルを張っていない層」の話。裏切りではなく、オラクルの不在。
-
帯で妥協しない。 「もっともらしさの帯」は真の不変条件ではない。継ぎ目(メタ⇔実体)には
==の等値pin を置く。 - scout は control を代替しない。 スクショも AI も探索の一次チェック。拾ったら決定的 pin へ昇格させて初めて守りになる。
網羅仕様を積める体制ならそれが理想です。積めない規模でも、継ぎ目に等値pin を 1 本置き、リリース前に全画面を一度撮る——これだけなら今日から、ほぼ無料で回せます。
一次資料
- NeNe Vault(OSS): 金額 ×100(#136)/未置換 i18n 変数(#137)/両者をまとめた PR #138
- 回帰テストの昇格(帯→等値オラクル): PR #187 /
tests/Demo/DemoSeedLifecycleTest.php(list == PDFの非循環 cross-artifact 等値、および降格した帯)/DocumentUploadModal.test.tsx(描画に{{を含まない pin) - 別シンクの実体:
src/Demo/DemoDataSeeder.php(amountCents:とtotalYen:)/src/Demo/DemoInvoicePdf.php(TOTAL JPY …の印字)
関連記事
- 品質・QA の連作:本記事 → curl では全緑、実ブラウザで 6 機能が 401 停止(公開準備中)→ 敵対的打鍵で本番を殴る話(執筆中)
- 金額 ×100 の設計側:円に補助単位がない前提で
amount_centsに何を入れるか(公開準備中)
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp