TDDサイクルは戻ることも大事

はじめに

本記事はテスト駆動開発 Advent Calendar 2021の1日目の記事です。このアドベントカレンダーはスカスカなので、テスト駆動開発経験談などをエントリーしてもらえると嬉しいです!

qiita.com

目次

本記事で伝えたいこと

TDDをやっている皆さんは、下記のサイクルをご存知の方が多いと思います。

f:id:nihonbuson:20211201110545p:plain
見てわかるテスト駆動開発 / TDD Live and Workshop 2019 Spring - Speaker Deckより引用

この中の「4. 目的のコードを書く」がうまくいかない時に、「3.そのテストを実行して失敗させる」よりも前まで戻ることがよくあるよ、というお話です。

今回のお題

みんな大好き、FizzBuzzを例にして説明します。

f:id:nihonbuson:20211201111210p:plain
見てわかるテスト駆動開発 / TDD Live and Workshop 2019 Spring - Speaker Deckより引用

この問題において、例えば、以下のテストケースを最初に書いたとします。(サイクル1周目の「3.そのテストを実行して失敗させる」)

//FizzBuzzTest.java
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class FizzBuzzTest {
    @Test
    void _1の場合1が出力される() {
        FizzBuzz fizzbuzz = new FizzBuzz();
        int actual = fizzbuzz.calculate();
        assertEquals(1, actual);
    }
}

それに対して、実装は次のようになりました。(サイクル1周目の「5. 2で書いたテストを成功させる」)

//FizzBuzz.java
public class FizzBuzz {
    public int calculate() {
        return 1;
    }
}

このテストケースは成功します。やったね!

特にRefactoringができそうな場所が無いので、次の失敗するテストケースを作成します。(サイクル2周目の「3.そのテストを実行して失敗させる」)

//FizzBuzzTest.java
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class FizzBuzzTest {
    @Test
    void _1の場合1が出力される() {
        FizzBuzz fizzbuzz = new FizzBuzz();
        int actual = fizzbuzz.calculate();
        assertEquals(1, actual);
    }
    @Test
    void _2の場合2が出力される() {
        FizzBuzz fizzbuzz = new FizzBuzz();
        int actual = fizzbuzz.calculate();
        assertEquals(2, actual);
    }
}

さて、これを実装しよう(サイクル2周目の「4. 目的のコードを書く」)と思った時に、大きな過ちに気付きます。入力する引数を指定していなかったのです!*1

このような場合、とりうる方法は大きく分けて2パターン、細かく分けると3パターンあります。

  • 【パターン1】無理やりにでも、Greenまで持っていく(サイクル2周目の「5. 2で書いたテストを成功させる」まで進める)
  • 【パターン2-1】一旦、現在やっているRedのテストケースを無くして、リファクタリングをして対応できる形に整える。(サイクル1周目の「6. テストが通るままでリファクタリングを行う」まで戻る)
  • 【パターン2-2】一旦、現在やっているRedのテストケースを無くして、今回の過ちを補うテストケースを次の目標として考え直す。(サイクル2周目の「1. 次の目標を考える」まで戻る)

つまり、TDDサイクルを前に進み続けるのか、一旦戻るのかでパターン分けがされます。そして、進み続ける場合(【パターン1】の場合)、「既存の振る舞いの変更に対応する」「新規のテストに対応する」を両方を同時に行うことになるので、行き詰まる可能性が高いです。*2

今回は、【パターン2-2】一旦、現在やっているRedのテストケースを無くして、今回の過ちを補うテストケースを次の目標として考え直す。(サイクル2周目の「1. 次の目標を考える」まで戻る)で対応してみたいと思います。

新しいテストケースを考えて対応する

サイクル2周目の「3.そのテストを実行して失敗させる」

「入力する引数を指定していなかった」という問題点に気付いたのですから、「入力する引数を指定した」テストケースを新たに作ることにします。

