はじめに
最近はtypescriptを使っての画面開発が中心になっていて、
久しぶりにjavaを書こうかと思い立ち、せっかくの機会なのでTDDを実践してみようと思います。
ここでの開発は以下にまとめています。
https://github.com/ko-flavor/study-TDD/pull/2
大きな目次とコミットをできるだけ対応させながら書きましたので、
コードと照らしあわせたい方は参考にしてください。
題材探し
Kata-Log さんから題材を拝借しました。
数字→ローマ数字に変換するメソッドを書くとのお題です。
さっそく準備
ここらへんはTDDと関係ないので、端折って書きます。
- Gradleプロジェクト自動生成
- パッケージ名、クラス名、メソッド名を編集
- パッケージ名は
RomanNumerals
→romanNumerals
- クラス名は
RomanNumeralConverter
- メソッド名は指示通り
convert
- パッケージ名は
-
.gitignore
編集 - jUnit5と、
assertThat
を使いたかったので、build.gradle
を編集 - テストの中身を
fail()
にして、準備完了。
TODOリスト作成
とりあえず自分の今もっている知識で、TODOリストを作ってみました。
TODOリストを作っていると「あれ、0の場合は?」とか「大きい数字とかってどう表現するんだろう?」とか気になってきます。
まあ、ひとまずは目をつぶって以下に沿って実装していこうと思います。
(すみません、github上にコミットしたTODOリスト文字化けしています。。)
- 1→I に変換する
- 2→II に変換する
- 3→III に変換する
- 4→IV に変換する
- 5→V に変換する
- 6→VI に変換する
- 9→IX に変換する
- 10→X に変換する
- 11→XI に変換する
一つ目のテストを作成、GREENにする
記念すべき一つ目のテストを書きます。
@Test
public void test_one_is_converted_to_I() {
RomanNumeralConverter converter = new RomanNumeralConverter();
String result = converter.convert(1);
assertThat(result).isEqualTo("I");
}
当然、実装していないので、テストは失敗します。
最速でGREENバーにもっていきたいので、メソッドを以下で実装しました。
public String convert(int i) {
return "I";
}
これで、はじめてGREENバーが拝めました。
- 1→I に変換する
二つ目のテストを作成、GREENにする
今度は二つ目のテストを書いてみます。
@Test
public void test_two_is_converted_to_II() {
RomanNumeralConverter converter = new RomanNumeralConverter();
String result = converter.convert(2);
assertThat(result).isEqualTo("II");
}
REDになりました、急いでGREENにします。
public String convert(int i) {
return i == 1 ? "I" : "II";
}
これで、TODOリストの以下も完了です。
- 2→II に変換する
GREENバーのうちにリファクタする
以下のように、数字が1/2以外の場合もできるようにしました。
public String convert(int number) {
StringBuilder builder = new StringBuilder();
IntStream.rangeClosed(1, number).forEach(i -> builder.append("I"));
return builder.toString();
}
テストもきれいに通っているので、安心してリファクタが影響ないと分かります。
テストクラスもインスタンスを各メソッドでnew
していたものをフィールドに移動させました。
三つ目のテストを作成、さらにリファクタ
三つ目のテストを作成しました。
@Test
public void test_three_is_converted_to_III() {
String result = this.converter.convert(3);
assertThat(result).isEqualTo("III");
}
既にGREENになっていました。テストメソッドがインライン化できそうなので、以下のようにテストクラスを修正しました。
@Test
public void test_three_is_converted_to_III() {
assertThat(this.converter.convert(3)).isEqualTo("III");
}
- 3→III に変換する
四つ目のテスト作成、GREENにする
なんだかIV
って難しそうなので、簡単そうなV
のパターンから手を付けます。
@Test
public void test_five_is_converted_to_V() {
assertThat(this.converter.convert(5)).isEqualTo("V");
}
当然、テストは失敗します。
最速でGREENバーを拝みたいので、以下のようなコードを書いて通します。
public String convert(int number) {
StringBuilder builder = new StringBuilder();
if (number == 5) {
return "V";
}
IntStream.rangeClosed(1, number).forEach(i -> builder.append("I"));
return builder.toString();
}
- 5→V に変換する
GREENのうちにリファクタする(part2)
5であれば、V
を返却するという部分が素晴らしく気に食いません。
やりたい処理は、5以上であればV
をつけて、変換対象の数字を5減算するです。
そのままコードに落としてあげます。
if (number >= 5) {
builder.append("V");
number = number - 5;
}
OKですね。テストもきちんと通っています。
6がVIに変換されるテストを作成する
以下テストを記述。テストは通りますね。
@Test
public void test_six_is_converted_to_VI() {
assertThat(this.converter.convert(6)).isEqualTo("VI");
}
**このテスト不要なのではないか?**という議論も当然出てきます。
テストメソッドも増やせばいいってものでもないので。。
今回の趣旨とは異なりますので、割愛しますが、
実践するときはチームで議論のネタになると思います。
一度作ったTODOリストは変えちゃいけないなんてルールはないので、
その議論を受けてどんどん更新していけばよいと思います。
- 6→VI に変換する
10がXに変換されるテストを作成し、GREENにする
ここまでくれば、だんだんテスト駆動の進め方も分かってきたかと思います。
以下テストを作成、当然テストは失敗します。
@Test
public void test_ten_is_converted_to_X() {
assertThat(this.converter.convert(10)).isEqualTo("X");
}
V
のケースを流用すればよさそうだったので、
以下分岐を加えて、テストを通しました。
if (number >= 10) {
builder.append("X");
number = number - 10;
}
- 10→X に変換する
GREENのうちにリファクタする(part3)
記事を書いていたのを忘れて、いろいろやってしまいました。
最終的には以下のようになりました。
StringBuilder builder;
int number;
public String convert(int numberToConvert) {
initVariables(numberToConvert);
appendX();
appendV();
appendI();
return builder.toString();
}
private void initVariables(int numberToConvert) {
this.builder = new StringBuilder();
this.number = numberToConvert;
}
private void appendX() {
if (number >= 10) {
builder.append("X");
number = number - 10;
}
}
フィールドにStringBuilderと変換対象の数字を移動させて、
convert
のたびに初期化するようにしました。
ただ、こうしたことにより、マルチスレッド対応はできなくなりましたので
呼び出す側で考慮が必要になります。
テストクラスに以下追加して、毎回インスタンスをnew
するようにしました。
@BeforeEach
public void setup() {
this.converter = new RomanNumeralConverter();
}
11がXIに変換されるテストを作成
特に説明も必要ない気もします。
@Test
public void test_eleven_is_converted_to_XI() {
assertThat(this.converter.convert(11)).isEqualTo("XI");
}
- 11→XIに変換する
4がIVに変換されるテストを作成
以下テストを作成します。
@Test
public void test_four_is_converted_to_IV() {
assertThat(this.converter.convert(4)).isEqualTo("IV");
}
急いでGREENにするため、以下メソッドを実装して、無事GREENになりました。
private void appendIV() {
if (number >= 4) {
builder.append("IV");
number = number - 4;
}
}
同様に、9→IXの変換のテストとロジックも実装しました。
- 4→IV に変換する
- 9→IX に変換する
これで、当初用意していたTODOリストは全部チェックがつきました。
TODOリストの再検討
ローマ数字の仕様についても詳しくなったところで、TODOリストを再度検討しました。
以下を全て満たせれば、どうやらローマ数字の変換は完璧で言えそうです。
- 40→XL に変換する
- 50→L に変換する
- 90→XC に変換する
- 100→C に変換する
- 400→CDに変換する
- 500→D に変換する
- 900→CMに変換する
- 1000→Mに変換する
- 3999→MMMCMXCIXに変換する
- 0以下の引数の場合にエラーとする
- 4000以上の引数の場合にエラーとする
GREENのうちにリファクタする(part4)
以下のようなappendHoge
がどんどん増えてきて、共通化の欲が高まってきました。
private void appendIX() {
if (number >= 9) {
builder.append("IX");
number = number - 9;
}
}
private void appendV() {
if (number >= 5) {
builder.append("V");
number = number - 5;
}
}
関数型インターフェース使って、以下宣言して、上記のようなメソッドは完全に撤廃しました。
これで、実装クラスは半分くらいの長さになりました。
private BiConsumer<Integer, String> appendRoman = (i, str) -> {
while (number >= i) {
builder.append(str);
number = number - i;
}
};
これまではappendHoge
で宣言していたメソッドも以下のように記載できるようになりました。
appendRoman.accept(5, "V");
appendRoman.accept(4, "IV");
appendRoman.accept(1, "I");
その他のテスト追加
ここまでくれば、実装もめちゃくちゃ楽です。
テストケース追加&実装まで、30秒くらいで完結します。
- 40→XL に変換する
- 50→L に変換する
- 90→XC に変換する
- 100→C に変換する
- 400→CDに変換する
- 500→D に変換する
- 900→CMに変換する
- 1000→Mに変換する
- 3999→MMMCMXCIXに変換する
引数チェックのテストの実装
以下でテストを実装しました。
@Test
public void test_zero_cannot_be_converted() {
assertThrows(IllegalArgumentException.class, () -> this.converter.convert(0));
}
久しぶりにREDになったので、急いでGREENにします。
private void checkArgument(int numberToConvert) {
if (numberToConvert == 0) {
throw new IllegalArgumentException();
}
}
同様に、4000の場合のテストも記載、GREENにした後にリファクタします。
最終的には以下のようになりました。
private void checkArgument(int numberToConvert) {
if (!(0 < numberToConvert && numberToConvert < 4000)) {
throw new IllegalArgumentException("Argument must be between 1 and 3999.");
}
}
- 0以下の引数の場合にエラーとする
- 4000以上の引数の場合にエラーとする
テストクラスのリファクタリング
さて、jUnitの実行結果を見てみると、なんだかテストケースがたくさん並んでいます。
20個も並列にテストが書いてあって、確かにひとつひとつのメソッドを見ると何をやっているのかはわかりますが、
パッと見てテストできてるのか分かりません。
ので、リファクタします。
jUnit5から@Nested
のアノテーションを用いてテストクラスを構造化できます。
これを使って、動くドキュメントを目指してテストクラスをリファクタします。
どうでしょうか、並列で20個のテストが並んでいるよりかはだいぶ分かりやすくなりましたね!
おわり。
まとめ
結構気づいたら大作の記事になってしまいましたね。。
また、適当に選んだ割にすごい学びも多くあって自分が夢中になって進めてしまいました。
やっぱり、動くものを自分で作るのは楽しいですね。
最近typescriptを中心に触っていたこともあって、
関数型インターフェースも特に迷いもせず抽象化できたなというのも実感できてうれしかったです。
しかし、なかなか文章だけでは伝わらない部分も多くある気がします。
今後もライブコーディングやペアプロ、モブプロなど通してTDD広めていきたいです!