はじめに

TDD(テスト駆動開発)を学び始めた方が最初にぶつかる壁は、「Red-Green-Refactorサイクルをどう回せばよいのか」という実践面の疑問ではないでしょうか。

Red-Green-Refactorは、TDDの心臓部とも呼べる開発サイクルです。Martin Fowler氏は「このサイクルを繰り返すことがTDDの本質である」と述べており、Kent Beck氏が体系化した『Test-Driven Development: By Example』でもこの3ステップが中核として扱われています。

この記事では、Red-Green-Refactorサイクルの各フェーズを深掘りし、具体的なコード例とともに実践テクニックを解説します。また、多くの開発者が陥りがちな失敗パターンとその回避策も紹介します。

Red-Green-Refactorサイクルとは

Red-Green-Refactorサイクルは、TDDにおける開発の基本単位です。以下の3つのフェーズを繰り返すことで、テストに守られた高品質なコードを段階的に構築していきます。

flowchart TB
    subgraph cycle["Red-Green-Refactorサイクル"]
        R["Red<br/>失敗するテストを書く"]
        G["Green<br/>テストを通す最小限のコード"]
        RF["Refactor<br/>コードを改善する"]
    end
    
    R --> G
    G --> RF
    RF --> R
    
    style R fill:#ffcccc,stroke:#cc0000,color:#000000
    style G fill:#ccffcc,stroke:#00cc00,color:#000000
    style RF fill:#cce5ff,stroke:#0066cc,color:#000000
フェーズ 目的 所要時間の目安
Red 失敗するテストを書き、要件を明確化する 1-3分
Green テストを通す最小限のコードを書く 1-5分
Refactor コードの品質を改善する 2-10分

重要なのは、このサイクルを小さく速く回すことです。1サイクルは数分から長くても15分程度で完了することが理想とされています。

サイクルを始める前に: テストリストの作成

Kent Beck氏は、Red-Green-Refactorサイクルに入る前にテストリストを作成することを推奨しています。これはTDDの重要な初期ステップであり、実装の全体像を把握するためのロードマップとなります。

テストリストの作成例

例として、割り勘計算機能のテストリストを考えてみましょう。

1
2
3
4
5
6
7
割り勘計算機能のテストリスト:
- [ ] 合計金額を人数で割った結果を返す(10000円 / 4人 = 2500円)
- [ ] 端数が出る場合は切り上げる(10000円 / 3人 = 3334円)
- [ ] 人数が1人の場合は合計金額をそのまま返す
- [ ] 人数が0以下の場合はエラーを返す
- [ ] 合計金額が負の場合はエラーを返す
- [ ] 合計金額が0の場合は0を返す

このリストを順番に処理していくことで、機能を段階的に実装できます。テストリストは固定ではなく、開発中に新たなケースが見つかれば追加していきます。

Redフェーズ: 失敗するテストを書く

Redフェーズは、まだ存在しない機能に対するテストを書くフェーズです。テストを実行すると当然失敗し、テストランナーは赤いエラー表示(Red)を出します。

Redフェーズの目的

Redフェーズには3つの重要な目的があります。

  1. 要件の明確化: テストを書くことで「何を実装すべきか」が明確になる
  2. インターフェースの設計: テスト対象の呼び出し方を先に決める
  3. 失敗の確認: テストが正しく機能することを確認する

実践: Redフェーズのコード例

先ほどのテストリストから、最初のテストケースを実装します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// JUnit 5での例
class SplitBillCalculatorTest {

    @Test
    @DisplayName("合計金額を人数で割った結果を返す")
    void shouldDivideTotalAmountByNumberOfPeople() {
        // Given: 合計10,000円、4人で割り勘
        SplitBillCalculator calculator = new SplitBillCalculator();
        
        // When: 1人あたりの金額を計算
        int perPerson = calculator.calculate(10000, 4);
        
        // Then: 2,500円になる
        assertEquals(2500, perPerson);
    }
}

この時点ではSplitBillCalculatorクラスもcalculateメソッドも存在しないため、コンパイルエラーになります。これが正しいRedの状態です。

Redフェーズのベストプラクティス

Redフェーズで守るべき原則を紹介します。