//FizzBuzzTest.java
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class FizzBuzzTest {
    @Test
    void _1の場合1が出力される() {
        FizzBuzz fizzbuzz = new FizzBuzz();
        int actual = fizzbuzz.calculate();
        assertEquals(1, actual);
    }
    @Test
    void _1を入力した場合1が出力される() {
        FizzBuzz fizzbuzz = new FizzBuzz();
        int actual = fizzbuzz.calculateWithParam(1);
        assertEquals(1, actual);
    }

}

これによって、今までのテストケースを残したまま、入力する引数を指定するテストケースを作りました。

サイクル2周目の「5. 2で書いたテストを成功させる」

次に、これを通す実装コードを作成します。

//FizzBuzz.java
public class FizzBuzz {
    public int calculate() {
        return 1;
    }
    public int calculateWithParam(int inputValue) {
        return 1;
    }
}

このテストケースは成功します。

サイクル2周目の「6. テストが通るままでリファクタリングを行う」

次にリファクタリングをします。

関数を呼び出す形に変更する

ここまで作成した2つは両方とも出力値が一緒(return 1;)なので、もう一方の関数を呼び出す形にできるはずです。つまり、下記のようになります。

//FizzBuzz.java
public class FizzBuzz {
    public int calculate() {
        return 1;
    }
    public int calculateWithParam(int inputValue) {
        return calculate();
    }
}

calculateWithParamメソッドのreturnでcalculate()メソッドを呼び出すようにしました。

コードを変更したらすぐにテスト実行し、テストが成功したままになっていることを確認します。

テストケースを1つ削除する

ここまでの変更によって、メソッドの呼び出しの関係図は以下のようになります。*3

f:id:nihonbuson:20211201115311p:plain
メソッド呼び出しの図解

上の図で、今回のようにcalculateWithParam()メソッドがcalculate()メソッドの呼び出し以外何もしていなければ、既存の2つのテストケースはほぼ同じことを表している状態になります。

よって、テストケースを1つ消すことができる判断を下せるでしょう。

//FizzBuzzTest.java
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class FizzBuzzTest {
    @Test
    void _1を入力した場合1が出力される() {
        FizzBuzz fizzbuzz = new FizzBuzz();
        int actual = fizzbuzz.calculateWithParam(1);
        assertEquals(1, actual);
    }

}

もちろん、ここでもテスト実行し、テストが成功したままになっていることを確認します。

メソッドをインライン化する

実装コードのうち、calculate()メソッドは、calculateWithParam()メソッド内でのみ呼び出されています。

そこで、メソッドのインライン化をします。

例えば、IntelliJの場合、該当のメソッド呼び出し部分の上で、「Refactor -> Inline Method」を選択することで、インライン化できます。

f:id:nihonbuson:20211201120121p:plain
インライン化(IntelliJの場合)

結果は下記になります。

//FizzBuzz.java
public class FizzBuzz {

    public int calculateWithParam(int inputValue) {
        return 1;
    }
}

以上により、テストを成功に保ったまま、引数を指定した状態に修正することができました。

おわりに

今回は、TDDサイクルを進めるだけでなく、時には戻った方が良いということを、FizzBuzzを使って説明しました。

以前のTDDBCでtwadaさんがライブコーディングをしていました*4が、あまりにもスムーズすぎて、どんどんTDDは前に進むものだと思っている人がいましたら、こういったケースもあるよと覚えてもらえればと思います。

*1:1つ目のテストケースも2つ目のテストケースもfizzbuzz.calculate()メソッドを呼んでおり、出力が変わる余地がありません

*2:今回のFizzBuzzのような簡単な例の場合は【パターン1】でも切り抜けられますが、実業務のコードを想像してください。【パターン1】がより困難になることが想像できるでしょう。

*3:ちゃんとした記法で描かれた図ではないです。イメージだけ掴んでもらえれば……

*4:www.youtube.com