ここまで9回、specの書き方を細かく書いてきました。最後に、それが今どういう意味を持つのかを書きます。
結論から書くと、時間をかけるべきはspecのレビューだと考えます。specは仕様そのものなので、間違っていればバグや障害になる。specも大量生産されるので、判断しやすいように可読性が重要になってきています。
- 時間をかけるべきはspecのレビュー
- 実装→specの順を許容する
- AIは書かれていない判断を埋め込む
- 過去の経緯はコミットしない
- 参考にしたspecがやばいと、作られるspecもやばい
- 最後に残るもの
- これから
- 「変更に強いテストを書く」のまとめ
時間をかけるべきはspecのレビュー
1回目に、実装している時もレビューが中心になった、という話を書きました。書くコストが下がった分、時間はレビューに寄っています。追いにくいコードのストレスが、そのまま生産性に効いてくる。
では何を見るのか。specです。
specが表しているのは「どういう時に、どう動くべきか」です。つまり仕様そのもの。実装が正しいかを判断する基準が、specに書かれています。
実装の方は動かせば確認できますし、仕様の誤認があってもレビューで気づけます。でもspecが間違っていると、間違った基準で緑になります。しかも暗黙の仕様(書かれていないが期待されている挙動)は、specに現れていなければ誰も気づけません。
これは人間が書いても同じです。AIが書くようになって量が増えた分、重要性が上がりました。
レビューで見ているのは、1回目に挙げた4つです。
- そのケースは既存と重複していないか
- 抜けている組み合わせはないか
- 1ケースの中で、検証すべき項目に漏れがないか
- その期待値は、仕様として正しいか
3で見るのは、成功したか失敗したか、何が変更されたか、何が呼び出されたか。書く側の検証項目(レスポンス、DBの変更、非同期処理の呼び出し)と、レビューする側の観点が対応しています。
だから、ここまで書いてきた書き方が効きます。テストパターンの宣言があれば1が確認しやすくなり、完全比較をしていれば3が確認しやすくなる。
実装→specの順を許容する
自分の書き方は、もともとこうでした。
- 最低限の正常系を実装して、手動で動作確認
- 正常系のspecを書く
- ケースを洗い出してcontextを組み立てる
- 検証を追加してから実装
- 全て通るまで繰り返す
何もないところに見通しは作り難いので、純粋なTDDではありません。ただ、観測点が最初から外側にあります。request specやmodelの公開インターフェースで見ているので、実装の内部構造は覗いていない。
AIに投げる時は、この順序を維持できません。先にspecを渡すと、そのspecが通る最小限の実装を書きます。仕様の全体像を持たないので、specに書かれていない部分が雑になる。
なので実装→specの順を許容しています。
代わりにリスクが上がります。実装を見てspecを書くので、実装をなぞるspecになりやすい。実装が間違っていれば、期待値も間違ったまま緑になります。
ここを、決めておいた書き方で吸収します。
- テストパターンを先に宣言させる
- 観測点は公開インターフェース
- mockは境界だけ
have_attributesやeqで完全比較
これが守られていれば、実装をなぞるspecにはなりにくい。順序の問題を書き方で吸収する形です。
AIは書かれていない判断を埋め込む
9回目にcaseのelseでraiseする話を書きました。AIに実装させていると、これが効いてきます。
AIはelseを書かないことが多い。 指示された分岐だけ書いて、漏れたケースはnilを返す。落ちないので気づけません。
もっと厄介なのは、分岐が2つの時にelseに正常系を書かれるケースです。
if download.model.to_sym == :member
# メンバーの処理
else
# スペースの処理をここに書かれる
end
動きます。レビューしても違和感がない。
ただ、これは「どれにも当てはまらなかった時はスペースとして扱う」という仕様を決めたことになっています。コードにはそう書いてある。でも誰もその判断をしていません。AIは「2つあるから片方をelseにする」という構造上の都合で書いただけです。
3つ目が増えた時に顕在化しますが、その時には「これがデフォルトだったのか」と誤読されます。
レビューでは見つけられない
elseに妥当な処理が書かれていれば、読んで違和感はありません。動作も正しい。間違っていないので指摘の根拠がない。
気づくには「これは網羅なのかデフォルトなのか」を毎回問う必要があります。レビュアーの注意力に依存する。
だからcase+elseでraiseが効きます。elseがraiseで埋まっていれば、デフォルトを置く場所がありません。デフォルトが必要なら明示的に書き換えることになり、その時初めて「デフォルトを決める」という判断が発生して、diffに出ます。
判断がコードの形に現れる。 今は「判断していない」と「デフォルトを決めた」が同じ見た目になっているのが問題です。
握り潰して解決しようとする
アラート対応を任せた時にも、同じことがありました。例外をrescueして握り潰し、通知を止めようとする。
AIにとっては「エラーが出なくなった」ので解決です。でも原因は残ったままで、しかも次に起きても誰も気づけなくなります。「この例外は無視していい」という判断を、誰も下していないのに埋め込んでいる。
過去の経緯はコミットしない
もう1つ、AIに書かせていて気になるのが回帰テストを足したがることです。
バグを直した箇所に「念のため」のケースが追加される。仕様変更の時には、変更前後を比べるケースが追加される。コストがゼロなので、判断せずに足せてしまいます。
どちらも、確認のために書くのは構いません。ただコミットする必要はないと思っています。
バグ対応のケースは、コードだけ見ても理由が読めません。そこだけ粒度が違う。リファクタで消していいか判断できず、結果的に誰も触らなくなる。
仕様変更のケースはもっと明確です。過去の仕様をテストに残すと、今後の実装を縛ります。 変更のたびに履歴が溜まって、実装を変えるたびに経緯を確認することになる。テストが変更を妨げる方向に働きます。
残すとすれば、それは回帰テストではなく現在の仕様として書けるはずです。「このバグが再発しないこと」ではなく「この条件ではこう動く」と書けるなら、普通のケースとして残せばいい。
「再発したらどうする」という反論はあると思います。ただ、仕様として書けないバグというのは、実装の特定の組み合わせでしか起きないものです。それはテストで蓋をするより、実装を直す方が筋が通ります。テストを足すと、歪んだ実装がそのまま残ります。
そして「念のため」でケースが積み上がると、8回目に書いた「実装を根拠にケースを絞る」が成立しなくなります。何のためにあるか分からないケースは、減らす判断もできません。
参考にしたspecがやばいと、作られるspecもやばい
AIは既存コードを参照して書きます。1ファイル悪いspecがあれば、それが増殖する。
毎回指示して守らせるより、既存コードを直す方が安い。 しかも既存コードは毎回読まれますが、プロンプトは忘れられます。
今リファクタする理由はここにあります。コードが増える前に手本を整えておく。3回目に書いた「後から直せない」と同じ話です。
既に大量にある場合
とはいえ、全部は直りません。直している間も増えます。
現実的には、AIが参照する範囲を制御する方向になります。
手本を1つ作って明示する。 「新しいspecはこのファイルを参考に書く」とCLAUDE.mdやAGENTS.mdのような指示ファイルに書く。既存の悪いspecが残っていても、参照先を指定すれば影響を減らせます。完全ではありませんが、何も指定しないよりはるかにいい。
触ったファイルだけ直す。 改修で開いたspecは決めた形に合わせる。新規は最初からその形で書く。時間はかかりますが、触られる頻度が高いファイルから直るので効率はいい。触られないファイルは、AIが参照する確率も低い。
ディレクトリ単位で揃える。 model specから揃える、など。まとまった範囲が揃っていれば、AIがそこを参照する確率が上がります。虫食いだと参照先が運になる。
最後に残るもの
構造を整えても、書き方を決めても、減らないものがあります。
その期待値が、仕様として正しいか。
カバレッジは通ったか、しか見ていません。完全比較は変化を検知するだけで、期待値が正しいかは判定しない。eq(403)の403が正しいかは、仕様を知らないと分かりません。
AIが書くspecが危ないのはここです。実装から期待値を起こすので、実装が間違っていれば期待値も間違います。緑になる。構造も書き方も守られている。それでも仕様と違う。
ここだけは人間が判断するしかない。
だから今までの9回は、全部そのための準備だと思っています。構造を整えてケースの重複と漏れを見やすくし、itをまとめて検証項目を一望できるようにする。完全比較とmatcherの選び方で検知漏れを減らし、テストデータと責務の分け方で無駄なケースと時間を削る。そして仕組みで書き忘れを落とす。
動くかではなく、仕様として正しいか。 そこに人間の時間を集中させるための手段です。
これから
この内容をルール化して、AIに適用していく予定です。
記事をそのままルールにはできません。記事は「なぜそうするか」に価値がありますが、ルールは「何をするか」だけでいい。理由まで書くと長くなります。指示ファイルはコンテキストを消費するので、他のルールが入らなくなる。どこまで残すかは試しながら決めることになりそうです。
そして一度で終わる作業でもありません。ルールを適用してspecを生成し、レビューして、ルールを直す。この繰り返しになります。今回の記事も、具体を見ないと言語化できませんでした。ルールも同じで、レビューの産物として育つものだと思っています。
コードベースや規約がAIに対するハーネスになるという話は、コードベースと規約をハーネスとして考えてみたで書きました。ルール化の実際は、また別の記事にします。
「変更に強いテストを書く」のまとめ
- ① 長いspecが辛いのは、追加とレビューのとき — 構造を作って、追加位置と網羅を見えるようにする
- ② specの並び順と共通化 — 読む量と読む場所を減らす
- ③ itを必要以上に分けない、aggregate_failuresの使い所 — 赤の数と原因の数を一致させる
- ④ expectに書いた範囲しか守られない — 完全比較で、追加も削除も検知する
- ⑤ matcherが広すぎると、壊れていても通る — 通る範囲を絞る
- ⑥ テストデータの使い分け —
build/createとlet系を目的で選ぶ - ⑦ factoryの設計 — 揃いすぎないデータを作る
- ⑧ どこまでテストするか — 責務で分けて、実装を根拠にケースを絞る
- ⑨ 書き忘れても静かに通ってしまうもの — 「気をつける」を「落ちる」に変える
- ⑩ AIがspecを書く時代に、何をレビューするか — 人間が見るべきものを残す
全部に共通しているのは1つで、リファクタでは落ちず、仕様が変わったら落ちる。その非対称を作るための具体でした。
そしてもう1つ、specの可読性の価値が上がっています。書くのはAIでも、仕様として正しいかを判断するのは人間です。読めないspecは判断できない。速く書けるようになった分、ストレスなく読めることの重要性が上がっていると感じています。
