はじめに
この記事では、AIを使ってテストファースト開発を続ける中で、実装中心だった思考がテストケース中心に変わっていった過程をまとめます。
あわせて、現場で回しやすい進め方や注意点も整理します。
AIを使ってテストファーストで開発していると、頭の使い方が少し変わってきます。
以前は「どう実装するか」を最初に考えていましたが、今は「何を満たせば正しいと言えるか」を先に考える時間が増えました。
これはAIが実装を補助してくれるからです。
実装の詳細より、テストケースの質がそのまま成果物の質に直結しやすくなりました。
実装より先にテストケースを考える理由
AIは実装案を高速に出せます。
ただし、指示が曖昧だとそれらしいコードを作るだけで、仕様を外すことがあります。
そこで重要になるのがテストケースです。
- 正常系で何を保証するか
- 境界値でどこまで許容するか
- 異常系で何を返すか
- 副作用をどこまで許すか
これを先に決めると、AIへの指示も評価も明確になります。
変わったのは「実装力」より「仕様分解力」
AI導入後に伸びたのは、アルゴリズムを書く速度より、仕様をテスト可能な単位へ分解する力でした。
例えば、次の観点を先に言語化するようになります。
- 入力の前提条件
- 出力の期待値
- 例外時の振る舞い
- データの不変条件
この分解ができると、AIが出した実装が多少違っても、テストで正否を判断できます。
導入前と導入後で変わった思考順序
以前は次のような流れでした。
- まず実装を書く
- 動かして問題が出たら直す
- 最後にテストを足す
AIを使うようになってからは、次の流れに変わりました。
- 先にテストケースを列挙する
- AIに実装させる
- テスト結果を見て仕様との差分を詰める
この違いで大きいのは、実装の巧さより、仕様の言語化の質が成果を左右する点です。
実際に起きた変化
テストファーストをAIと回すと、日々の開発で次の変化が起きやすいです。
- 実装レビューよりテストケースレビューの比重が上がる
- 先に失敗するテストを書くので、仕様の抜けに早く気づく
- 実装の書き換えが怖くなくなる
- バグ修正時に再発防止テストを追加する流れが自然になる
結果として、実装の中身そのものへの執着が少し減ります。
代わりに、仕様をどれだけ検証できているかに意識が向きます。
先にテストケースを作り切る進め方も有効
最近は、実装に入る前にテストケースを一度作り切ってしまう進め方も有効だと感じています。
特にエッジケースを先に洗い出しておくと、後からの手戻りが減ります。
- 正常系を最小セットで定義する
- 境界値と異常系を先に列挙する
- 条件の組み合わせケースを先に作る
- 仕様上あいまいなケースを先に議論して確定する
- そのテストケース群をAIへの実装指示とレビュー基準に使う
この順番にすると、実装の速さより「何を満たせば完成か」が先に固まります。
特に組み合わせケースまでテストで示すと、自然言語だけで説明するよりAIが仕様を理解しやすくなります。
例えば hoge のケースでは、「閾値を超えたユーザーを1人だけアラート化する」のか「閾値を超えたユーザー全員をアラート化する」のかを、次のようにテストで固定できます。
func TestRunWithMultipleUsersAboveThreshold(t *testing.T) {
t.Run("複数のユーザーが閾値を超えた場合は全てアラート化されること", func(t *testing.T) {
loc, err := time.LoadLocation("Asia/Tokyo")
if err != nil {
t.Fatalf("failed to load location: %v", err)
}
clock := fixedClock{now: time.Date(2026, 1, 24, 10, 0, 0, 0, loc)}
store := &fakeStore{
totals: []WeeklyTotal{
newTestWeeklyTotal("u1", 100_000),
newTestWeeklyTotal("u2", 200_000),
newTestWeeklyTotal("u3", 300_000),
newTestWeeklyTotal("u4", 99_999), // 閾値未満
},
}
svc := Service{Store: store, Clock: clock}
params := RunParams{
AlertType: AlertTypeTradeWeekly,
UserType: UserTypeIndividual,
RiskClass: RiskClassMedium,
}
if err := svc.Run(context.Background(), params); err != nil {
t.Fatalf("run failed: %v", err)
}
if got := len(store.inserted); got != 3 {
t.Fatalf("expected 3 alerts, got %d", got)
}
gotUsers := make(map[string]struct{}, len(store.inserted))
for _, alert := range store.inserted {
gotUsers[alert.UserID] = struct{}{}
}
for _, uid := range []string{"u1", "u2", "u3"} {
if _, ok := gotUsers[uid]; !ok {
t.Errorf("expected alert for user %s, but not found", uid)
}
}
})
}
この形にしておくと、実装が1件だけ通知してしまうのか、対象ユーザー全員を通知するのかという解釈ぶれを減らせます。
それでも一度では出し切れないので反復する
とはいえ、最初の1回でテストケースをすべて出し切るのは現実的ではありません。
まずは大まかなパターンを列挙し、実装を進めながらもう一度テストケースを見直して、欠けているケースを追加していく進め方が実務では安定します。
この反復を回すときは、次の順で進めると整理しやすいです。
- テスト失敗を仕様差分として記録する
- 差分が仕様不足か実装不具合かを切り分ける
- 仕様不足ならテストケースを追加する
- 実装不具合なら実装だけ直して再実行する
テストと実装を同時に育てる意識があると、途中で迷いにくくなります。
AIに渡すときのテストケースの書き方
ここは厳密にやりすぎると、準備コストが重くなります。
最近のAIは意図を汲む力が上がっているので、最初は雑な自然言語で渡しても十分回せる場面が多いです。
例えば次のような粒度でも、まずは実装とテストのたたき台を出せます。
-
hogeの条件では複数件を返してほしい - 通常は最新順で返す
- 条件が足りない場合はエラーにする
まずは日本語でもよいので、ざっくり「作成してほしいテスト」をAIに渡すのがおすすめです。
その日本語をそのまま Go のテスト実行名にすると、仕様とテストコードの対応が取りやすくなります。
例えば t.Run("条件が足りない場合はエラーを返すこと", func(t *testing.T) { ... }) のようにしておくと、テスト名を読むだけで意図を確認できます。
この進め方で重要なのは、最初から完璧に書くことではなく、方向がずれたらすぐ止めて修正することです。
そのため、最初の指示は軽く、確認と修正を早く回すほうが実務では速いです。
一方で、複雑な仕様や不具合が続く箇所は、条件を明文化したほうが安定します。
その場合はテストケースを次の形式で整理します。
- 前提条件
- 入力
- 期待値
- 補足ルール
例としては次のような形です。
- 前提条件: データは有効なユーザーのみ対象
- 入力:
status=active,limit=10 - 期待値: 作成日時降順で最大10件
- 補足ルール:
hoge条件が重なる場合は複数件返却可
このレベルまで明記すると、AIの実装と人間の期待値が揃いやすくなります。
DB層はモックしない前提が効く
DB層を基本モックしない運用は、AI活用時にも相性が良いです。
理由は、実データに近い挙動でテストできるため、仕様理解のズレを早めに拾えるからです。
- SQLの条件漏れ
- ソート順の違い
- NULLや重複の扱い
- 複数件返却時の順序と件数
このあたりは、モック中心では見落としやすい領域です。
注意点
テストファーストをAIで回すときは、次の落とし穴があります。
- テスト自体が仕様を誤解している
- 期待値の定義が曖昧で、テストが仕様の代わりになれていない
- ケースの抜けがあるまま、テストが通ったことで安心してしまう
もうひとつ実務で起きやすいのが、見かけ上はテスト成功でも、内部ではエラーが発生しているケースです。
例えば、バッチ処理の成功だけを確認するシナリオテストでは、最終的なジョブ結果は成功でも、内部の登録処理で一時的なエラーや重複insertが起きていることがあります。
処理自体は完走するため見逃しやすいのですが、ログは汚れ続けるため、放置すると保守性が下がります。
こうした箇所は人間がログまで細かく確認すると、テストコードの品質を上げやすくなります。
そのため、次の観点を確認しておくと安全です。
- テスト実行ログに握りつぶされた例外がないか
- 初期データ作成で一意制約違反や重複insertが起きていないか
-
try-catchで広く例外を吸収していないか
対策はシンプルです。
- 期待値の根拠を仕様書やユースケースに結びつける
- DB層は基本モックせず、実データに近い条件で検証する
- 契約テストや統合テストで現実との接点を持つ
- テストコードも本体コードと同じ基準でレビューする
自分の体感では、AIがテストに合わせた不自然な実装を出すケースはそこまで多くありません。
それよりも、テストケースの設計が甘いまま進むことのほうが、実際の品質に効いてくると感じています。
細かく書くほど良いわけではない
ここで気をつけたいのは、テストケースを細かくしすぎると、内部実装に依存した壊れやすいテストを量産しやすい点です。
実装の手順や内部関数の呼ばれ方まで固定すると、仕様は変わっていないのにテストだけ壊れる状態になります。
大事なのは、アプリケーションとしての振る舞いを細かく検証することです。
- 何を入力したら何が返るか
- どの条件で成功/失敗するか
- 副作用として何が保存・更新されるか
- 利用者から見て観測できる結果が正しいか
逆に、次のような項目は必要以上に固定しないほうが安定します。
- 内部の関数分割や呼び出し順
- 中間オブジェクトの細かい形
- 実装都合の一時的な処理手順
中の実装を意識しないテストに寄せるほど、リファクタリング耐性が上がり、AIとの反復でも保守しやすくなります。
チーム運用で意識していること
個人で回せても、チームで回せないと効果は限定的です。
そのため、次の運用ルールを置くと定着しやすくなります。
- 実装PRと同じ重さでテストケースをレビューする
- 新規バグには再発防止のテスト追加を必須にする
- テスト名を仕様文として読める形にする
- テスト名を見ながら、日本語で未作成のテストがないか確認する
- ケース分類を定期的に整理して重複を減らす
テストケースをチームの共通言語にできると、AI活用の再現性も上がります。
まとめ
AIでテストファースト開発を始めると、実装の中身よりテストケースを先に考える時間が増えます。
これは手抜きではなく、品質保証の重心が移ったということです。
実装はAIが支援できる時代だからこそ、人間は「何を正解とするか」を定義する役割を強く持つようになります。
最初に完璧なテストケースを作る必要はありません。
大枠を作って反復し、エッジケースと組み合わせケースを追加していく運用が現実的です。
その意味で、これからの開発力は実装速度だけでなく、良いテストケースを設計し続ける力で決まると感じています。