FizzBuzz with TDD

シナリオ

  • Java + JUnit4で実装する。
  • FizzBuzzクラスと、そのテスト用クラスFizzBuzzTestが存在する。
  • この2つは同じパッケージに属する。

以下長文注意

実装

まずはFizzBuzzTestを作成。
今回はassertThatを利用するので、それに関したインポートを先に行っておく。
そして、今回は特殊なケース(数字以外を返す時)からテストを行う事にする。

// FizzBuzzTest.java
package tdd;

import org.junit.Test;

import static org.junit.Assert.*;

import static org.hamcrest.core.Is.*;
import static org.hamcrest.core.IsNot.*;
import static org.hamcrest.core.IsNull.*;

public class FizzBuzzTest {

	@Test
	public void 数字が3の倍数の時Fizzと返す() {
		FizzBuzz fizzbuzz = new FizzBuzz();
	}
}

eclipseNetBeansを使っていれば、Ctrl+1(デフォルト)やエラー部分をマウスで操作する事でクラスの追加を行う事が出来る。
まずは、FizzBuzzクラスを追加する。

// FizzBuzz.java
package tdd;

public class FizzBuzz {
}

これで、コンパイルエラーはとりあえず起こらなくなる。
次に、メソッドにテストケースを追加する。

// FizzBuzzTest.java(修正)
@Test
public void 数字が3の倍数の時Fizzと返す() {
	FizzBuzz fizzbuzz = new FizzBuzz();
	assertThat(fizzbuzz.say(3), is("Fizz"));
}

こうすると、sayでメソッドが無いというエラーが出てくる。
メソッドの自動生成をした後に、戻り値をObject型から適切な型(今回はString)に変える。

// FizzBuzz.java
package tdd;

public class FizzBuzz {
	public String say(int i) {
		return null;
	}
}

そして、テストを実装するとREDになる。
"Fizz"が返ってくる予定が、nullが返ってきているためである。
そこで、このテストが通る様にまずはコードを修正する。

// FizzBuzz.java
package tdd;

public class FizzBuzz {
	public String say(int i) {
		return "Fizz";
	}
}

これでテストを実行すると、何の問題も無いのでGREENになります。
1つの処理が出来たので、次に取りかかります。
次は、5の倍数の場合です。この時のテストをFizzBuzzTestに追加します。

@Test
public void 数字が5の倍数の時Buzzを返す() {
	FizzBuzz fizzbuzz = new FizzBuzz();
	assertThat(fizzbuzz.say(5), is("Buzz"));
}

このテストを実行すると、残念ながらREDになります。
そこで、このテストが通る様にsayメソッドを変更します。

// FizzBuzz.java
package tdd;

public class FizzBuzz {
	public String say(int i) {
		if( i == 5 ) return "Buzz";
		return "Fizz";
	}
}

これで再びテストを実行するとGREENになります。
ここで、インスタンスを生成する部分がテストの重複として現われています。
そこで、この重複を取り除くリファクタリングを行います。

// FizzBuzzTest.java (import, package略)

public class FizzBuzzTest {

	FizzBuzz fizzbuzz;

	@Before
	public void setUp() {
		fizzbuzz = new FizzBuzz();
	}

	@Test
	public void 数字が3の倍数の時Fizzを返す() {
		assertThat(fizzbuzz.say(3), is("Fizz"));
	}

	@Test
	public void 数字が5の倍数の時Buzzを返す() {
		assertThat(fizzbuzz.say(5), is("Buzz"));
	}
}

これで、再びテストを行えばGREENのままとなります。
テストの振る舞いは変わっていないということをきちんと確認しつつ、リファクタリングを行いました。
2/6のわんくまで少し話が出ましたが、私はこのような手法は間違っていないと考えています。
テストが健康であるためには、必ずリファクタリングが必要だからです。


さて、次にこれらの処理を修正します。
三角測量を利用して、正しく動作するかを確かめつつsayメソッドのコードを修正して行きます。

// FizzBuzzTest.java (修正)
@Test
public void 数字が3の倍数の時Fizzを返す() {
	assertThat(fizzbuzz.say(3), is("Fizz"));
	assertThat(fizzbuzz.say(18), is("Fizz"));
}

処理を追加したのでテスト。
これだけではGREENのままです。何の問題も無いので次に行きます。

@Test
public void 数字が5の倍数の時Buzzを返す() {
	assertThat(fizzbuzz.say(5), is("Buzz"));
	assertThat(fizzbuzz.say(10), is("Buzz"));
}

ここでREDが出てきます。
全てのテストが通るように、sayメソッドを修正します。

// FizzBuzz.java
package tdd;

public class FizzBuzz {
	public String say(int i) {
		if( i%5 == 0 ) return "Buzz";
		return "Fizz";
	}
}

これでGREENになります。
以下、このようなテンポで進めて行きます。

・数字が3と5の両方の倍数の時のテストを追加

// FizzBuzzTest.java
@Test
public void 数字が3と5両方の倍数である時FizzBuzzを返す() {
	assertThat(fizzbuzz.say(15), is("FizzBuzz"));
}

REDが出るので、sayを修正する。

// FizzBuzz.java
package tdd;

public class FizzBuzz {
	public String say(int i) {
		if( i == 15 ) return "FizzBuzz";
		if( i%5 == 0 ) return "Buzz";
		return "Fizz";
	}
}

GREENを確認。
さらにテストケースを追加して三角測量。コードを正しく動く様にする。