1. 一度に1つの振る舞いのみをテストする

悪い例(複数の振る舞いをテスト):

1
2
3
4
5
6
7
8
@Test
void shouldCalculateCorrectly() {
    // 複数のケースを1つのテストに詰め込んでいる
    assertEquals(2500, calculator.calculate(10000, 4));
    assertEquals(3334, calculator.calculate(10000, 3)); // 端数処理
    assertThrows(IllegalArgumentException.class, 
        () -> calculator.calculate(10000, 0)); // エラー処理
}

良い例(1つの振る舞いに集中):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Test
void shouldDivideTotalAmountByNumberOfPeople() {
    assertEquals(2500, calculator.calculate(10000, 4));
}

@Test
void shouldRoundUpWhenThereIsRemainder() {
    assertEquals(3334, calculator.calculate(10000, 3));
}

@Test
void shouldThrowExceptionWhenNumberOfPeopleIsZero() {
    assertThrows(IllegalArgumentException.class, 
        () -> calculator.calculate(10000, 0));
}

2. テスト名は振る舞いを説明する

テスト名は「何をテストしているか」が一目でわかるように記述します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 悪い例
@Test
void test1() { ... }

@Test
void testCalculate() { ... }

// 良い例
@Test
void shouldReturnZeroWhenTotalAmountIsZero() { ... }

@Test
void shouldThrowExceptionWhenNumberOfPeopleIsNegative() { ... }

3. 失敗を必ず確認する

テストを書いたら、実装前に必ず実行して失敗することを確認します。これにより、テストが正しく機能していることを検証できます。もしテストが最初から成功してしまう場合、以下のいずれかの問題があります。

  • テストの書き方が間違っている
  • すでに機能が実装されている
  • テスト自体が何もテストしていない

Redフェーズでよくある失敗パターン

失敗パターン 問題点 改善策
テストを書かずに実装を始める TDDのメリットが得られない 必ずテストを先に書く習慣をつける
一度に多くのテストを書く フィードバックサイクルが長くなる 1つずつテストを追加する
テストの失敗を確認しない テストが正しく機能しているか不明 実装前に必ず実行して赤くなることを確認
複雑すぎるテストを書く Greenフェーズが困難になる 最小のテストから始める

Greenフェーズ: テストを通す最小限のコードを書く

Greenフェーズは、テストを通すための最小限のコードを書くフェーズです。ここで重要なのは「最小限」という点であり、完璧なコードを書く必要はありません。

Greenフェーズの目的

Greenフェーズの目的は明確です。

  1. テストを通すことだけに集中する: コードの美しさは後回し
  2. 動くコードを素早く作る: フィードバックサイクルを短く保つ
  3. YAGNI原則を守る: 今必要なものだけを実装する

実践: Greenフェーズのコード例

先ほどのテストを通すための最小限の実装を書きます。

1
2
3
4
5
6
public class SplitBillCalculator {
    
    public int calculate(int totalAmount, int numberOfPeople) {
        return 2500; // ハードコードでテストを通す
    }
}

この実装は明らかに不完全ですが、テストは通ります。これが正しいGreenの状態です。

「こんな実装で意味があるのか」と疑問に思うかもしれません。しかし、この段階ではテストとコードの両方が正しく連携していることを確認することが目的です。

三角測量(Triangulation)

1つのテストだけでは、ハードコードで通せてしまいます。そこで、2つ目のテストを追加することで、より一般的な実装を強制します。これを三角測量と呼びます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Test
@DisplayName("合計金額を人数で割った結果を返す - ケース1")
void shouldDivideTotalAmountByNumberOfPeople_case1() {
    assertEquals(2500, calculator.calculate(10000, 4));
}

@Test
@DisplayName("合計金額を人数で割った結果を返す - ケース2")
void shouldDivideTotalAmountByNumberOfPeople_case2() {
    assertEquals(2000, calculator.calculate(10000, 5));
}

2つ目のテストを追加すると、ハードコードでは対応できなくなり、一般的な実装が必要になります。

1
2
3
4
5
6
public class SplitBillCalculator {
    
