はじめに
本記事はテスト駆動開発 Advent Calendar 2021の1日目の記事です。このアドベントカレンダーはスカスカなので、テスト駆動開発の経験談などをエントリーしてもらえると嬉しいです!
目次
本記事で伝えたいこと
TDDをやっている皆さんは、下記のサイクルをご存知の方が多いと思います。
この中の「4. 目的のコードを書く」がうまくいかない時に、「3.そのテストを実行して失敗させる」よりも前まで戻ることがよくあるよ、というお話です。
今回のお題
みんな大好き、FizzBuzzを例にして説明します。
この問題において、例えば、以下のテストケースを最初に書いたとします。(サイクル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
上の図で、今回のように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」を選択することで、インライン化できます。
結果は下記になります。
//FizzBuzz.java public class FizzBuzz { public int calculateWithParam(int inputValue) { return 1; } }
以上により、テストを成功に保ったまま、引数を指定した状態に修正することができました。
おわりに
今回は、TDDサイクルを進めるだけでなく、時には戻った方が良いということを、FizzBuzzを使って説明しました。
以前のTDDBCでtwadaさんがライブコーディングをしていました*4が、あまりにもスムーズすぎて、どんどんTDDは前に進むものだと思っている人がいましたら、こういったケースもあるよと覚えてもらえればと思います。