令和のTDD AIが書いて、エンジニアが仕上げる新卒流実践法
この記事はスマートキャンプ株式会社のアドベントカレンダー 2025 の18日目の記事です!
はじめに
こんにちは、りゅうです!
新卒1年目のエンジニアとして、TDD(テスト駆動開発)に取り組み始めたとき、正直なところ「難しそう...」と思っていました。テストを先に書く? 実装の前にテストの設計を考える? 経験が浅い自分にできるのか不安でした。
でも、AIコーディングアシスタント(Cursorとか)が登場して、この状況が変わりました!AIがテストコード生成をサポートしてくれるおかげで、新卒でもTDDが実践しやすくなったんです。
ただAIに丸投げするだけじゃダメなんですよね。今回は、標準的なRed-Green-Refactorサイクルに、Commit(先人の知恵)とOptimize(独自の工夫)という2つのフェーズを追加した5段階TDDサイクルを紹介します!
この記事では、僕が実際に実践している、AIと人間が適切に役割分担しながら高品質なテストを書く方法をシェアします。
この記事で分かること
- 標準的なTDDサイクルの基礎
- AIと協働するための5段階TDDサイクル
- CommitとOptimizeフェーズって何?なぜ必要?
- テスト独立性の保ち方
- 新卒エンジニアがAI時代にTDDで成長する方法
標準的なTDDとは
まず、TDDの基本となるRed-Green-Refactorサイクルを理解しましょう!
Red フェーズ:失敗するテストを書く 🔴
最初に、まだ実装されていない機能のテストを書きます。このテストは当然失敗します。
# RSpecの例
describe 'User API' do
it 'returns user data with status 200' do
user = create(:user, id: 1, name: 'John Doe')
get "/api/users/#{user.id}"
expect(response).to have_http_status(200)
expect(JSON.parse(response.body)['name']).to eq('John Doe')
end
end
このテストを実行すると、まだAPIエンドポイントが実装されていないため、当然失敗します(Red)。
Green フェーズ:テストを通す最小実装 🟢
次に、テストを通すための最小限の実装を行います。完璧を目指さず、とにかくテストが通ることを最優先にします!
# 最小実装の例
get '/api/users/:id' do
# ハードコードでもOK(最小実装)
status 200
{ name: 'John Doe' }.to_json
end
テストが通れば成功(Green)です。
Refactor フェーズ:コードの品質向上 🔵
テストが通った状態で、実装コードをリファクタリングします。機能は変えずに、コードの品質を向上させます。
# リファクタリング後
get '/api/users/:id' do
user_id = params[:id]
user = User.find(user_id) # DBから取得
status 200
{ name: user.name }.to_json
end
この3つのサイクルを繰り返すことで、テストに守られながら機能を開発していくのが、標準的なTDDの流れです。
独自拡張:5段階TDDサイクル
標準的なRed-Green-Refactorに、CommitとOptimizeという2つのフェーズを追加した5段階サイクルを紹介します。
なぜ2つのフェーズを追加するのか?
1. AI時代の新しい役割分担
- Red-Green(AI主体): AIが迅速にテストと実装を生成
- Refactor-Optimize(人間主体): エンジニアが調整・理解・試行錯誤
2. TDDの実用性を保つ
TDDでは網羅的なテストを書くため、テスト実行時間が増大しがちです。実用的な開発サイクルを保つために、最適化フェーズが必要になります。
3. 新卒エンジニアの学習機会
後半のRefactor-Optimizeフェーズで、AIが書いたコードを読み解き、改善を考えることで、テスト設計の原則を深く学べます。
Commit フェーズ:変更をコミット 📝
このフェーズについて:
このCommitフェーズは、TDD実践者コミュニティで推奨されている「小さく区切ってコミットする」ベストプラクティスを、AIペアプログラミングに適用したものです。特にAI時代では、AIの暴走を防ぐための「承認ポイント」としての役割が重要になります。
Refactorフェーズが完了したら、1つの完結したTDDサイクルとしてコミットします!
Commitフェーズの目的:
- TDDサイクルの明確な区切りを作る
- ユーザー承認のタイミングを設定(AI暴走防止)
- TODOリストを見直し、次のサイクルを計画
# コミット例
git add .
git commit -m "feat: ユーザー取得APIのエンドポイントを追加"
重要なポイント:
- 各サイクル完了後に必ずユーザー(開発者自身)が確認
- TODOリストを更新し、次に何をするかを明確化
- AIが勝手に先へ進まないようにブレーキをかける
Optimize フェーズ:テスト効率化 🟡
このフェーズについて:
このOptimizeフェーズは、僕が実際のプロジェクトで直面した課題から独自に考案したものです。Commitフェーズが既存のベストプラクティスなのに対し、Optimizeは現場の制約から生まれた独自の工夫です。
基本的なTDDサイクルが完了し、すべてのテストが通った後に実行するテスト最適化フェーズです!
僕が携わっているプロダクトでは、「テスト実行時間を抑えつつ(テストケース数を抑えつつ)、網羅的なテストを行いたい」という隠し要件があります。(あると思っています)
一方で、TDDは過去のテストを保持することで、新たな変更が他の機能に影響を与えないことを保証しながら開発を進めます。そのため、必然的にテストケース数が増えていきます。
この相反する2つの要求を解決するのが、Optimizeフェーズの役割です!
なぜこのフェーズを独自に追加したのか:
- 既存のTDD理論には「テスト実行時間の最適化」を体系的に扱うフェーズがない
- 実際の開発現場では、テスト実行時間が開発速度のボトルネックになる(CI/CDが長くなる弊害)
- この課題に対する実践的な解決策として、試行錯誤の末にたどり着いた
現在進行形で改良中:
このOptimizeフェーズはまだまだ検討の余地があり、実践を通じて引き続き改良を重ねています。より良いアプローチやパターンがあれば、ぜひフィードバックをいただけると嬉しいです!
Optimizeフェーズの目的:
- テスト実行時間の削減(例:5リクエスト → 1リクエスト)
- テストコードの可読性とメンテナンス性向上
- テスト独立性を保ちながら効率化
Refactorとの違い:
| 観点 | Refactor | Optimize |
|---|---|---|
| 対象 | 実装コード | テストコード |
| 目的 | コード品質向上 | テスト効率化 |
| 機能 | 変更しない | 変更しない |
| 実行時間 | 変わらない | 削減する |
最適化の例(Before/After):
# Before: 5つのテストケース = 5回のリクエスト
it 'returns status 200' do
get "/api/users/#{user.id}"
expect(response).to have_http_status(200)
end
it 'returns correct user name' do
get "/api/users/#{user.id}"
expect(response.body['name']).to eq(user.name)
end
it 'returns correct email' do
get "/api/users/#{user.id}"
expect(response.body['email']).to eq(user.email)
end
# ... さらに続く
# After: 1つのテストケース = 1回のリクエスト
it 'returns user data with status 200' do
get "/api/users/#{user.id}"
expect(response).to have_http_status(200)
expected_body = {
'name' => user.name,
'email' => user.email,
'age' => user.age,
'createdAt' => user.created_at.iso8601
}
expect(response.body).to match(expected_body)
end
新卒の僕が感じた価値:
AIが作ったテストコードを見て、「なんで5回もリクエストしてるの?」「これ1回にまとめられるんじゃない?」って考えるのが、めちゃくちゃ勉強になりました。この過程でテスト設計の理解が深まります!
テスト独立性の保証
Optimizeフェーズで最も大事なのが**テスト独立性(Test Independence)**です!
なぜテスト独立性が重要?
テストが他のテストに依存してると、こんな問題が起きます:
- ランダム順序で実行すると失敗する(困る...)
- 並列実行ができない(遅い...)
- 特定のテストだけ実行するとエラーになる(デバッグできない...)
- 原因の特定が難しくなる
独立性を保つための3つの原則
1. テストデータの独立性
各テストは独自のテストデータを使用し、他のテストのデータに依存しません。
# Good: 各テストが独立したデータを作成
describe 'User API' do
it 'returns published users' do
published_user = create(:user, status: 'published')
get '/api/users'
expect(response.body).to include(published_user.name)
end
it 'filters draft users' do
draft_user = create(:user, status: 'draft')
get '/api/users'
expect(response.body).not_to include(draft_user.name)
end
end
2. 実行順序への非依存性
テストの実行順序が変わっても、結果は変わりません。
# ランダム順序で実行してもすべて通る
bundle exec rspec --order random
# または特定のテストだけ実行しても通る
bundle exec rspec spec/requests/users_spec.rb:42
3. 状態の独立性
テストは共有状態を持たず、前のテストの結果に影響されません。
# Good: before/letで毎回クリーンな状態を作る
describe 'ShoppingCart' do
let(:cart) { ShoppingCart.new } # 毎回新しいインスタンス
it 'starts empty' do
expect(cart.items).to be_empty
end
it 'adds items' do
cart.add_item('apple')
expect(cart.items.size).to eq(1)
end
end
独立性の検証方法
最適化後は、必ず以下の方法でテスト独立性を検証します:
# 1. 個別実行テスト(特定のテストのみ実行)
bundle exec rspec spec/requests/users_spec.rb:42
# 2. ランダム順序実行テスト
bundle exec rspec --order random
# 3. 並列実行テスト(可能な場合)
bundle exec rspec --parallel
すべてのパターンでテストが通れば、独立性が保たれています。
実践例:AIコーディングアシスタントでの実装
実際にAIアシスタント(Cursor等)で5段階TDDサイクルを実践する方法を紹介します。
ワークフロー全体像
1. タスク理解
↓
2. 既存コード確認
↓
3. タスク分解(TODOリスト作成)
↓
4. TDDサイクル実行(Red → Green → Refactor → Commit)
↓(ユーザー承認)
5. 次のサイクルへ(または完了後にOptimize)
Step 1: タスク理解
まず、実装する機能の要件を明確に理解します。
AIへのプロンプト例:
以下のタスクについて、実装すべき内容を整理してください:
【タスク】
ユーザー一覧API(GET /api/users)を実装する
- 公開済みユーザーのみ返す
- ページネーション対応(limit, offset)
- ステータスでフィルタリング可能
Step 2: 既存コード確認
AIに既存のコードパターンを確認させます。
AIへのプロンプト例:
このプロジェクトの既存のAPI実装パターンを確認してください。
特に以下の点を調べてください:
- テストファイルの命名規則
- ページネーションの実装方法
- エラーハンドリングのパターン
Step 3: タスク分解
AIにTODOリストを作成させます。
生成されるTODOリストの例:
- [ ] 🔴 Red: 公開済みユーザーを返すテストを作成
- [ ] 🟢 Green: ハードコードで200を返す最小実装
- [ ] 🔵 Refactor: DBからデータを取得する実装に変更
- [ ] 📝 Commit: 基本機能をコミット
- [ ] 🔴 Red: ページネーションのテストを追加
- [ ] 🟢 Green: limit/offsetパラメータ処理を実装
- [ ] 🔵 Refactor: クエリを最適化
- [ ] 📝 Commit: ページネーション機能をコミット
- [ ] 🟡 Optimize: テストケースを最適化
Step 4: TDDサイクル実行(1サイクルずつ)
重要なのは、1サイクルずつユーザー承認を得ながら進めることです。
AIへのプロンプト例:
TODOリストの最初のサイクル(Red → Green → Refactor → Commit)を実行してください。
完了したら、次に進む前に私の承認を待ってください。
AIの動作:
- Redフェーズ: テストを作成
- Greenフェーズ: 最小実装
- Refactorフェーズ: コード改善
- Commitフェーズ: 変更をコミット
- 停止して、ユーザーの承認を待つ ← 重要!
Step 5: Optimizeフェーズ
すべてのTDDサイクルが完了し、テストが通った後に実行します。
重要: Optimizeフェーズでは、実装コードは一切変更しません。テストコードの最適化のみを行います!
AIへのプロンプト例:
すべてのテストが通ったので、Optimizeフェーズを実行してください。
以下の点に注意してください:
- テスト独立性を必ず保つこと
- リクエスト数を削減すること
- 最適化後、個別実行とランダム実行で検証すること
AIモデルの使い分け
プロジェクトによっては、AIモデルを使い分けると効率的です:
| フェーズ | モデル | 理由 |
|---|---|---|
| タスク理解・分解 | 高性能モデル | 深い理解が必要 |
| Red-Green-Refactor | 高速モデル | 繰り返し実行が多い |
| Optimize | 高性能モデル | 複雑な判断が必要 |
最適化パターン集
実際のOptimizeフェーズで使える、具体的なパターンを紹介します。
パターン1: 個別検証の一括化
Before(非効率):
it 'returns correct user id' do
expect(response.body['id']).to eq(user.id)
end
it 'returns correct user name' do
expect(response.body['name']).to eq(user.name)
end
it 'returns correct email' do
expect(response.body['email']).to eq(user.email)
end
After(効率的):
it 'returns user data with correct properties' do
expected_body = {
'id' => user.id,
'name' => user.name,
'email' => user.email
}
expect(response.body).to match(expected_body)
end
独立性の観点: 同じリクエスト、同じレスポンスを検証しているので統合可能。
パターン2: 配列検証の順序非依存化
Before(順序依存):
expect(response.body['users']).to eq([
{ 'id' => 1, 'name' => 'Alice' },
{ 'id' => 2, 'name' => 'Bob' }
])
After(順序非依存):
expect(response.body['users']).to match_array([
hash_including('id' => 1, 'name' => 'Alice'),
hash_including('id' => 2, 'name' => 'Bob')
])
独立性の観点: DBから取得する順序が不定の場合、順序に依存しない検証が安全。
パターン3: テストデータ作成の効率化
Before(冗長):
let!(:user1) { create(:user, name: 'User 1', status: 'published') }
let!(:user2) { create(:user, name: 'User 2', status: 'published') }
let!(:user3) { create(:user, name: 'User 3', status: 'published') }
After(簡潔):
let!(:users) do
create_list(:user, 3, status: 'published').tap do |users|
users[0].update(name: 'User 1')
users[1].update(name: 'User 2')
users[2].update(name: 'User 3')
end
end
独立性の観点: データ作成方法を変えても、各テストは独立したデータセットを使用。
パターン4: 動的期待値の生成
Before(ハードコード、危険):
expect(response.body['userName']).to eq('John Doe')
# ← 実際のデータと一致しない可能性がある
After(動的生成、安全):
expect(response.body['userName']).to eq(user.name)
# ← 実際のテストデータから期待値を生成
独立性の観点: テストデータと期待値が常に同期するため、信頼性が向上。
パターン5: 同じレスポンスの複数検証を統合
Before(5リクエスト):
context 'published user' do
let!(:user) { create(:user, status: 'published') }
it 'returns status 200' do
get "/api/users/#{user.id}"
expect(response).to have_http_status(200)
end
it 'includes user name' do
get "/api/users/#{user.id}"
expect(response.body['name']).to eq(user.name)
end
it 'includes email' do
get "/api/users/#{user.id}"
expect(response.body['email']).to eq(user.email)
end
it 'includes status' do
get "/api/users/#{user.id}"
expect(response.body['status']).to eq('published')
end
it 'includes timestamps' do
get "/api/users/#{user.id}"
expect(response.body['createdAt']).to be_present
end
end
After(1リクエスト):
context 'published user' do
let!(:user) { create(:user, status: 'published') }
it 'returns user data with status 200 and all properties' do
get "/api/users/#{user.id}"
expect(response).to have_http_status(200)
expected_body = {
'name' => user.name,
'email' => user.email,
'status' => 'published',
'createdAt' => user.created_at.iso8601
}
expect(response.body).to match(expected_body)
end
end
独立性の観点:
- 同じユーザーデータに対する同じリクエスト
- 同じレスポンスの異なるプロパティを検証
- 統合しても独立性は保たれる
効果:
- リクエスト数: 5 → 1(80%削減)
- テスト実行時間: 大幅に短縮
- 可読性: 1つのテストで全体像が把握できる
AI時代のTDD実践で得られた気づき
新卒1年目の僕が、AIと一緒にTDDを実践して気づいたことをシェアします!
役割分担の発見
一番の発見は、AIと人間でちゃんと役割分担できるってことでした!
Red-Greenフェーズ(AI主体)
AIの強み:
- テンプレート的なテストコードの迅速な生成
- 基本的な実装パターンの適用
- 繰り返し作業の効率化
AIは、「こういうAPIならこういうテストが必要だろう」という基本パターンを高速に生成してくれます。これにより、Red-Greenフェーズは驚くほどスムーズに進みます。
Refactor-Optimizeフェーズ(人間主体)
人間の役割:
- AIが生成したコードを読み解く
- テスト設計の原則を適用する
- 最適化の余地を見つけ出す
- 試行錯誤しながら改善する
ここが新卒にとって最も学びが多い部分です。
新卒にとっての学習効果
1. コードを読み解く力がつく
AIが生成したテストコードを見て、「なぜこのテストが必要なのか?」「どういう意図で書かれているのか?」と考えます。この過程で、テストコードの読解力が格段に向上しました。
# AIが生成したコード
it 'returns 404 for non-existent user' do
get "/api/users/999999"
expect(response).to have_http_status(404)
end
# 読み解き
# → エッジケースのテスト(存在しないID)
# → エラーハンドリングの検証
# → これは重要なテストだ!
2. 最適化を考える中で原則を学ぶ
「このテストは統合できるだろうか?」と考える際、自然とテスト独立性の原則に向き合います。
# このテスト、統合できる?
it 'returns user A' do
expect(response.body[0]['name']).to eq('Alice')
end
it 'returns user B after user A' do
expect(response.body[1]['name']).to eq('Bob')
end
# → 統合できない!順序に依存している
# → 順序非依存にするには?
# → arrayContainingを使おう!
このように、実際に手を動かしながらテスト設計の原則を体得できます。
3. 試行錯誤する実践力
最適化フェーズでは、「こうしたらどうなるか?」と試行錯誤します。
# 試行錯誤の例
$ bundle exec rspec spec/requests/users_spec.rb
# → 通った!
$ bundle exec rspec --order random
# → あれ、失敗した...順序依存してる
# → テストデータを見直そう
# → let!をbeforeEachに変更してみよう
# → 再度ランダム実行...今度は通った!
この試行錯誤こそが、実践的なスキルを身につける最良の方法だと感じています。
TDDの心理的ハードルが下がった
以前のTDD:
- 完璧なテストを最初から書かなきゃ...
- テスト設計難しすぎる...
- 経験ないと無理じゃない...?
AI時代のTDD:
- まずAIに基本書いてもらおう!
- 後から直せばいいや
- やりながら学べばOK!
AIがいるおかげで、「完璧じゃなくても、後で直せばいいんだ」って思えるようになりました。この安心感のおかげで、TDDを始めるハードルがめちゃくちゃ下がったと感じてます。
本質的な原則は人間が守る
ただし、テスト独立性などの本質的な原則は、人間が理解し判断する必要があることも学びました。
AIは優れたアシスタントですが、以下のような判断は人間が行うべきです:
- 「このテストは統合すべきか、分けるべきか?」
- 「独立性は保たれているか?」
- 「このテストは何を保証すべきか?」
- 「このコードはもっと綺麗に書けるんじゃないか?」
AIはあくまでツールです。最終的な判断と責任は、開発者である僕たち人間にあります。
最後に
ここまで、Red-Green-RefactorにCommitとOptimizeを加えた5段階TDDサイクルを紹介してきました!
5段階TDDサイクルの振り返り
| フェーズ | 主体 | 目的 |
|---|---|---|
| 🔴 Red | AI | 失敗するテストを生成 |
| 🟢 Green | AI | 最小実装でテストを通す |
| 🔵 Refactor | AI+人間 | コード品質向上 |
| 📝 Commit | 人間 | 区切りを作り、承認 |
| 🟡 Optimize | 人間 | テスト効率化、学習 |
CommitとOptimizeの価値
Commitフェーズ(先人の知恵を応用):
- TDDコミュニティで推奨される頻繁なコミットの実践
- AI時代の文脈での再解釈:AI暴走を防ぐユーザー承認ポイント
- TODOリストの見直しと次の計画
- 明確な作業単位の区切り
Optimizeフェーズ(独自の工夫):
- 実際のプロジェクトの制約から生まれた独自のフェーズ
- TDDの実用性を保つ(実行時間削減)
- テスト独立性を保ちながら効率化
- 新卒エンジニアの学習機会
あとがき
AIは本当に強力なパートナーですが、テスト設計の原則を理解することは今でも大事です!
ただ、AIがいることで、新卒でも実践しながら学べる環境が整ったのは間違いないです。完璧を目指さず、まずはやってみることが大切だと思います。
特にOptimizeフェーズについては、僕自身もまだまだ試行錯誤中です。プロジェクトによって最適なアプローチは異なるはずですし、もっと良い方法があるかもしれません。この記事をきっかけに、皆さんのプロジェクトに合った最適化手法を一緒に見つけていけたら嬉しいです!
まずは小さく始めてみましょう!
- AIアシスタントで簡単なAPIのテストを書いてみる
- Red-Green-Refactorの基本サイクルを体験してみる
- 慣れてきたらOptimizeフェーズに挑戦してみる
- テスト独立性を意識しながら改善を繰り返す
この記事が、AI時代のTDD実践の参考になれば嬉しいです!
Discussion