    public int calculate(int totalAmount, int numberOfPeople) {
        return totalAmount / numberOfPeople;
    }
}

Greenフェーズのベストプラクティス

1. 最も単純な解決策を選ぶ

Greenフェーズでは、最も単純な方法でテストを通すことを優先します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 複雑すぎる(Greenフェーズでは不適切)
public int calculate(int totalAmount, int numberOfPeople) {
    if (numberOfPeople <= 0) {
        throw new IllegalArgumentException("人数は1以上である必要があります");
    }
    int base = totalAmount / numberOfPeople;
    int remainder = totalAmount % numberOfPeople;
    return remainder > 0 ? base + 1 : base;
}

// 単純(Greenフェーズに適切)
public int calculate(int totalAmount, int numberOfPeople) {
    return totalAmount / numberOfPeople;
}

2. 一歩ずつ進む

大きなジャンプを避け、小さなステップで進みます。

1
2
3
4
5
6
7
ステップ1: return 2500; (ハードコード)
    ↓ 2つ目のテスト追加
ステップ2: return totalAmount / numberOfPeople; (基本実装)
    ↓ 端数処理のテスト追加
ステップ3: 端数処理を追加
    ↓ エラー処理のテスト追加
ステップ4: バリデーションを追加

3. コンパイルエラーを素早く解消する

Redフェーズでコンパイルエラーになっている場合、まずコンパイルが通る状態にします。

1
2
3
4
5
6
// まずコンパイルを通す
public class SplitBillCalculator {
    public int calculate(int totalAmount, int numberOfPeople) {
        return 0; // 暫定的な値
    }
}

その後、テストが通るように値を修正します。

Greenフェーズでよくある失敗パターン

失敗パターン 問題点 改善策
最初から完璧なコードを書こうとする 時間がかかり、フィードバックが遅れる まずテストを通すことだけに集中
テストにないエッジケースを実装する YAGNI原則に反する テストで要求された振る舞いのみ実装
複数のテストを一度に通そうとする 問題の切り分けが困難になる 1つずつテストを通す
Greenを確認せずにRefactorに進む 壊れた状態でリファクタリングしてしまう テスト成功を必ず確認してから次へ

Refactorフェーズ: コードを改善する

Refactorフェーズは、テストが通った状態を維持しながらコードの品質を改善するフェーズです。Martin Fowler氏は「リファクタリングを怠ることがTDD失敗の最も一般的な原因」と指摘しています。

Refactorフェーズの目的

Refactorフェーズには以下の目的があります。

  1. コードの重複を排除する: DRY原則を適用
  2. 可読性を向上させる: 命名の改善、構造の整理
  3. 設計を改善する: 責務の分離、抽象化の導入
  4. 技術的負債を返済する: 蓄積した問題を解消

実践: Refactorフェーズのコード例

Greenフェーズで作成した実装をリファクタリングします。

リファクタリング前:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class SplitBillCalculator {
    
    public int calculate(int totalAmount, int numberOfPeople) {
        if (numberOfPeople <= 0) {
            throw new IllegalArgumentException("人数は1以上");
        }
        if (totalAmount < 0) {
            throw new IllegalArgumentException("金額は0以上");
        }
        int base = totalAmount / numberOfPeople;
        int remainder = totalAmount % numberOfPeople;
        if (remainder > 0) {
            return base + 1;
        }
        return base;
    }
}

リファクタリング後:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class SplitBillCalculator {
    
    public int calculate(int totalAmount, int numberOfPeople) {
        validateInputs(totalAmount, numberOfPeople);
        return calculatePerPersonAmount(totalAmount, numberOfPeople);
    }
    
    private void validateInputs(int totalAmount, int numberOfPeople) {
        if (numberOfPeople <= 0) {
            throw new IllegalArgumentException(
                "人数は1以上である必要があります");
        }
        if (totalAmount < 0) {
            throw new IllegalArgumentException(
                "合計金額は0以上である必要があります");
        }
    }
    
    private int calculatePerPersonAmount(int totalAmount, int numberOfPeople) {
        return (int) Math.ceil((double) totalAmount / numberOfPeople);
    }
}

