0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「帯アサーション」は金額×100を見逃す|一覧==PDF の等値オラクルへ昇格させた話

0
Last updated at Posted at 2026-07-14

はじめに

網羅的な 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 で、上限にちょうど乗って <= を通過します。
  • 集計だから局所ズレが見えないMINMAX しか見ないので、帯の内側に収まる 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 #187tests/Demo/DemoSeedLifecycleTest.phplist == PDF の非循環 cross-artifact 等値、および降格した帯)/ DocumentUploadModal.test.tsx(描画に {{ を含まない pin)
  • 別シンクの実体: src/Demo/DemoDataSeeder.phpamountCents:totalYen:)/ src/Demo/DemoInvoicePdf.phpTOTAL JPY … の印字)

関連記事

  • 品質・QA の連作:本記事 → curl では全緑、実ブラウザで 6 機能が 401 停止(公開準備中)→ 敵対的打鍵で本番を殴る話(執筆中)
  • 金額 ×100 の設計側:円に補助単位がない前提で amount_cents に何を入れるか(公開準備中)

── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?