// FizzBuzzTest.java
@Test
public void 数字が3と5両方の倍数である時FizzBuzzを返す() {
	assertThat(fizzbuzz.say(15), is("FizzBuzz"));
	assertThat(fizzbuzz.say(30), is("FizzBuzz"));
}
// FizzBuzz.java
package tdd;

public class FizzBuzz {
	public String say(int i) {
		if( i%5 == 0 && i%3 == 0 ) return "FizzBuzz";
		if( i%5 == 0 ) return "Buzz";
		return "Fizz";
	}
}

GREENを確認。ここでソースコード中に重複を確認。
ソースコードリファクタリングする。
ソースコードが長くなってきたので、中括弧もここで追加。

// FizzBuzz.java
package tdd;

public class FizzBuzz {
	public String say(int i) {
		boolean isNumMod5Equals0 = (i%5 == 0);

		if( isNumMod5Equals0 && i%3 == 0 ) {
			return "FizzBuzz";
		}
		if( isNumMod5Equals0 ) {
			return "Buzz";
		}
		return "Fizz";
	}
}

すでにテストがあるので、安心してリファクタリングを行うことができる。
しかし、変数命名に関してはセンス無いなぁ……何かいい名前あったら教えて下さい。

最後に、通常の数字の場合を追加。

// FizzBuzzTest.java
@Test
public void 数字が3の倍数でも5の倍数でもない時にはその数字の文字列を返す() {
	assertThat(fizzbuzz.say(2), is("2"));
}

相変わらずの手法でsayメソッドを修正。

// FizzBuzz.java
package tdd;

public class FizzBuzz {
	public String say(int i) {
		boolean isNumMod5Equals0 = (i%5 == 0);
		if( i == 2 ) { return "2"; }

		if( isNumMod5Equals0 && i%3 == 0 ) {
			return "FizzBuzz";
		}
		if( isNumMod5Equals0 ) {
			return "Buzz";
		}
		return "Fizz";
	}
}

一応これでもGREENは貰えるので次に進む。
次に、三角測量を実施するためにテストケースを追加する。

// FizzBuzzTest.java
@Test
public void 数字が3の倍数でも5の倍数でもない時にはその数字の文字列を返す() {
	assertThat(fizzbuzz.say(2), is("2"));
	assertThat(fizzbuzz.say(7), is("7"));
}

ここで、テストケースを通すための簡単な方法としては(i==7)を追加することが考えられるので、それを実行する。
今までは2回目でうまく行っていたが、最後のreturnがFizzであるが故に、これをリファクタリングするためだ。
REDのままリファクタリングするのでは無く、GREENに戻してからリファクタリングを行う。

1度GREENに戻した後、sayの振る舞いを変えないように"Fizz"や数字の位置を修正する。
すると、以下のような結果が得られる。

// FizzBuzz.java
public String say(int i) {
	boolean isNumMod5Equals0 = (i%5 == 0);
	if( isNumMod5Equals0 && i%3 == 0 ) {
		return "FizzBuzz";
	}
	if( isNumMod5Equals0 ) {
		return "Buzz";
	}
	if( i%3 == 0 ) {
		return "Fizz";
	}
	return String.valueOf(i);
}

ここにも重複があるので、以下の様に修正する。
また、使い手側から見ると名前iが何の事だか分からないので、これもリファクタリングしよう*1

// FizzBuzz.java
public String say(int num) {
	boolean isNumMod5Equals0 = (num%5 == 0);
	boolean isNumMod3Equals0 = (num%3 == 0);
	if( isNumMod5Equals0 && isNumMod3Equals0) {
		return "FizzBuzz";
	}
	if( isNumMod5Equals0 ) {
		return "Buzz";
	}
	if( isNumMod3Equals0 ) {
		return "Fizz";
	}
	return String.valueOf(num);
}

さて、正しい値がsayに渡されるのならば良いが、0以下の整数が渡された場合の挙動は未定義だ。
この場合は「仕様にない」ため、例外とすることにしよう。(おそらく、本来の設計では仕様に定義されている)

// FizzBuzzTest.java
@Test(expected=IllegalArgumentException.class)
public void 数字が0の場合は引数例外をスローする() {
	fizzbuzz.say(0);
}

@Test(expected=IllegalArgumentException.class)
public void 数字が負数の場合は引数例外をスローする() {
	fizzbuzz.say(-3);
}

ここで2つのメソッドを出したのは、例外がスローされた瞬間そのメソッドの処理が終わるためである。
2つの処理を同じメソッドに書くと、例外がスローされたことは分かるが、それがどちらの呼び出しによるものかまでは分からないのである。

これらのテストをパスするように作成したFizzBuzzクラスは以下の通りである。

package tdd;

public class FizzBuzz {

	public String say(int num) {
		if( num <= 0 ) throw new IllegalArgumentException();

		boolean isNumMod5Equals0 = (num%5 == 0);
		boolean isNumMod3Equals0 = (num%3 == 0);
		if( isNumMod5Equals0 && isNumMod3Equals0) {
			return "FizzBuzz";
		}
		if( isNumMod5Equals0 ) {
			return "Buzz";
		}
		if( isNumMod3Equals0 ) {
			return "Fizz";
		}
		return String.valueOf(num);
	}

}

非常に長くなってしまったが、一応自分がTDDを行っているときの手順などを述べてみた。
まずいところや、改善点、参考になったという点等々、もしあればお気軽にコメントやtwitterで話かけて下さい。
# ソースコードも注意はしたけれど、間違いがあったらご指摘下さい……

*1:と思ったが、ここでは適切な名前が思いつかなかった。適切な名前がある場合は、どこかのリファクタリングで修正をすること。……とはいえ、命名は難しい。