はじめに
AIを使った開発では、実装だけでなくテストコードまで生成できます。
例えば、不具合の内容を伝えて、
この不具合を修正してください。
回帰テストも追加してください。
と依頼すれば、
- 原因となるコードを調査する
- 修正案を作る
- テストコードを追加する
- テストを実行する
ところまでAIが進めてくれます。
非常に便利です。
一方、実務で不具合修正とレビューを経験する中で、別の疑問を持つようになりました。
AIが実装した
テストも追加した
すべてPASSした
↓
本当にこれで「直った」と判断していいのか?
テストコードを書く作業そのものは、AIによってかなり効率化できます。
だからこそ人間側には、
そのテストは本当に今回の不具合を検出できるのか?
その期待値は本当に仕様と一致しているのか?
を判断する力が必要なのではないかと思うようになりました。
この記事では、実務での不具合修正を通して変わった、自分なりの「テストを見る視点」を整理します。
※掲載しているコード、データ構造、名称は説明用に単純化したサンプルです。実際のシステム固有の実装や仕様を示すものではありません。
最初は「テストが通った」がゴールだった
エンジニアとして実務に入った当初、自分の中では、
実装する
↓
テストする
↓
PASS
↓
実装OK
というイメージがかなり強くありました。
AIがテストコードを生成してくれた場合も、
AIがテストを追加
↓
テスト実行
↓
All tests passed
となれば、一安心していました。
もちろん、テストが通ること自体は重要です。
しかし、不具合修正について学んでいく中で、
「テストが通った」と「今回の不具合を正しく修正できた」は同じなのか?
と考えるようになりました。
そもそもテストは何を動かしているのか
この理解の出発点になったのが、Next.jsのRoute Handlerに対するテストでした。
例えば、
route.ts
route.test.ts
というファイルがあります。
route.tsがAPIの処理本体なのは分かります。
しかし最初は、route.test.tsが実際の処理とどうつながっているのか、あまりイメージできていませんでした。
コードを追ってみると、テスト側では実際のRoute Handlerをimportしています。
import { POST } from "./route";
そして、テスト用のリクエストを作り、
const request = new Request("http://localhost/api/items", {
method: "POST",
body: JSON.stringify({
name: "example",
}),
});
実際の処理を呼び出します。
const response = await POST(request);
つまり、
テスト
↓
条件・入力を用意する
↓
実際の処理を呼ぶ
↓
振る舞いを確認する
という関係です。
当たり前のことかもしれませんが、自分にとってはここを理解したことで、
テストコードは「正解を証明してくれる何か」ではなく、自分たちが決めた条件と期待値を使って実装を検証している
という感覚が持てるようになりました。
バグ修正では「PASS」より先に「FAIL」を確認する
さらに大きかったのが、不具合修正における回帰テストの考え方です。
以前の自分なら、
バグを確認
↓
原因を調査
↓
実装を修正
↓
テストを追加
↓
PASS
と考えていました。
しかし、この流れだけでは一つ確認できていないことがあります。
追加したテストは、本当に今回のバグを検出できるのか?
そこで重要になるのが、実装を修正する前にテストを書くことです。
バグを理解する
↓
バグを再現するテストを書く
↓
修正前の実装でFAILすることを確認する
↓
実装を修正する
↓
同じテストがPASSすることを確認する
いわゆるRed → Greenの流れです。
なぜ「修正前にFAILすること」が重要なのか
例えば、ある条件では更新処理を行ってはいけない不具合があったとします。
it("条件Aでは更新処理を実行しない", async () => {
await execute({ type: "A" });
expect(updateData).not.toHaveBeenCalled();
});
修正前のコードで実行して、
FAIL
したとします。
これは、不具合修正においては意味のある失敗です。
現在バグがある
↓
追加したテストがFAILする
↓
このテストは少なくとも
現在の問題を検出できている
その後、実装を修正して、
FAIL
↓
実装修正
↓
PASS
となれば、
今回追加したテストが検出していた問題について、実装変更によって振る舞いが変わった
ことを確認できます。
AIがテストを書いてくれるからこそ「最初からPASS」を疑う
これはAIを使った開発でも重要だと感じています。
例えばAIへ、
このバグを修正して、
回帰テストも追加してください。
と依頼します。
AIが、
実装を修正しました
テストを追加しました
すべてPASSしました
と返してきたとします。
一見、完璧です。
しかし、
修正前のコードでも
そのテストはPASSするのでは?
という可能性は残っています。
もしそうなら、
- バグの発生条件を再現できていない
- assertionが不十分
- mockによって問題が隠れている
- 違う処理経路をテストしている
かもしれません。
AIがテストコードを生成できることと、
そのテストが今回の不具合を捕まえるテストになっていること
は別です。
だからこそ、生成されたテストについても、
このテストは
修正前ならFAILする?
と確認する視点が必要だと学びました。
テストがPASSしても、テストしていない条件は分からない
もう一つ、自分の理解が変わったのが「PASS」の意味です。
例えば、
条件A → テストあり → PASS
条件B → テストあり → PASS
条件C → テストなし
だったとします。
ここから言えるのは、
AとBについて、
書いた期待値を満たした
ということです。
Cが正しいかどうかは分かりません。
つまり、
All tests passed
≠
あらゆる条件で正しい
です。
当初は「テストが全部通った」という結果を強く見ていました。
今はそれよりも、
何をテストした結果、全部通ったのか?
を見る必要があると考えるようになりました。
さらに怖いのは「実装とテストが同じ間違いをする」こと
テストにはもう一つ限界があります。
例えば、本当の仕様が、
条件Aの場合
→ 処理Xはしない
→ 処理Yは行う
だったとします。
しかし、仕様を、
条件Aの場合
→ 何もしない
と誤解したとします。
すると、
間違った仕様理解
↓
┌─┴─┐
↓ ↓
実装 テスト
↓ ↓
└─┬─┘
↓
PASS
ということが起こり得ます。
実装もテストも同じ前提から作られているため、テストはきれいに通ります。
しかし、仕様とは違います。
つまり、
テストは「与えられた期待値と実装が一致しているか」は確認できても、その期待値そのものが正しいとは限らない
ということです。
AIが実装とテストの両方を書く場合にも同じ問題がある
これはAIを使う場合にも考える必要があります。
AIへ、
この仕様で実装してください。
テストも追加してください。
と依頼したとします。
AIが仕様を一つの意味に解釈し、その解釈をもとに、
AIの仕様解釈
├── 実装
└── テスト
の両方を生成すれば、両者は整合しやすくなります。
その結果、
実装
+
テスト
↓
PASS
になったとしても、
そもそもの仕様解釈は正しい?
という問いは残ります。
AIがコードを書けるようになるほど、
生成されたコードが動くか
だけではなく、
AIが何を正しい仕様として解釈したのか
を見る必要があると感じています。
既存コードや既存テストも「仕様そのもの」とは限らない
仕様を確認するとき、既存コードや既存テストは非常に重要な手がかりになります。
ただし、
既存コードにそう書いてある
↓
絶対にそれが正しい仕様
とも限りません。
既存コードに不具合があるから、Issueになっている可能性があります。
既存テストも同様です。
そのため、自分は徐々に、
Issueを読む
↓
既存コードを見る
↓
既存テストを見る
↓
処理経路を確認する
↓
それでも仕様が曖昧なら確認する
という流れを意識するようになりました。
既存コードやテストを「答え」として見るのではなく、
仕様を判断するための証拠の一つとして見る
という感覚です。
AI時代に必要なのは「コードを書く力」だけではない
AIによって、
実装を書く
テストを書く
原因候補を探す
修正案を出す
といった作業は大きく効率化されています。
自分自身も、開発ではAIを活用しています。
だからこそ、
自分ですべてのコードを書く
ことだけを価値にする必要はないと思っています。
一方で、AIが出したものに対して、
この仕様理解で合っている?
このテストは本当に
バグを再現している?
修正前ならFAILする?
この条件しか
テストしていなくて大丈夫?
正常系まで止めていない?
既存テストと実装が
同じ勘違いをしていない?
と確認できなければ、
AIがコードを生成
↓
テストも生成
↓
PASS
↓
そのまま採用
になってしまいます。
自分にとって今回の学びは、テストコードの書き方そのものよりも、
AIが作った実装やテストを、何を基準にレビューすればよいのか
を少しずつ理解し始めたことでした。
今の自分が考える、不具合修正の流れ
実務に入った当初は、
実装
↓
テスト
↓
PASS
↓
完成
というイメージでした。
今は次のように考えています。
① 現象を確認する
↓
② 期待する仕様を確認する
↓
③ 処理経路を調べる
↓
④ 原因について仮説を立てる
↓
⑤ バグを再現するテストを書く
↓
⑥ 修正前にFAILすることを確認する
↓
⑦ 実装を修正する
↓
⑧ 同じテストがPASSすることを確認する
↓
⑨ 既存テスト・正常系への影響も確認する
この中でAIには、
- コード調査
- 仮説出し
- テスト生成
- 実装
- テスト実行
などを手伝ってもらえます。
しかし、その結果を見て、
これで何が確認できた?
まだ何が確認できていない?
そもそもの期待値は正しい?
と考えるところまで含めて、不具合修正なのだと思うようになりました。
AIがテストを書けるなら、テストの知識はいらなくなるのか
以前なら、
テストコードを書ける
こと自体が大きなスキルだったのかもしれません。
今はAIがかなりのテストコードを書いてくれます。
それなら、
テストについて深く理解する必要もなくなるのでは?
とも考えられます。
しかし、自分は実務を経験する中で、むしろ逆だと感じています。
AIに、
テストを書いて
と頼むことは簡単です。
難しいのは、
何をテストすべき?
何をもって正しいとする?
このテストで何が保証できる?
何がまだ保証できない?
このFAIL/PASSから
何を結論づけてよい?
を判断することです。
AIによって「テストを書くコスト」が下がるほど、
テストコードを書く力より、テストの意味を判断する力
の重要性が高まるのではないかと思っています。
まとめ
実務に入った当初、自分にとってテストは、
実装したあとに実行して、PASSなら安心するもの
でした。
しかし、不具合修正を経験する中で、少しずつ見方が変わりました。
今は、
テストがPASSした
↓
何をテストした?
↓
修正前ならFAILした?
↓
今回のバグを本当に再現していた?
↓
期待値そのものは仕様と合っている?
↓
テストしていない条件は何?
まで考える必要があると思っています。
AIは実装もテストも生成してくれます。
だからこそ、
「AIがテストを書いたから大丈夫」でも、「テストが全部通ったから大丈夫」でもなく、そのテストによって何が確認できたのかを説明できること
が重要です。
自分にとってテストを学ぶ意味も、
「テストコードを自力でたくさん書けるようになること」から、「AIを含めて生成された実装やテストを、仕様と検証の観点から評価できるようになること」
へ変わりつつあります。
AIがコードもテストも書いてくれる時代だからこそ、
「PASSした」という結果ではなく、「なぜそのPASSを信頼してよいのか」を考えられるエンジニアになりたい。
今はそう考えています。