LoginSignup
6
20

More than 5 years have passed since last update.

FizzBuzz 問題をテスト駆動型開発で実装する (実践編)

Last updated at Posted at 2017-06-18

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) を以下の通りに実装しました。

FizzBuzzTest.java
public class FizzBuzzTest {
    @Test
    public void 引数に1を与えたら1を返す() {
        FizzBuzz fizzbuzz = new FizzBuzz();
        assertEquals("1", fizzbuzz.response(1));
    }
}
FizzBuzz.java
public class FizzBuzz {
    // null を返す (何も値を返さない)
    public String response(int num) {
        return null;
    }
}

FizzBuzzTest にカーソルを合わせ右クリック -> Run As… -> JUnit Test を押下し、テストを実行すると当然結果はテスト失敗を示す赤のバーが表示されます。

4.JPG

このテスト失敗が実はポイントです。
今回のようにテスト対象クラスを新規開発する場合は、JUnit が正しくテスト成否を判定しているか を確認することが出来ます。

また、テスト対象クラスの 既存メソッドに機能追加を行う場合、新しいテストを追加してテストが成功するようであれば、追加機能の一部が既に実装済である可能性を見抜けます。

テストが通るようなプログラムを実装する

次に、テストが通るようなテスト対象メソッドを実装します。

FizzBuzz.java
    public String response(int num) {
        return "1";
    }

先程と同様にテストを実行すると、当然、テストは成功します。

5.JPG

TDD の開発では、まずはテストが通るプログラムを書くことから始めるので、慣れるまではテストケースを初めから絞らず、やや愚直でも 1 つ 1 つ目的を達成するよう積み重ねていきます。

それでは、引数が 2 の場合も試してみましょう。

FizzBuzzTest.java
    @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));
    }
FizzBuzz.java
    public String response(int num) {
        return "1";
    }

テスト実行結果は以下の通り、テスト失敗を示す赤のバーが表示されました。

6.JPG

要件を満たし、テストが通るようなプログラムを実装する

追加したテスト失敗より、一般的には以下 2 つの可能性が考えられます。

  • 追加したテストプログラムの誤り
  • テスト対象プログラムが要件を満たしていない

ここで JUnit の実行結果を見返すと以下の記載より、実行結果が 2 となることを期待していたのに実行結果は 1 となり期待値と一致しなかったと分かります。

org.junit.ComparisonFailure: expected:<[2]> but was:<[1]>

よって、テスト対象プログラム FizzBuzz.java を 要件 : 引数に取った値を文字列で返す に沿って以下の通りに修正します。

FizzBuzz.java
    public String response(int num) {
        return String.valueOf(num);
    }

テストが成功するのを確認し、 要件 1 の実装が完了です。

7.JPG

同様に要件 2 ~ 要件 5 まで実装し、同値クラスのテストケースを 1 つに絞った FizzBuzzTest.java と FizzBuzz.java は以下の通りです。

FizzBuzzTest.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));
    }
}
FizzBuzz.java
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 を以下のように書き換えます。

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));
        }
    }
}

当然、書き換えたらテストを実行し、全て成功することを確認します。

構造化.PNG

テスト結果も、テストケースがずらりと並んでいるよりも、どの要件に対するテストケースなのかがより分かりやすくなったと思います。

以上で実践編は終わりです。
今回は比較的単純な要件に対する TDD でしたが、今後はより複雑な要件を実装する際にも是非 TDD を試し、その効果や限界を確かめていこうと思います。

参考文献



  1. @RunWith(Enclosed.class) アノテーションによって、全内部クラスがテスト対象クラスであると認識され、各内部クラス内の @Test アノテーションのついたメソッドを実行してくれるようになります。 

  2. 内部クラスが好かない、という場合はテストのカテゴリ毎にテストメソッドを作成し、テストケースはアサーションを複数設定することによって表現する、という方法もあります。 

6
20
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
20