リファクタリングでは以下の改善を行いました。

  • メソッドの抽出により責務を分離
  • 意図が明確になるメソッド名を使用
  • エラーメッセージを具体的に改善
  • Math.ceilを使用して端数処理を明確化

リファクタリングの原則

1. テストを頻繁に実行する

リファクタリング中は、小さな変更ごとにテストを実行します。

1
変更 → テスト実行 → Green確認 → 次の変更 → テスト実行 → ...

テストが赤くなったら、直前の変更を見直します。

2. 一度に1つの改善に集中する

複数の改善を同時に行うと、問題が発生したときの原因特定が困難になります。

1
2
3
4
5
6
7
8
良い進め方:
1. メソッド名を変更 → テスト実行 → Green
2. メソッドを抽出 → テスト実行 → Green
3. 定数を導入 → テスト実行 → Green

悪い進め方:
1. メソッド名変更 + メソッド抽出 + 定数導入 → テスト実行 → Red
   → どの変更が原因かわからない

3. 振る舞いを変えない

リファクタリングは外部から見た振る舞いを変えずに内部構造を改善することです。新機能の追加や仕様変更はリファクタリングではありません。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// これはリファクタリング(振る舞いは同じ)
// Before
return totalAmount / numberOfPeople;

// After
return (int) Math.floor((double) totalAmount / numberOfPeople);

// これはリファクタリングではない(振る舞いが変わる)
// Before: 切り捨て
return totalAmount / numberOfPeople;

// After: 切り上げ(新機能)
return (int) Math.ceil((double) totalAmount / numberOfPeople);

振る舞いを変える場合は、新しいRedフェーズから始めます。

コードの臭い(Code Smell)の発見

リファクタリングのタイミングを判断するために、「コードの臭い」を認識することが重要です。

コードの臭い 症状 リファクタリング手法
長すぎるメソッド 1メソッドが50行以上 メソッドの抽出
重複コード 同じロジックが複数箇所に存在 メソッドの抽出、継承の活用
長すぎるパラメータリスト 引数が5つ以上 パラメータオブジェクトの導入
マジックナンバー 意味不明な数値リテラル 定数の導入
不明確な命名 変数名やメソッド名が意図を表さない 名前の変更

Refactorフェーズでよくある失敗パターン

失敗パターン 問題点 改善策
リファクタリングをスキップする 技術的負債が蓄積する 各サイクルで必ず改善の余地を検討
大きなリファクタリングを一度に行う 問題発生時の原因特定が困難 小さなステップで進める
テストを実行せずにリファクタリング 壊れたことに気づかない 変更ごとにテストを実行
新機能追加とリファクタリングを混同 サイクルが乱れる 振る舞いを変える場合はRedから

サイクル全体の流れ: 実践例

ここまでの内容を踏まえて、Red-Green-Refactorサイクルの全体的な流れを具体例で確認します。

例題: パスワード強度チェッカー

パスワードの強度を判定する機能を実装します。

テストリスト:

1
2
3
4
5
6
パスワード強度チェッカー:
- [ ] 8文字未満は「弱い」を返す
- [ ] 8文字以上で数字を含まない場合は「普通」を返す
- [ ] 8文字以上で数字を含む場合は「強い」を返す
- [ ] 12文字以上で数字と記号を含む場合は「非常に強い」を返す
- [ ] nullまたは空文字の場合は例外を投げる

サイクル1: 8文字未満は「弱い」

Red:

1
2
3
4
5
@Test
void shouldReturnWeakWhenLessThan8Characters() {
    PasswordStrengthChecker checker = new PasswordStrengthChecker();
    assertEquals("弱い", checker.check("abc"));
}

テストを実行 → コンパイルエラー(Red)

Green:

1
2
3
4
5
public class PasswordStrengthChecker {
    public String check(String password) {
        return "弱い";
    }
}

テストを実行 → 成功(Green)

Refactor:

現時点では改善の余地がないため、次のサイクルへ進みます。

サイクル2: 8文字以上で数字なしは「普通」

Red:

1
2
3
4
@Test
void shouldReturnNormalWhenAtLeast8CharsWithoutDigit() {
    assertEquals("普通", checker.check("abcdefgh"));
}

