TDD(Test-Driven Development、テスト駆動開発)は、実装より先に失敗するテストを1本書き、それを通す最小のコードを足し、テストが通った状態で設計を整える開発手法です。この3段階を Red・Green・Refactor と呼び、数分単位で繰り返します。
この記事では、送料計算の関数を題材に、Red から Refactor までを pytest 9.2.0 と JUnit 6.1.3(Maven)で実際に動かした出力で追います。時刻に依存するロジックを Red にする方法、テストファーストやBDDとの違い、研究で測られた効果とコスト、向かない場面、チームでの始め方まで扱います。
まとめ:TDDの要点と採用判断
- TDDはテストを増やす活動ではなく、仕様を「実行できる例」として先に固定し、そこから実装を導く設計手法
- 手順は「テストリストを書く→1本だけテストにする→通す→必要なら整える→リストが空になるまで繰り返す」。テストを全部先に書いてから実装するのはTDDではない
- Red では失敗の理由まで読む。JUnit なら例外による Errors と、期待値の不一致による Failures を区別する
- Microsoft と IBM の4チームを調べた2008年の研究では、リリース前の欠陥密度が40〜90%下がり、初期の開発時間は15〜35%増えた(工数増は管理者の主観的な見積もり)
- 効くのは分岐と境界値が多く、長く保守するロジック。捨てる試作、期待結果が決まっていない画面の試行錯誤には向かない。テストの無い既存コードは特性テストから入る
TDDの意味と提唱者・Canon TDDの5手順
テスト駆動開発の意味とKent Beckによる体系化
TDDは Test-Driven Development の略で、日本語では「テスト駆動開発」と訳されます。エクストリーム・プログラミング(XP)のテストファーストの実践を、Kent Beck 氏が書籍『Test-Driven Development: By Example』(Addison-Wesley、2002年)で手順として体系化しました。日本語版は和田卓人氏の訳で、2017年にオーム社から『テスト駆動開発』として刊行されています。
「駆動」は、テストが実装の次の一手を決めるという意味です。書く実装は「いま失敗しているテストを通すのに必要な分」に限られるので、使われないコードや、呼び出し側から使いにくいインターフェースが生まれにくくなります。実装後にテストを書く進め方に対し、TDDでは小さな振る舞いごとにテストを先に書きます。これは開発全体の工程順序とは別の、実装時の作業手順です。
Canon TDDの5手順とテストリスト
TDDの定義が人によって食い違ったまま批判されている状況を受けて、Beck 氏は2023年12月11日に「Canon TDD」という記事で手順を改めて定義しました。要約すると次の5手順です。
- 扱いたいテストシナリオのリスト(テストリスト)を書く
- リストの項目をちょうど1つだけ、具体的で実行できるテストにする
- そのテストと既存の全テストが通るようにコードを変える(途中で気づいた項目はリストに足す)
- 必要なら、実装の設計を改善するためにリファクタリングする
- リストが空になるまで2に戻る
Red・Green・Refactor は手順2〜4に当たります。見落とされやすいのは手順1と「ちょうど1つだけ」という制約です。テストリストは仕様の分析そのもので、ここで境界値や異常系を洗い出しておけば、以降は1本ずつ消化するだけになります。
Red・Green・Refactorを回す手順(pytestの実行例)
題材は「注文の合計金額から送料を返す関数」です。仕様は、合計5,000円以上なら送料無料、それ未満は550円、合計が0円以下なら入力エラーとします。最初のテストリストは次の3行です。
- 合計4,999円なら送料550円
- 合計5,000円なら送料0円
- 合計0円以下は例外
出力は Python 3.14.6 と pytest 9.2.0(2026年10月27日リリース、Python 3.10以上が必要)で実行したものです。pytest の書き方全般はpytestの使い方と書き方で扱っています。
Red:失敗するテスト1本の作成
リストの1行目だけをテストにします。shipping.py には raise NotImplementedError だけを書いた関数を置き、テストが想定どおりの理由で失敗することを確かめます。
# test_shipping.py
from shipping import shipping_fee
def test_合計4999円なら送料550円():
assert shipping_fee(4999) == 550
$ pytest -q
FAILED test_shipping.py::test_合計4999円なら送料550円 - NotImplementedError
1 failed in 0.05s
一度も失敗を見ないまま先へ進むと、テスト側の書き間違いで常に通ってしまうケースに気づけません。失敗の理由が想定どおりかを読むところまでが Red です。
Green:仮実装と三角測量による最小の実装
最初の Green は、定数を返すだけの仮実装(Fake It)で構いません。
# shipping.py
def shipping_fee(total):
return 550
これで1本目は通ります。リストの2行目をテストにすると、仮実装では通らなくなります。
$ pytest -q
.F
def test_合計5000円なら送料無料():
> assert shipping_fee(5000) == 0
E assert 550 == 0
E + where 550 = shipping_fee(5000)
1 failed, 1 passed in 0.06s
2つ目の例で仮実装を崩し、一般化した実装を引き出すやり方を三角測量(Triangulation)と呼びます。ここで初めて if total >= 5000 の分岐を書きます。答えが最初から明らかなら、仮実装を飛ばして直接書く「明白な実装(Obvious Implementation)」を選んで構いません。仮実装は、次の一手に自信が持てないときに歩幅を小さくするための選択肢です。3行目の例外も、pytest.raises(ValueError) のテストで失敗を確認してから raise ValueError を足します。
Refactor:テストを通したままの重複と名前の整理
リファクタリングは、外から見た振る舞いを変えずに内部の構造を改善することです。テストがすべて通っている状態でだけ行い、変更のたびにテストを流します。Beck 氏は Canon TDD で、テストを通す作業にリファクタリングを混ぜることを典型的な誤りに挙げています。ここではマジックナンバーを定数に出し、テストを parametrize の表にまとめ、すでに通る実装を確かめる行として1円・12,000円・-1円を加えました。
# shipping.py
FREE_SHIPPING_THRESHOLD = 5_000
FLAT_FEE = 550
def shipping_fee(total: int) -> int:
if total <= 0:
raise ValueError(f"合計金額は1円以上: {total}")
if total >= FREE_SHIPPING_THRESHOLD:
return 0
return FLAT_FEE
# test_shipping.py
import pytest
from shipping import shipping_fee
@pytest.mark.parametrize(
("total", "expected"),
[
(1, 550),
(4_999, 550),
(5_000, 0),
(12_000, 0),
],
)
def test_送料は5000円を境に無料になる(total, expected):
assert shipping_fee(total) == expected
@pytest.mark.parametrize("total", [0, -1])
def test_合計が0円以下なら例外(total):
with pytest.raises(ValueError):
shipping_fee(total)
$ pytest -q
...... [100%]
6 passed in 0.04s
このテストが境界を守っているかは、実装をわざと壊して確かめられます。total >= FREE_SHIPPING_THRESHOLD を > に書き換えると、5,000円のケース1本だけが落ちました(1 failed, 5 passed)。表から5,000円の行を消して同じ変更を流すと、5本すべてが通ります。12,000円のように境界から離れた値だけでは、この誤りは素通りするということです。実装を変えてテストが検出するかを測る手法はミューテーションテストとして体系化されています。
JavaでTDDを回す手順(JUnit 6.1とMaven)
Maven・Gradleの依存設定とJUnit 6の要件
Javaでは JUnit が事実上の標準です。2025年9月30日の JUnit 6.0.0 で最低要件が Java 17 に上がり、2026年10月時点の最新安定版は6.1.3(2026年8月7日リリース)です。テストの実行環境をJava 11以前に保つ場合はJUnit 5.14系を利用します。JUnit 6は実行時にJava 17以上を必要としますが、以前のJDKでコンパイルしたコードもテストできます。JUnit 自体の書き方はJUnitとは?Javaの単体テストの書き方にまとめています。
Maven では、次の dependency を pom.xml の dependencies に、plugin を build の plugins に追加します(検証では maven.compiler.release を17、ソースの文字コードを UTF-8 に設定)。JUnit のビルド設定ドキュメントによると、JUnit 6.0以降は Maven Surefire(結合テスト用の Failsafe も同じ)3.0.0以上が必要です。今回は2026年10月26日リリースの Surefire 3.6.1 を指定しました。
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.3</version>
<scope>test</scope>
</dependency>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.1</version>
</plugin>
Gradle(Kotlin DSL)では、同ドキュメントの例どおり BOM で版を揃え、junit-platform-launcher を実行時依存に足し、テストタスクで useJUnitPlatform() を有効にします。
dependencies {
testImplementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform()
}
mvn testで読むRedの出力(ErrorsとFailures)
Python と同じ1本目のテストを書き、本体は throw new UnsupportedOperationException("未実装") だけにして mvn test を流します。Temurin 25.0.4.1、Maven 3.9.11 での出力です。
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
[ERROR] shop.ShippingTest.合計4999円なら送料550円 -- Time elapsed: 0.102 s <<< ERROR!
java.lang.UnsupportedOperationException: 未実装
仮実装 return 550; で1本目を通し、2本目の「5,000円なら0円」を足すと、今度は Failures に数えられます。
[ERROR] shop.ShippingTest.合計5000円なら送料無料 -- Time elapsed: 0.038 s <<< FAILURE!
org.opentest4j.AssertionFailedError: expected: <0> but was: <550>
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
Surefire は、アサーションの不一致を Failures、テスト中に投げられたそれ以外の例外を Errors として分けて数えます。Red で確かめるのは、想定した理由で失敗していることです。未実装の例外による Errors も Red ですが、期待値の不一致による Failures まで進めば、assert が仕様どおりに書けていることも確かめられます。設定ミスや準備コードの例外による Errors は、実装に進む前に解消します。本体のメソッドが存在しないなどでコンパイルが通らない段階も Red の一種ですが、テストは1本も実行されず、出力は COMPILATION ERROR になります。
時刻依存ロジックのClock注入と月境界の検証
「12月は3,000円以上で送料無料」のように現在時刻で結果が変わるロジックは、そのままでは実行する日によってテストの結果が変わり、Red を再現できません。Java では LocalDate.now() を直接呼ばず、java.time.Clock をコンストラクタで受け取ります。Clock の Javadoc も、この抽象化の主な目的を、必要に応じて別の時計を差し込めるようにすることだと説明しています。
public final class Shipping {
static final int FREE_SHIPPING_THRESHOLD = 5_000;
static final int SALE_THRESHOLD = 3_000;
static final int FLAT_FEE = 550;
private final Clock clock;
public Shipping(Clock clock) {
this.clock = clock;
}
public int fee(int total) {
if (total <= 0) {
throw new IllegalArgumentException("合計金額は1円以上: " + total);
}
return total >= threshold() ? 0 : FLAT_FEE;
}
private int threshold() {
LocalDate today = LocalDate.now(clock);
return today.getMonthValue() == 12 ? SALE_THRESHOLD : FREE_SHIPPING_THRESHOLD;
}
}
class ShippingTest {
private static final ZoneId TOKYO = ZoneId.of("Asia/Tokyo");
private static Shipping at(String dateTime) {
return new Shipping(Clock.fixed(OffsetDateTime.parse(dateTime).toInstant(), TOKYO));
}
@ParameterizedTest
@CsvSource({"1, 550", "4999, 550", "5000, 0", "12000, 0"})
void 通常期間は5000円以上で送料無料(int total, int expected) {
assertEquals(expected, at("2026-11-30T23:59:59+09:00").fee(total));
}
@ParameterizedTest
@CsvSource({"2999, 550", "3000, 0"})
void 十二月は3000円以上で送料無料(int total, int expected) {
assertEquals(expected, at("2026-12-01T00:00:00+09:00").fee(total));
}
@Test
void 合計が0円なら例外() {
assertThrows(IllegalArgumentException.class, () -> at("2026-11-30T12:00:00+09:00").fee(0));
}
}
import 文を除いたこの構成で Tests run: 7, Failures: 0 になりました。試しにテストの ZoneId を UTC に変えると、12月1日0時(日本時間)は UTC ではまだ11月30日15時なので、3,000円のケースだけが expected: <0> but was: <550> で落ちます。本番で Clock.systemUTC() を渡すと、セールの開始が9時間遅れるのと同じ誤りです。この仕様なら、12月1日0時の開始境界に加え、1月1日0時に通常の送料へ戻る終了境界もテストリストに入れます。Python でも、現在時刻を引数で受け取る形にすれば同じことができます。
テストファースト・BDD・単体テストとの違い
TDDと混同されやすい用語は、何を先に決めるか、誰が読むかで区別できます。
| 用語 | 先に書くもの | 主な読み手 | TDDとの関係 |
|---|---|---|---|
| テストファースト | テスト | 開発者 | TDDの一部(順序だけ) |
| TDD | 失敗するテスト1本 | 開発者 | 順序+リファクタリング |
| BDD | 振る舞いのシナリオ | 開発者・業務担当 | TDDから派生 |
| 単体テスト | 決まっていない | 開発者 | TDDが生む成果物 |
| 工程の最後のテスト | 設計書と実装 | テスト担当 | 順序が逆 |
テストファーストは「実装より先にテストを書く」という順序の約束で、TDDはそこに「1本ずつ」「最小の実装」「通った状態でのリファクタリング」を加えたものです。テストを先に書いても、全部まとめて書いてから実装に入るならTDDの循環にはなりません。
BDD(振る舞い駆動開発)は、TDDのテストを業務担当者とも共有できる「振る舞いの仕様」として書く方向に発展させた手法で、Given・When・Then の形式で書いたシナリオを Cucumber などで実行します。詳しくはBDDの仕組みとTDDとの違いで扱っています。単体テストは粒度の名前で、書く順序とは無関係です。実装後に書いた単体テストも単体テストですが、TDDではありません。単体・結合・E2Eの配分はテストピラミッドの考え方で決めます。
TDDのメリットとデメリット(研究データでみる効果とコスト)
欠陥密度の低下と開発時間の増加
実プロジェクトで効果を測った代表的な研究が、Nagappan 氏らが2008年に Empirical Software Engineering 誌で発表した、Microsoft の3チームと IBM の1チームの事例研究です。TDDを採用した4製品は、採用しなかった類似プロジェクトと比べてリリース前の欠陥密度が40〜90%低く、その代わり初期の開発時間が15〜35%増えたと報告されています(工数増は各チームの管理者による主観的な見積もり)。
対象は4チームの事例で、著者自身も結果を他の環境へ一般化できるとは前提にできないと述べています。少なくともこの研究では、TDDは初期の工数を増やして欠陥を減らす方向に働いており、開発を速くする手法としては報告されていません。保守期間が短く、リリース後の修正がほとんど起きないコードでは、その工数を回収できる余地は小さくなります。
テストが仕様書として残る効果
先の test_送料は5000円を境に無料になる は、それ自体が「5,000円ちょうどは無料側に入る」という仕様の記述です。文章の仕様書は、そのままでは実装との一致を自動判定できませんが、テストはCIで実行するたびに、実装が期待値から外れたことを失敗として示します。テスト名に条件と期待結果を書き、1本で1つの振る舞いだけを確かめると、テスト一覧が仕様の目次として読めます。
モックの多用で保守が重くなる失敗パターン
外部依存を何でもモックに置き換えると、テストが実装の内部手順に縛られます。メソッドの呼び出し回数や順序まで検証するテストは、振る舞いを変えないリファクタリングでも落ちるので、Refactor の段階が回らなくなります。モックはデータベースや外部APIなど遅い・不安定な境界に絞り、ドメインロジックは実物のオブジェクトで検証するのが出発点です。種類と使い分けはモックとスタブの違いにまとめています。
2014年の公開対談「Is TDD Dead?」でも、テストしやすさのために間接層を増やしすぎて設計が複雑になる「テストによる設計損傷(test-induced design damage)」が論点になりました。TDDを捨てるかどうかではなく、モックの範囲とテストの粒度を選ぶ議論として読むのが実務的です。
遅いテストでサイクルが止まる失敗パターン
Red・Green・Refactorを短い間隔で回すには、作業対象の単体テストを数秒程度で実行できる構成を目指します。単体テストのたびにデータベースへ接続したり大量の初期データを投入したりすると、1回の実行が分単位になり、Refactor のたびにテストを流す約束が崩れます。上の送料計算の例は6本で0.04秒でした。遅くなったら、遅い依存を境界の外へ出してロジックだけを単体で検証できる形に直し、外部との接続は結合テストに分けて回します。
カバレッジの数値目標に寄せる危険
行カバレッジは行の実行有無を、分岐カバレッジは分岐先の実行有無を示しますが、どちらも期待結果を適切に検証したかどうかは測れません。shipping_fee(5000) を呼ぶだけで結果を確かめないテストでも、カバレッジは下がりません。TDDに必要なカバレッジの基準は無く、TDDで書いた結果として高くなるものです。数値目標を置くなら、期待値の検証を伴っているかをレビューで併せて確認します。読み方はテストカバレッジの網羅率と目標設定を参照してください。
TDDが向くシステムと採用を見送る条件
TDDが最も効くのは、入力と出力の対応がはっきりしていて、分岐と境界値が多いロジックです。料金計算、税・送料・ポイントの計算、権限判定、状態遷移、パーサーやバリデーションがこれに当たります。長く保守され、複数人が触るモジュールでは、テストが仕様の共有手段にもなります。
次の場面では、TDDを採用しない、または対象を絞る判断のほうが妥当です。
- 数日で捨てる検証用のスパイク(試作)。仕様が決まっていないのでテストリストを書けない
- 画面の配色や余白を試行錯誤している段階。期待結果がまだ決まっていない
- 外部サービスの挙動を調べている段階。まず探索的に動かし、仕様が固まってからテストを書く
- スレッドの実行順に依存する並行処理。同じテストを流しても毎回は失敗しないので、Red を信用できない。並行処理の部分は薄く保ち、計算ロジックだけを切り出してTDDで書く
既存コードとチームにTDDを導入する順序
テストの無い既存コードへの特性テスト導入
データベースや外部サービスを直接呼び出す密結合なコードには、いきなりTDDを適用できません。テストを書くには依存を差し替えられる構造が必要で、その構造に変えるにはテストが必要だからです。Michael Feathers 氏は『レガシーコード改善ガイド』(原題 Working Effectively with Legacy Code、邦訳は翔泳社・2009年)で、この循環を断つために、コードがいま実際にどう振る舞うかを記録する特性テスト(characterization test)を先に書くことを勧めています。
- 修正頻度が高く、障害の多いモジュールを1つ選ぶ
- そのモジュールに特性テストを書き、現在の出力をそのまま期待値にする(正しいかどうかは問わない)
- 特性テストが通る状態で、依存を引数やインターフェース経由に差し替える最小のリファクタリングをする(上の Clock 注入もその1つ)
- 以降、そのモジュールへの修正とバグ修正だけをTDDで行う。バグ修正は、まずバグを再現するテストを Red にしてから直す
特性テストは既存のバグもそのまま固定します。見つけたバグはその場で直さずテストリストに記録し、1本ずつ Red から直します。大きなシステムを機能単位で置き換えるなら、ストラングラーフィグパターンと組み合わせ、新しく書く側だけをTDDにする進め方もあります。
チームで定着させる進め方
チームで始めるときは、全員に一斉に義務づけるより、効果が見えやすい場面から広げるほうが続きます。最初の対象はバグ修正が向いています。「バグを再現するテストを先に Red にする」というルールなら、テストを書く理由が明確で、同じバグの再発も防げます。
- プルリクエストごとにCIでテストを流し、通らない変更はマージできないようにする(GitHub ならブランチ保護のステータスチェック)
- 実装コードより先にテストリストとテスト名をレビューする。仕様の読み違いはここで見つかる
- 慣れるまではペアプログラミングで、片方がテスト、もう片方が実装を書く役割を交代しながら回す
CIの仕組みづくりから外部に任せるなら、DevOps・CI/CD導入支援でパイプラインと運用ルールを一緒に整える方法もあります。
生成AIにコードを書かせるときのTDDの位置づけ
生成AIでよく見かけるのが、「先にテストを全部AIに書かせ、それを通す実装もAIに書かせる」進め方です。テストが先にある点はTDDと同じですが、Canon TDD の手順2の「ちょうど1つだけ」を外しているので、TDDとは別物です。一括で作ったテストを途中で見直さないまま進めると、仕様の読み違いが複数のテストに同じ形で入り込み、それを通す実装も同じ誤りを抱えます。
AIに任せる場合でも、テストリストを人が書き、AIには1項目ずつテストと実装を作らせ、Red を実際に見てから Green に進ませるほうがTDDの利点を保てます。Anthropic の Claude Code ドキュメント(Best practices)も「Give Claude a check to run」の節で、テストファイル、ビルドの終了コード、リンター、モックアップと並べたブラウザのスクリーンショットなど、合否が返る検証手段を渡すよう勧めています。理由として挙げているのは、Claude は作業が完成に見えた時点で止まるという点です。
逆向きの使い方、つまり実装を先にAIに書かせてからテストを生成させると、実装の誤りがそのまま期待値に写り、誤ったテストが通ります。その仕組みと対策はAIテスト自動化で期待値に誤りが写る問題で扱っています。要求仕様を起点にする仕様駆動開発と組み合わせるなら、仕様の受け入れ条件をテストリストの元にすると、両者の役割が重なりません。
よくある質問
TDDは何の略で、提唱者は誰ですか?
Test-Driven Development の略で、日本語ではテスト駆動開発です。Kent Beck 氏が2002年の著書『Test-Driven Development: By Example』で体系化しました。
テストファーストとTDDの違いは何ですか?
テストファーストは「実装より先にテストを書く」という順序だけを指します。TDDはそれに、テストを1本ずつ書く、最小の実装で通す、通った状態でリファクタリングする、という循環を加えた手法です。
仮実装は英語で何と呼びますか?
Fake It です。Beck 氏の『Test-Driven Development: By Example』では、Red から Green へ進む手法の1つとして紹介されています。同じ本で、2つ目の例から一般化する手法は Triangulate(三角測量)、最初から本物の実装を書く手法は Obvious Implementation(明白な実装)です。
TDDのサイクルは1周どのくらいの時間ですか?
決まった時間はありませんが、1本のテストを Red から Green にするまでは数分程度で回すのが一般的です。何を書けば通るのか見通せないまま Red が続くなら歩幅が大きすぎるので、テストを小さく分けるか、仮実装から入り直します。
「TDD is dead」と言われるのは本当ですか?
2014年の David Heinemeier Hansson 氏の記事と、その後の公開対談「Is TDD Dead?」に由来する言葉で、手法の廃止を意味するものではありません。Kent Beck 氏は2023年に Canon TDD として手順を改めて定義しており、テストを先に置く手順は、生成AIの出力を受け入れる基準づくりにも応用できます。