AIにテストは書かせていい。渡してはいけないのは模範解答のほうだ
まとめ
- テストは問題と答えと採点に分かれる。AIに任せていいのは問題と採点で、答え(Test Oracle)だけが人間の担当
- 答えまで生成させると期待値が実装の写しになり、緑は「実装が実装どおり動く」しか意味しなくなる
- 人間が書くのは観測点と不変条件と境界の期待挙動。自然言語で10行ほどで、テストコードは書かない
- 全実装に課すと破綻するので、深さはリスクで3段に切る
前に、自動改善ループが収束しないのは評価する側まで生成させているからという話を書いた。 ものさしを決定論のプログラムに寄せれば、判定の揺れは止まる。 そこは納得している。
引っかかっていたのはその先だった。 ものさしを固定しても、そのものさしが目的とズレていれば、間違った方向へ静かに収束する。 では、ものさしの中身は誰がどこまで書くのか。
緑が保証しているのは、答え合わせが済んだことだけ
参考記事は最初にこれを置いている。 Greenは品質そのものではなく、定義された評価基準を満たしたという結果に過ぎない、と。
def calculate_price(price, quantity)
price * quantity
endテストが price=100, quantity=2, expected=200 の1件だけなら、緑はその1件を満たした以上のことを言わない。 quantityが0のとき、負のとき、nilのとき、数値でないとき、最大値、桁溢れ、通貨の丸め。 どれも未検証のまま緑になる。
効いているのはテストの部品構成だと思う。 テストは3つに分かれる。
- 問題:どの入力で試すか
- 答え:出た結果が正しいと言える根拠
- 採点:比較して緑か赤かを出す
真ん中だけに名前が付いている。 これが Test Oracle と呼ばれるもので、日本語にすれば正解の出どころだ。 デルポイの神託所(oracle)が問いに正しい答えを告げる存在だったところから、テスト工学が借りた語である。 問題集の巻末解答にあたる。
RSpecで見ると、Oracleは1つのテストに複数ある。
it "retryしても二重配信されない" do
post_notification(idempotency_key: "k1")
post_notification(idempotency_key: "k1") # 問題
expect(response).to have_http_status(:ok) # Oracle 1
expect(Notification.where(key: "k1").count).to eq 1 # Oracle 2
expect(mailer.deliveries.size).to eq 1 # Oracle 3
end参考記事が「HTTP 200の確認では足りない、DB StateとQueue Stateと外部APIのRequestを見よ」と書いているのは、1しかない状態を指している。 retryで二重配信しないことを保証したいのに、見ているのがステータスコードだけなら、答え合わせは成立していない。
そして、なぜ送信が1回でなければならないのか(二重通知や二重課金の損害)は、このコードのどこにも書かれていない。 それはOracleの根拠であって、Oracleではない。
答えまで生成させると、答え合わせが消える
危ないのは、Oracleが実装自身になることだ。
# 実装を動かして出た値を、そのまま期待値として貼る
expect(calculate_price(100, 2)).to eq 200200の出どころが仕様なら健全で、実装の実行結果なら何も検証していない。 実装が実装どおりに動くことを確かめているだけなので、仕様が間違っていても緑になる。 実装とテストを続けて生成させると、構造的にこの形へ寄る。
参考記事が挙げている極端な例は、その成れの果てだ。
if input == "known-test-value":
return expected_valueReward Hackingと呼ばれている。 評価指標そのものを攻略する挙動で、テスト用の値のときだけ期待値を返す。 モデルが手を抜いたのではなく、与えられた成功条件を最短で満たしただけである。 正しいコードではなく成功条件を目指すのだから、成功条件が甘ければそこへ収束する。
評価する側を生成物にすると、生成側は評価のほうを最適化しにいく。 ループが収束しない話と同じ構造が、1本のテストの中にも入っている。
人間が書くのは10行
では、人間がテストコードを書くのか。 書くのは答えの側だけで、しかもRSpecでなくていい。
境界線は一つの問いで引ける。 コードから推論できないことだけを人間が書く。
AIは既存コードから、この実装がどう動くかを推論できる。 推論できないのは次の3つで、これは情報がコードの外にしかない。
- どの失敗が許容できないか(二重課金は不可、表示の崩れは可)
- どこまでが業務上ありうる入力か(quantityが0は正常系か異常系か)
- 何が壊れてはいけない性質か(残高の保存、状態遷移の順序)
この3つがOracleの中身になる。 逆に、AIに聞けば分かることを人間が書くのは無駄である。
実物はこれくらいの分量にしかならない。 issueや作業指示に置くだけだ。
## 受け入れ基準(Oracle)
観測点: HTTP status / 通知レコード件数 / 送信回数
不変条件: 同一の冪等キーで何回叩いても、レコードは1件、送信は1回
ケース:
- 正常1回 → 200, 件数=1, 送信=1
- 同一キー2回 → 2回目も200, 件数=1, 送信=1
- 同一キー並行2本 → 片方が待つか409。件数=1, 送信=1
- 送信後にDB失敗 → 「件数=0 かつ送信=1」は許容しない
- キー欠落 → 400, 件数=0, 送信=0
禁止: 上記ケースの削除、改名、アサーションの緩和。追加は報告すること。11行。 RSpecへの落とし込みも、factoryもmockも実装も、この後ろは渡してしまえる。 最後の1行が要るのは、緑にする過程で基準が緩む方向へ動くからだ。
これを全実装に課すと破綻するので、深さはリスクで3段に切る。
| 対象 | 人間が書く量 |
|---|---|
| 表示、文言、戻せる小変更 | 0行。落ちない、例外を吐かない、で足りる |
| ふつうのロジック追加 | 3〜5行。観測点と境界入力の列挙だけ。期待挙動はAIに書かせて読んで承認する |
| 金銭、状態遷移、冪等性、戻せない操作 | 10〜20行。上の全部 |
期待値が書けないときの答え合わせ
仕様が複雑で期待値なんて書けない、と詰まることがある。 入力を作るのは簡単だが正解を知るのは難しい、という現象にはOracle Problemという古い名前が付いている。
ただ、詰まる原因はOracleが作れないことではなく、期待値を直接書く形しか使っていないことが多い。 正解の出どころは5種類ある。
| 種類 | 正解の出どころ | 例 |
|---|---|---|
| 明示的 | 人間が仕様から期待値を決める | retry時は200、送信回数は1のまま |
| 暗黙的 | 正解を知らなくても明らかに不正と分かる | 落ちない、未捕捉の例外を吐かない、タイムアウトしない |
| 不変条件 | 常に保たれるべき性質 | 残高の合計が処理の前後で一致する |
| メタモルフィック | 正解値は不明でも入出力の関係は分かる | ソート結果の件数は入力と同数、条件を緩めたら件数は減らない |
| 差分 | 別実装や旧実装の出力と比較する | リファクタの前後で出力が完全に一致する |
下の3つは、正解値を知らないまま答え合わせができる。 リファクタなら差分、集計処理なら不変条件、検索やソートならメタモルフィックが効く。
それでも境界の期待挙動が決まらないなら、それは実装の詳細ではなく仕様の穴だと思う。 0のとき0円を返すのか拒否するのか。 送信した後にcommitが落ちたら、どちらを正とするのか。 決まっていないから書けない。
だから、どこまでやるかの答えは、仕様の穴を埋めるところまでになる。 埋めずに渡せば、AIが埋めた解釈がそのままOracleになって緑が出る。 実装と検証が同じループに入るというのは、たいていこの経路で起きる。
人間の模範解答も間違う
甘いOracleを事後のレビューで見抜くのは難しい。 甘くても実装と整合している限り、読んで違和感が出ない。 緑だし、書いてあることは正しい。 抜けているものは見えない。
事前に固定しておけば、突き合わせが照合になる。 事前の11行が全件残っているか、緩められていないか。 これは機械的に見られる。
とはいえ、人間が書いたOracleが正しいとも限らない。 ものさしが目的とズレていても決定論の評価系はそのまま収束する、という前の話とちょうど同じ場所だ。
攻め方は、依頼の向きを反転させることだと思う。 「テストを通して」ではなく「この受け入れ基準を全部満たしたまま、仕様を破る実装を書けるか試せ」。 書けてしまったらOracleに穴がある。 機械でやるならMutation Testingで、実装をわざと壊してテストが落ちるかを見る。
そこまで組めているかというと、自分はまだだ。 Oracleの妥当性は結局レビューに頼っている。 答えを作る側の答え合わせを誰がするのか、ここはまだ答えを持っていない。