2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

テスト駆動開発Advent Calendar 2020

Day 23

数字をローマ字に変換するコードをTDDで書いてみた

Last updated at Posted at 2020-12-22

はじめに

最近はtypescriptを使っての画面開発が中心になっていて、
久しぶりにjavaを書こうかと思い立ち、せっかくの機会なのでTDDを実践してみようと思います。

ここでの開発は以下にまとめています。
https://github.com/ko-flavor/study-TDD/pull/2

大きな目次とコミットをできるだけ対応させながら書きましたので、
コードと照らしあわせたい方は参考にしてください。

題材探し

Kata-Log さんから題材を拝借しました。
数字→ローマ数字に変換するメソッドを書くとのお題です。

さっそく準備

ここらへんはTDDと関係ないので、端折って書きます。

  • Gradleプロジェクト自動生成
  • パッケージ名、クラス名、メソッド名を編集
    • パッケージ名はRomanNumeralsromanNumerals
    • クラス名は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個のテストが並んでいるよりかはだいぶ分かりやすくなりましたね!

image.png

おわり。

まとめ

結構気づいたら大作の記事になってしまいましたね。。
また、適当に選んだ割にすごい学びも多くあって自分が夢中になって進めてしまいました。
やっぱり、動くものを自分で作るのは楽しいですね。

最近typescriptを中心に触っていたこともあって、
関数型インターフェースも特に迷いもせず抽象化できたなというのも実感できてうれしかったです。

しかし、なかなか文章だけでは伝わらない部分も多くある気がします。
今後もライブコーディングやペアプロ、モブプロなど通してTDD広めていきたいです!

2
1
0

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
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?