本記事は Zenn にも同内容を公開している: https://zenn.dev/flip451/articles/sotohe-test-obligation-gate
「機能 XX を実装して、そのテストも追加して」と LLM に頼んだとしよう。返ってくるのは、膨大な量のソースコードの差分と、膨大な数の自動テストかもしれない。テストはすべて green だ。さて、これをどう評価し、どう信頼すればよいだろうか。
この問いを、少し引いた場所から考えたい。自然言語で書かれた「仕様」を、実際に動くプログラムへと変換する変換器のことを考えよう。LLM とコーディングエージェントは、まさにこの変換器である。このような変換器の出力をどうすれば「信頼」できるだろうか。考えられる方針を列挙してみる。
- 変換器そのものを信頼し、出力をただ信じる。理想形ではあるが、変換器が確率的でバグの可能性を持つ限り、机上の空論に近い
- 出力されたソースコードを、すべて人力でレビューする。責任の所在は明快だが、レビューの時間はソースコードの量におおよそ比例し、人間の認知能力がボトルネックになる
- ソースコードをレビューする判定器を導入し、その結果を信頼する。この判定器には、単にバグを検出させるだけでなく、「仕様に従っているか」「仕様を網羅した実装か」「仕様に無い振る舞いが混ざっていないか」まで保証させる工夫が要る
- プログラムのテストを構築し、テストの通過をもって信頼する。ただし問題の重点は「そのテストをどう信頼するか」へ移る。ソースコードの全文をすべてレビューするのに比べればいくらかマシだが、テスト一つひとつの正しさの確認は易しい一方で、テストの集合の網羅性の確認は、抜け漏れが無いことを証明する必要があるという意味で悪魔の証明であり、相応のコストがかかる
- 信頼する前に運用へ出し、不具合を見つけては直すことを繰り返して、運用の中で信頼を構築する
方針は他にもあるだろうが、ここでは目標を 1 に定め、これを実現する方法を考える。本稿が掘り下げるのは、その実現のための部品としての 4、すなわち「テストによる信頼」である。
これは SoTOHE 紹介シリーズの第 5 回である。
第 1 回で SoT Chain(「ADR ← 仕様書 ← 型契約 ← 実装」を一方向の参照で結ぶチェーン)の全体像を、第 2 回で track ワークフローを、第 3 回で型契約書 (TDDD) を、第 4 回で LLM 判定ゲートの規律を扱った1。
シリーズ全体の地図は記事末尾に置いた。
信頼は変換器の設計で作り込む
1 の理想(変換器への絶対的な信頼)に近づくためにできることは何だろうか。その方法の一つは、この変換器そのものを設計し、信頼性を作り込むことだ。ハーネスエンジニアリングやループエンジニアリングと呼ばれる営みの重要な側面の一つは、ここにあると筆者は考えている。そして先に挙げた 2〜5 は、その設計の部品になる。これらの考え方を組み合わせて、ハーネスやループを構築するのだ。
これら 2〜5 の部品の要点は次のとおりだ。
-
2 の人力のレビューだけに頼る運用はスケールしない。
だから、人力に頼る領域を可能な限り絞る設計が必要だ。たとえば、プログラム作成の起点となる設計判断そのものや、実装中に事後的に必要になった設計判断の承認のみ人力に頼るという設計が可能だ。 -
3 の判定器の導入は、愚直に「バグがないか、仕様に従い、かつ網羅しているか、仕様に無い振る舞いを勝手に足していないかをレビューせよ」などとプロンプトを打てば済むほど単純ではない。LLM のコンテキストは有限で、プログラムの規模が大きくなるほどレビューを担当するエージェントのコンテキストは圧迫され、「知能」が下がり、精度が下がる。
だから、問題を適度な大きさに分割し、判定器を一つひとつの問題に集中させて精度を上げる対処が必要になる2。 -
5 の運用を通じた信頼の確立は 1 の理想の実現を諦め気味だといえるが現実的だ。
たとえばある汎用ハーネスの開発をそのハーネス自身で行えば、運用の中で見つかった不具合や気づきがハーネスの改善へ直に還る。運用が自己改善のループになる。(もちろん過学習を避けるために、多様なターゲットに適用する試験は必要だが。)運用を介して改善を施したハーネスは 1 の理想形に近づく。 -
4 のテストを通じた信頼の構築は、信頼の依存先がテストに移動するだけで本質的には 2 や 3 と同じ問題を抱える。それゆえ、テストの信頼にも 3 と同じ分割の技法が要る。
ただし、テストへの信頼は一度確立すれば、テストランナーが自動で検証を繰り返してくれる。つまり、一度構築した信頼を損なうことなく何度でも再利用することができる。
本稿では 4 に着目して、「テストをどう信頼するか」という問題に対処する方法の一つを提案する。
すなわち「構造化された仕様書」と「機能の実現に必要となる型の宣言一覧」から「テストの義務」を導出し、その義務が果たされていることを検証する仕組みだ。考え方自体は言語やテストフレームワークを選ばない。筆者の作成しているハーネス(SoTOHE)では、これをテスト義務ゲートとして実装している。
カバレッジは「どれだけ実行されたか」しか語らない
テストの品質を評価するための指標のひとつとして、テストカバレッジが挙げられる。だがカバレッジが答えているのは「コードの何割がテストで実行されたか」であって、「書かれるべきテストが存在し、約束された振る舞いを検証しているか」ではない。この二つは似て非なるものだ。
たとえば、あるユースケースの受け入れ基準が「在庫が足りれば注文を確定し、足りなければ在庫不足エラーを返す」だったとする。実装者が正常系(在庫が足りるケース)のテストだけを書くと、行カバレッジ上はそのユースケースが実行済みとして計上される。だが「在庫不足エラーを返す」という約束は一本も検証されていない。カバレッジの % はこの欠落を教えてくれない。
人間の実装者ならレビューで気づくかもしれない。だが実装者が LLM の場合、happy path のテスト一本で「基準を満たした」と申告する経路が容易に生まれる。冒頭の場面で返ってきた膨大なテスト群の中にも、こうした欠落は紛れ込みうる。検査対象が黙って検査から漏れて green になる——サイレントパス (silent pass) と呼ぶ——という失敗モードが、テストの世界でも起きるということだ。
問題を一般化するとこうなる。テストの「量」は数えられているが、テストの「義務」——何が検証されるべきかの一覧——がどこにも存在しない。義務の一覧が無ければ、「書かれるべきなのに書かれていないテスト」は定義すらできず、検出のしようがない。だから最初の仕事は、義務の一覧を機械が導出できる形で手に入れることである。両者の違いを先取りしてまとめると、次のようになる。
| 行カバレッジ | テスト義務ゲート(本稿の仕組み) | |
|---|---|---|
| 数えるもの | テストが実行したコードの行数 | 約束ごとに、履行するテストが存在し内容が正しいか |
| 見逃すもの | 書かれるべきなのに存在しないテスト | 仕様への引用がそもそも張られていないケース(後述) |
三段構え:義務の導出 ⇒ 存在検証 ⇒ 履行検証
テスト義務ゲートは、次の三段で構成される。
- 義務の導出:設計の宣言から「どのテストが存在すべきか」を機械的に列挙する
- 存在検証:義務に対応するテストが結び付けられているかを、決定論の検査で確かめる
- 履行検証:書かれたテストが約束を本当に検証しているかを、LLM の判定で確かめる
本節では、一つの例を最後まで持ち歩きながら、この三段を順に見ていく。題材は、金額を表す値オブジェクト Money としよう。前提として、筆者の開発様式では、実装より先に「どんな型を、どんな役割 (role) と制約で作るか」を機械可読な型契約書として宣言する3。その型契約書のうえで、Money は ValueObject という role を持ち、二つの不変条件 (invariant) を宣言している。「金額は非負」と「通貨コードは 3 文字」だ。そしてこの Money の型宣言は、仕様書の二つの受け入れ基準を引用している。A1「負の金額はコンストラクタで拒否される」と A2「不正な通貨コードは拒否される」だ。ここまでの前提を図にすると、こうなる。
第一段:義務の導出 型契約書の宣言から「どのテストが存在すべきか」を決定論的に列挙する。ValueObject で invariant が二つ宣言されているので、「invariant ごとに境界値テスト」というルールが適用される。このルールの出どころは、role ごとに「どんな宣言から、どんな種類のテスト義務を立てるか」を定めた対応表である。本稿ではこれを「決定表」と呼ぶ4。この決定表に従うと、義務は 2 件立つ。invariant「非負」の境界テスト義務と、invariant「3 文字」の境界テスト義務だ。ここで導出されるのは「最低限これだけは書け」という下限の一覧であって、上限ではない。実装者が追加でテストを書くのは自由だ。
第二段:存在検証 導出された 2 件の義務に対し、それを検証するテストが結び付けられているかを検査する(結び付けの記録方法は後述する)。義務に結び付いたテストが無ければ検出し、逆に、もう存在しない義務を指したままの結び付けが残っていればそれも検出する。これは LLM を使わない決定論の検査で、CI ゲートになる。
第三段:履行検証 書かれたテストが、引用先の受け入れ基準が約束する振る舞いを実際に検証しているかを、LLM の判定でゲートする。invariant「非負」のテストが本当に負の金額の拒否を検証しているか、を内容レベルで見る。この判定結果は入力(仕様やテストの本文)の hash に凍結され、どちらかが変われば失効する。
三段を一文でまとめると、義務の導出が「何を果たすべきか」を、存在検証が「試みがあるか」を、履行検証が「正しく果たしたか」を、それぞれ受け持つ。
注意点として、テストコードの本文までは自動生成しない。テストの内容は、仕様書に書かれた約束の意味を読み解き、それをテストコードへ翻訳する形で書かれる必要があり、機械的な自動生成には向かない。だから機械が担うのは「対象 + 引用先の約束 + テストの形」を短くまとめた指示書 (brief) を渡すところまでで、本文の執筆は実装を担う書き手(人 / AI)に委ねる。
二軸モデル:宣言が「件数と種類」、引用先が「意味」を決める
三段構えの土台にあるのが「二軸モデル」だ。Money の例で見ていく。
型契約書で宣言された各型は、二種類の情報を持つ。一つは role とその内訳(ValueObject と invariant 2 つ)、もう一つは、その型が引用する受け入れ基準(A1, A2)である。この二つはテスト義務ゲートにおいて異なる役割をもつ。
-
宣言の軸(role とその内訳)は、義務の件数と種類を決める。
Moneyは invariant が 2 つなので、境界テスト義務が 2 件立つ。ある型に立つ義務の件数は宣言の内部構造だけで定まる - 意味の軸(引用先の本文)は、各義務が何を検証すべきかを決める。「負の金額を拒否する」という A1 の本文が、テストが確かめるべき中身を規定する
義務の件数は、宣言の軸だけで決まる。引用の本数は件数に影響しない。invariant「非負」は Money という型の宣言に属する性質であって、引用先が増えても分裂したりはしない。だからその境界テスト義務も、受け入れ基準を何本引用していようと 1 件のままだ。
では、ある型が仕様書内の複数の約束を引用する場合、その多様性はどこで受け止めるのか。1 つの義務に複数のテストを結び付けることで対応する。すなわち、1 義務 = 1 テストではなく、1 義務 = 1 テスト群で、1 つの義務には複数のテストを束ねてよい。引用先が増えたときに厚くなるのは義務の件数ではなくテスト群の内訳で、束ねられたテストたちが複数の約束を分担して検証する。
分かりやすいのはユースケースの例だ。role が UseCase の型に立つ「結果テスト」義務は 1 件だが、そこに成功系の受け入れ基準を検証するテストとエラー系の受け入れ基準を検証するテストの 2 本を束ねてよい。義務は 1 件、テストは 2 本。カバレッジの節の在庫の例で言えば、「注文確定」と「在庫不足エラー」の両方が、同じ 1 つの結果義務のテスト群に含まれる。
edge:引用 1 本ごとの独立判定
では「エラー系の受け入れ基準を検証するテストが一本も書かれていない」ケースはどう捕まえるのか。ここで edge という単位が効いてくる。edge とは、型契約書で宣言された型と、それが引用する仕様の約束とを結ぶ 1 本の線のことだ(グラフの辺を思い浮かべてほしい)。Money なら (Money, A1) と (Money, A2) の 2 本の edge がある。義務の件数は引用ごとに増えないが、履行の判定はこの edge ごとに独立に走る。だから、あるテスト群が引用先の約束のうち一部を全く検証していない状態は、その引用先の edge の fail として検出される。義務の件数を増やさなくても、黙って消える経路は生じない。
この「edge ごとの独立判定」が、二軸モデルが破綻しないための要だ。
義務ゼロもサイレントパスさせない
二軸モデルの帰結として、義務が 0 件になる型が出てくる。単なるデータ運搬体は、検証すべき実行時の振る舞いを持たないので義務 0 件だ。Money でも、もし invariant を一つも宣言していなければ(型システムが不正状態を表現不能にしているなら)実行時の境界が存在せず、義務は 0 件になる。
ここで素朴な実装は「義務 0 件なら黙って何もしない」とするだろう。それを禁じるのが、このゲートのもう一つの柱だ。決定表は義務の存在は導出できるが、不存在は証明できない。「導出結果が 0 件」は「テスト不要」を意味しない。決定表の漏れかもしれないし、型契約書の宣言漏れかもしれない。それを黙って green にするのは、まさにサイレントパスである。
そこで、すべての edge が必ず次のいずれかで明示的に解決されることを要求する(この「一つ残らず解決せよ」という要求を網羅性と呼ぶ)。
- (a) テストで解決する。義務に対応するテストを書いて結び付け、履行検証(第三段の内容検証)に合格する
- (b) 免除で解決する。テスト不要なら、その理由を散文で書き、LLM の検証に合格する
黙って消える経路は、この図のどこにも存在しない。方針 4 で悪魔の証明と呼んだ「抜け漏れが無いことの確認」は、こうして全 edge の有限な突合へと置き換えられる。
(b) の分業には特徴がある。免除は「LLM が裁定する」のではなく、実装の書き手が理由を書き、LLM がその理由を検証する。書かれる理由は、例えば「この型は外部 API へのデータ運搬専用で、内部に分岐も不変条件も持たないため、境界テストの適用対象外」といった短い散文である。免除の検証も、履行検証と同じ「主張と証拠」の構図で行う。「テストは不要である」という免除理由が主張、その edge を構成する記述——型の宣言と引用先の本文——が、主張を突き合わせる相手、すなわち証拠になる。判定が問うのは、主張が証拠の記述と矛盾しないかである。そして合格には「理由のどの主張が edge のどの記述に裏付けられるか」の引用を必須とする。
例えば、Money が invariant を一つも宣言しておらず、導出される義務が 0 件だったとしよう。ところが引用先の受け入れ基準 A1 は、「負の金額を拒否する」という境界の振る舞いを約束したままだ。この食い違いのもとで実装者が「テスト不要」という免除理由を書いても、引用先が振る舞いを約束している以上、免除の検証はこの理由を却下する。そして「宣言の不足」(invariant を宣言すべきなのにしていない)として、型契約の書き手への差し戻しに routing される。義務 0 件が、上流の欠陥を炙り出す仕掛けにもなっている。
失敗の三類型:とくに「中心部の未検証」
履行検証が fail を返すとき、それは三つの類型のどれかだ。Money の invariant「非負」に対応する受け入れ基準 A1「負の金額はコンストラクタで拒否される」を例に説明する。
- (a) 矛盾:引用先の約束と逆のことを assert している。例:「負の金額でもコンストラクタが成功する」ことを検証してしまっているテスト
-
(b) すり替え:受け入れ基準を引用しながら無関係な内容を検証している。例:A1 を引用しつつ、実際には
Moneyの JSON シリアライズ形式だけをテストしている - (c) 中心部の未検証:矛盾も無関係もないが、引用先が約束する中心的な振る舞いが検証されないまま残っている
三つ目の (c) が、このゲートの独自性を担う。カバレッジの節で見た在庫の例に戻る。在庫の受け入れ基準が「在庫が足りれば確定、足りなければエラー」という二分岐を約束しているのに、実装者が正常系(在庫が足りる)のテスト一本しか書かなかった、というケース。これは矛盾でも無関係でもない。書かれたテストは確かに約束の一部を正しく検証している。だが約束の中心である「エラーを返す」分岐が手つかずだ。
(a)(b) だけを見ていると、この「正常系テスト一本で約束を検証したことにする」抜け道が塞げない。だから (c) を独立の類型として持つ。これこそが「カバレッジ % では検出できない穴」の正体であり、テスト義務ゲートが存在する理由そのものだ。
過剰要求にならないよう、(c) が見るのは引用先の本文のうち「条件 ⇒ 観測可能な結果」として読める記述、つまり振る舞いの主張だけだ。理由の説明や背景の記述にまでテストは要求されない。この判定には、合格への引用義務、入力 hash への凍結、既知の誤り例による検出精度の検査、安価なモデルから人間への段階エスカレーション、fail-closed な適用範囲、という規律をかけて CI ゲートに耐えさせる5。
結び付けの記録と、乖離の検出
テストと義務の結び付けは、ソースコード内の目印コメントではなく、独立した成果物として記録する。記録は、機械が導出するものと書き手が書くものの 2 つに分かれる。
- 義務一覧(ツールが導出する):義務ごとに、対象の型、引用先、義務種別、実装者への指示書、宣言と引用先それぞれの hash を持つ
- 結び付け表(書き手が書く):どの義務をどのテストで解決したかの写像。「義務 → テスト群」「edge → 免除理由」の形態を持つ
ソースに目印を埋めないのは、正本を一つにするためだ。結び付けの情報がソースと成果物に分散すると、どちらが正しいのか割れてしまう。成果物の側に閉じておけば、テストコードは素のままで、記録と実態のずれは「成果物とコードの突合」として機械的に検出できる。
こうして記録された結び付けは、開発が進むうちに実態とずれていく。テストが消える、義務の導出元が変わる、仕様やテストの中身が書き換わる。このずれを本稿では乖離と呼ぶ。筆者の実装が検出する乖離は 6 種類あり、2 族に分かれる。
-
存在系(決定論検査):
missing(義務に対応するテストが無い。テストが改名されて結び付けが切れた場合もここで出る)/orphaned(結び付けの指す先の義務がもう導出されない。型や invariant の改名・削除で生じる残骸で、結び付け表の追随を要求する) -
鮮度系(判定結果の失効):証拠側の
spec_changed(引用先の仕様本文の変化)/decl_changed(型宣言の変化)、主張側のtest_changed(テスト本体の変化)/reason_changed(免除理由の変化)
鮮度系の 4 種は、それぞれが独立した検査というわけではない。実体はどれも「hash に凍結された判定結果が失効した」という同じ一つの事象で、4 種の名前は「何が変わって失効したのか」の内訳を表しているに過ぎない。仕様やテストが変われば hash が変わり、既存の判定結果は無効になる。回復は hash の機械的な再計算ではなく、再評価の合格のみである。
運用面は三つの操作から構成される。義務を導出する操作、その履行を検査する操作、意味論を評価する操作である。導出と検査は決定論的で、CI とコミットのゲートに載る。検査は存在と網羅性に加え、各 edge に現行の hash と一致する LLM の判定結果が在り、それが合格しているかまで確かめる。LLM を呼ぶのは履行と免除の意味論評価だけだ(テストは静的に読むのであって、実行はしない)。
迂回するためのフラグは用意しない。解決不能な義務や不整合はエラーとして報告されて非零の exit code で止まり、治療はゲートの緩和ではなく成果物(型契約書 / 仕様 / 結び付け表 / テスト)側の修正で行う。
正直なコスト
最後に、このゲートが背負うコストを隠さず書く。
第一に、LLM 判定のコストが増える。hash 凍結キャッシュが効けば変更が無いときのコストは 0 になるが、初回と変更時のコストは現実に発生する。
第二に、決定表そのものが新しい保守対象になる。表の漏れは、妥当なはずの免除理由が通らないといった形で顕在化し、決定表の編集で解決されるが、顕在化までのラグはある。
第三に、指示書の質が実装者のテスト執筆品質を左右する。前提として、型契約書から仕様への引用は、型全体に張ることも、その型の個々のメソッドに張ることもできる。メソッド単位まで張られていれば、指示書は「Money::new が負の金額を拒否することを検証せよ」まで具体化できる。型単位でしか張られていなければ「Money が A1 の約束を検証するテストを書け」までしか言えず、どのメソッドを叩くかの特定は実装者に委ねられる。指示書の粒度は引用の粒度の写しであり、機械はそれ以上に細かくはなれない。
最後の一つはコストというより、責任の分界点だ。本ゲートが保証するのは「張られたすべての edge の解決の確認」と「どこからも引用されない受け入れ基準や制約の検出」までだ。型宣言が関連する仕様の約束へそもそも edge を伸ばしていない状態(引用の欠落)は視界の外にある。そこは型契約書の執筆品質と、そのレビューの責務だ。
これらのコストを払ってもなおこのゲートを入れる理由は、冒頭の問いに戻る。LLM が返してきた膨大なテストを、人力のレビューだけで信頼するのは持続しない。だから義務を機械導出し、その存在を決定論で、内容を LLM の判定で、二段階でゲートする。書き手(人 / AI)の自己申告を信頼せずに済むこの構造が、方針 4 の但し書き——「そのテストをどう信頼するか」——への本稿の答えである。そして、一度信頼を確立したテストは、以後は決定論のランナーが何度でも検証を繰り返してくれる。変換器への絶対的な信頼という 1 の理想へ、テストの側から一歩近づくのだ。
SoT Chain の中での位置づけ
最後に、このシリーズが取り扱っている SoTOHE における位置づけについてもふれておこう。SoT Chain の最後のリンク「実装 → 型契約書」は、第 3 回の双方向突合が構造的な一致を担っている。だが「実装が設計意図どおりに振る舞うか」は、コードの構造を眺めても分からない。振る舞いを観測するには、実行可能な検証物——テスト——を媒介にする必要がある。ただし、保証を生むのはテストが実行できることそれ自体ではなく、テストの意味論的な側面——約束された振る舞いを正しく検証しているか——のほうだ。そこを確かめるのが、本稿で見てきたテスト義務ゲートである。このゲートは「実装 → 型契約書」リンクの意味論検証を担う最後のピースであり、これで 4 階層のチェーンは全リンクが構造と意味論の二層で守られたことになる。
仕組みの紹介は、これで一巡した。次回は「では、この一式を自分のプロジェクトへどう持ち込むか」、つまりテンプレートの export と使い始めの手順を扱う。
シリーズの地図
本稿は 📍 の位置、チェーンの最後のリンクの意味論を検査するゲートの回である。
シリーズ一覧
- AI エージェントに「仕様どおり」を保証させる — SoT Chain という設計
- ADR から PR まで自走する track ワークフローとマルチエージェント分業
- 型契約書を SSoT にする — TDDD(型定義駆動開発)
- LLM による意味論検証を支える規律 〜参照整合性への応用例を添えて〜
- LLM が書いたテストを信頼する方法 — テスト義務ゲート(本記事)
- SoTOHE を使い始める — テンプレート export と新規プロジェクト実走記録(公開予定)
- SoTOHE を支える設計原則(公開予定)
リポジトリ: https://github.com/Flip451/SoTOHE-core