テストを実行 → 失敗(Red): 「弱い」が返される

Green:

1
2
3
4
5
6
public String check(String password) {
    if (password.length() < 8) {
        return "弱い";
    }
    return "普通";
}

テストを実行 → 成功(Green)

Refactor:

マジックナンバーを定数に抽出します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class PasswordStrengthChecker {
    private static final int MIN_LENGTH = 8;
    
    public String check(String password) {
        if (password.length() < MIN_LENGTH) {
            return "弱い";
        }
        return "普通";
    }
}

テストを実行 → 成功(Green)

サイクル3: 8文字以上で数字ありは「強い」

Red:

1
2
3
4
@Test
void shouldReturnStrongWhenAtLeast8CharsWithDigit() {
    assertEquals("強い", checker.check("abcdefg1"));
}

テストを実行 → 失敗(Red)

Green:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public String check(String password) {
    if (password.length() < MIN_LENGTH) {
        return "弱い";
    }
    if (containsDigit(password)) {
        return "強い";
    }
    return "普通";
}

private boolean containsDigit(String password) {
    return password.matches(".*\\d.*");
}

テストを実行 → 成功(Green)

Refactor:

判定結果を列挙型に抽出して型安全性を向上させます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class PasswordStrengthChecker {
    private static final int MIN_LENGTH = 8;
    
    public enum Strength {
        WEAK("弱い"),
        NORMAL("普通"),
        STRONG("強い");
        
        private final String label;
        
        Strength(String label) {
            this.label = label;
        }
        
        public String getLabel() {
            return label;
        }
    }
    
    public String check(String password) {
        return evaluate(password).getLabel();
    }
    
    private Strength evaluate(String password) {
        if (password.length() < MIN_LENGTH) {
            return Strength.WEAK;
        }
        if (containsDigit(password)) {
            return Strength.STRONG;
        }
        return Strength.NORMAL;
    }
    
    private boolean containsDigit(String password) {
        return password.matches(".*\\d.*");
    }
}

テストを実行 → 成功(Green)

このように、小さなサイクルを繰り返すことで、テストに守られた状態で段階的に機能を実装できます。

よくある質問と回答

Q1: Redフェーズでどこまで詳細なテストを書くべきですか

最小限のテストから始めてください。1つの振る舞いを検証する小さなテストを書き、必要に応じて追加していきます。最初から完璧なテストを書こうとすると、時間がかかりすぎてサイクルが回らなくなります。

Q2: Greenフェーズでハードコードするのは本当に良いのですか

はい。ハードコードでテストを通すことで、テストとコードの連携が正しく機能していることを確認できます。その後、三角測量で2つ目のテストを追加することで、より一般的な実装に進化させます。

Q3: Refactorフェーズはどのタイミングで終わりにすべきですか

以下の基準を満たしたら次のサイクルに進みます。

  • 明らかな重複がない
  • コードが読みやすい
  • メソッドが1つの責務に集中している
  • 次のテストを追加する準備ができている

完璧を目指す必要はありません。後のサイクルでさらに改善することもできます。

Q4: テストを書く時間がもったいなく感じます

短期的にはテストを書く時間が追加されますが、長期的にはデバッグ時間の削減、安全なリファクタリング、仕様書としてのテストなど、多くのメリットがあります。Agile Allianceの報告でも、多くのチームが「初期コストを上回る効果がプロジェクト後半で得られた」と報告しています。

まとめ

Red-Green-Refactorサイクルは、TDDの心臓部となる開発プロセスです。

Redフェーズのポイント:

  • 失敗するテストを先に書く
  • 1つの振る舞いのみをテストする
  • 失敗を必ず確認する

Greenフェーズのポイント:

  • テストを通す最小限のコードを書く
  • 完璧なコードは書かない
  • 三角測量で一般化を促進する

Refactorフェーズのポイント:

  • テストが通った状態を維持しながら改善する
  • 小さなステップで進める
  • 振る舞いは変えない

このサイクルを小さく速く回すことで、テストに守られた高品質なコードを段階的に構築できます。まずは簡単な問題から始めて、Red-Green-Refactorのリズムを体感してみてください。

参考リンク