2017.07.02 編集しました。
先日投稿した準備編に引き続き、今回は実践編として FizzBuzz 問題の TDD を進めていきます。
準備編のおさらいと実践編の流れ
TDD のメリット
- 動く綺麗なコードを実現出来るため (Clean code that works)。
- 実装を終えてから、テストで出たバグの影響範囲全てを修正していくという手戻りを少なくできるため。
- 保守性の高い設計を実現出来るため (テストしやすい == 変更に強く保守性が高い、テストしにくい == 設計改善の余地あり)
FizzBuzz 問題の要件
要件 | 内容 |
---|---|
要件 1 | 引数に取った値を文字列で返す |
要件 2 | ただし、3の倍数のときは 「Fizz」 を返す |
要件 3 | ただし、5の倍数のときは 「Buzz」 を返す |
要件 4 | ただし、3と5両方の倍数の場合には 「FizzBuzz」 を返す |
要件 5 | 引数が 1 から 100 までの数でない場合、エラー |
実践編の流れ
要件 1 ~ 要件 5 まで全て紹介しては冗長になってしまうため、要件 1 のみ当記事で紹介します。プログラムの全容は記事末尾に掲載しています。
要件 1 : 引数に取った値を文字列で返す
はじめにテストが失敗することを確認する
要件 1 の実装ではまず一番初めに、テストクラス (FizzBuzzTest.java) とテスト対象クラス (FizzBuzz.java) を以下の通りに実装しました。
public class FizzBuzzTest {
@Test
public void 引数に1を与えたら1を返す() {
FizzBuzz fizzbuzz = new FizzBuzz();
assertEquals("1", fizzbuzz.response(1));
}
}
public class FizzBuzz {
// null を返す (何も値を返さない)
public String response(int num) {
return null;
}
}
FizzBuzzTest にカーソルを合わせ右クリック -> Run As… -> JUnit Test を押下し、テストを実行すると当然結果はテスト失敗を示す赤のバーが表示されます。
このテスト失敗が実はポイントです。
今回のようにテスト対象クラスを新規開発する場合は、JUnit が正しくテスト成否を判定しているか を確認することが出来ます。
また、テスト対象クラスの 既存メソッドに機能追加を行う場合、新しいテストを追加してテストが成功するようであれば、__追加機能の一部が既に実装済__である可能性を見抜けます。
テストが通るようなプログラムを実装する
次に、テストが通るようなテスト対象メソッドを実装します。
public String response(int num) {
return "1";
}
先程と同様にテストを実行すると、当然、テストは成功します。
TDD の開発では、まずはテストが通るプログラムを書くことから始めるので、慣れるまではテストケースを初めから絞らず、やや愚直でも 1 つ 1 つ目的を達成するよう積み重ねていきます。
それでは、引数が 2 の場合も試してみましょう。
@Test
public void 引数に1を与えたら1を返す() {
FizzBuzz fizzbuzz = new FizzBuzz();
assertEquals("1", fizzbuzz.response(1));
}
@Test
public void 引数に2を与えたら2を返す() {
FizzBuzz fizzbuzz = new FizzBuzz();
assertEquals("2", fizzbuzz.response(2));
}
public String response(int num) {
return "1";
}
テスト実行結果は以下の通り、テスト失敗を示す赤のバーが表示されました。
要件を満たし、テストが通るようなプログラムを実装する
追加したテスト失敗より、一般的には以下 2 つの可能性が考えられます。
- 追加した__テストプログラム__の誤り
- __テスト対象プログラム__が要件を満たしていない
ここで JUnit の実行結果を見返すと以下の記載より、実行結果が 2 となることを期待していたのに実行結果は 1 となり期待値と一致しなかったと分かります。
org.junit.ComparisonFailure: expected:<[2]> but was:<[1]>
よって、テスト対象プログラム FizzBuzz.java を 要件 : 引数に取った値を文字列で返す に沿って以下の通りに修正します。
public String response(int num) {
return String.valueOf(num);
}
テストが成功するのを確認し、 要件 1 の実装が完了です。
同様に要件 2 ~ 要件 5 まで実装し、同値クラスのテストケースを 1 つに絞った FizzBuzzTest.java と FizzBuzz.java は以下の通りです。
public class FizzBuzzTest {
private FizzBuzz fizzbuzz;
@Before
public void インスタンス生成() {
fizzbuzz = new FizzBuzz();
}
@Test
public void 引数に1を与えたら1を返す() {
assertEquals("1", fizzbuzz.response(1));
}
@Test
public void 引数3を与えたらFizzを返す() {
assertEquals("Fizz", fizzbuzz.response(3));
}
@Test
public void 引数5を与えたらBuzzを返す() {
assertEquals("Buzz", fizzbuzz.response(5));
}
@Test
public void 引数15を与えたらFizzBuzzを返す() {
assertEquals("FizzBuzz", fizzbuzz.response(15));
}
@Test(expected = IndexOutOfBoundsException.class)
public void 引数に0を与えたらエラーとなる() {
fizzbuzz.response(0);
}
@Test(expected = IndexOutOfBoundsException.class)
public void 引数に101を与えたらエラーとなる() {
fizzbuzz.response(101);
}
@Test
public void 引数に100を与えたらBuzzを返す() {
assertEquals("Buzz", fizzbuzz.response(100));
}
}
public class FizzBuzz {
public String response(int num) {
if(num < 1 || num > 100) {
throw new IndexOutOfBoundsException();
}
StringBuilder result = new StringBuilder();
if(num % 3 == 0) {
result.append("Fizz");
}
if(num % 5 == 0) {
result.append("Buzz");
}
if(result.length() == 0) {
result.append(String.valueOf(num));
}
return result.toString();
}
}
おまけ : テストクラスの構造化
上記の FizzBuzzTest.java テストメソッド名から、何を確認するテストであるかは分かるのでこのまま終わっても良いのですが、各テストメソッドがどの要件に対応しているのかをより分かりやすくするため、最後にテストクラスの構造化を行います。
テストクラスの構造化とは、 JUnit の @RunWith(Enclosed.class) アノテーションを使用し、テストのカテゴリ毎に内部クラスを設定してテストケースを分類することです。12
今回のテストは要件に沿って以下のカテゴリに分ける事ができます。
項番 | カテゴリ |
---|---|
1 | 引数が 3 と 5 の倍数でない |
2 | 引数が 3 のみの倍数 |
3 | 引数が 5 のみの倍数 |
4 | 引数が 3 と 5 両方の倍数 |
5 | 引数が無効境界値 (1 から 100 までの数でない) |
6 | 引数が有効境界値 (1 から 100 までの数) |
上記のカテゴリ毎に内部クラスを設定し、最終的に FizzBuzzTest.java を以下のように書き換えます。
@RunWith(Enclosed.class)
public class FizzBuzzTest {
public static class 引数が3と5の倍数でない {
FizzBuzz fizzbuzz = new FizzBuzz();
@Test
public void 引数に1を与えたら1を返す() {
assertEquals("1", fizzbuzz.response(1));
}
}
public static class 引数が3のみの倍数 {
FizzBuzz fizzbuzz = new FizzBuzz();
@Test
public void 引数3を与えたらFizzを返す() {
assertEquals("Fizz", fizzbuzz.response(3));
}
}
public static class 引数が5のみの倍数 {
FizzBuzz fizzbuzz = new FizzBuzz();
@Test
public void 引数5を与えたらBuzzを返す() {
assertEquals("Buzz", fizzbuzz.response(5));
}
}
public static class 引数が3と5の倍数 {
FizzBuzz fizzbuzz = new FizzBuzz();
@Test
public void 引数15を与えたらFizzBuzzを返す() {
assertEquals("FizzBuzz", fizzbuzz.response(15));
}
}
public static class 引数が無効境界値 {
FizzBuzz fizzbuzz = new FizzBuzz();
@Test(expected = IndexOutOfBoundsException.class)
public void 引数に0を与えたらエラーとなる() {
fizzbuzz.response(0);
}
@Test(expected = IndexOutOfBoundsException.class)
public void 引数に101を与えたらエラーとなる() {
fizzbuzz.response(101);
}
}
public static class 引数が有効境界値 {
FizzBuzz fizzbuzz = new FizzBuzz();
@Test
public void 引数に1を与えたら1を返す() {
assertEquals("1", fizzbuzz.response(1));
}
@Test
public void 引数に100を与えたらBuzzを返す() {
assertEquals("Buzz", fizzbuzz.response(100));
}
}
}
当然、書き換えたらテストを実行し、全て成功することを確認します。
テスト結果も、テストケースがずらりと並んでいるよりも、どの要件に対するテストケースなのかがより分かりやすくなったと思います。
以上で実践編は終わりです。
今回は比較的単純な要件に対する TDD でしたが、今後はより複雑な要件を実装する際にも是非 TDD を試し、その効果や限界を確かめていこうと思います。