はじめに#
TDD(テスト駆動開発)の理論を学んでも、実際に手を動かさなければ身につきません。本記事では、プログラミングの定番課題であるFizzBuzz問題を題材に、TDDのRed-Green-Refactorサイクルを一緒に体験していきます。
FizzBuzz問題は、シンプルなルールでありながらTDDの本質を学ぶのに最適な題材です。コードを書く前にテストを書き、テストが通る最小限のコードを実装し、リファクタリングで改善するという流れを、具体的なコード例とともに段階的に解説します。
この記事を読み終える頃には、TDDの基本的なワークフローを自分のプロジェクトに適用できるようになっているでしょう。
FizzBuzz問題とは#
FizzBuzz問題は、プログラミングの基礎力を測るための古典的な課題です。ルールは以下のとおりです。
- 1から100までの数値を順番に処理する
- 3の倍数のときは「Fizz」を返す
- 5の倍数のときは「Buzz」を返す
- 3と5の両方の倍数(15の倍数)のときは「FizzBuzz」を返す
- それ以外の場合は数値をそのまま文字列として返す
期待される出力例は以下のとおりです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
...
|
シンプルに見えますが、TDDで段階的に実装することで、テスト駆動開発の醍醐味を味わえます。
前提条件#
本記事では、JavaScriptとJavaの両方でFizzBuzzのTDD実装を解説します。お使いの環境に合わせて選択してください。
| 言語 |
必要な環境 |
| JavaScript |
Node.js 18以上、Jest |
| Java |
JDK 17以上、JUnit 5 |
環境構築の詳細については、以下の記事を参照してください。
テストリストの作成#
Red-Green-Refactorサイクルに入る前に、まずテストリストを作成します。これはTDDの重要な初期ステップであり、実装の全体像を把握するためのロードマップとなります。
FizzBuzz問題のテストリストを作成してみましょう。
1
2
3
4
5
6
7
8
9
|
FizzBuzz テストリスト:
- [ ] 1を渡すと"1"を返す
- [ ] 2を渡すと"2"を返す
- [ ] 3を渡すと"Fizz"を返す(3の倍数)
- [ ] 5を渡すと"Buzz"を返す(5の倍数)
- [ ] 6を渡すと"Fizz"を返す(3の倍数)
- [ ] 10を渡すと"Buzz"を返す(5の倍数)
- [ ] 15を渡すと"FizzBuzz"を返す(3と5の倍数)
- [ ] 30を渡すと"FizzBuzz"を返す(3と5の倍数)
|
このリストの順番には意図があります。最もシンプルなケースから始めて、徐々に複雑なケースへ進むのがTDDのベストプラクティスです。
TDD実装の全体像#
これから行う実装の流れを、Mermaidで可視化します。
flowchart TB
subgraph cycle1["サイクル1: 数値をそのまま返す"]
R1["Red: 1→'1'のテスト"] --> G1["Green: 数値を文字列で返す"]
G1 --> RF1["Refactor: 不要(シンプル)"]
end
subgraph cycle2["サイクル2: 3の倍数でFizz"]
R2["Red: 3→'Fizz'のテスト"] --> G2["Green: 3で割れたらFizz"]
G2 --> RF2["Refactor: 不要"]
end
subgraph cycle3["サイクル3: 5の倍数でBuzz"]
R3["Red: 5→'Buzz'のテスト"] --> G3["Green: 5で割れたらBuzz"]
G3 --> RF3["Refactor: 不要"]
end
subgraph cycle4["サイクル4: 15の倍数でFizzBuzz"]
R4["Red: 15→'FizzBuzz'のテスト"] --> G4["Green: 15で割れたらFizzBuzz"]
G4 --> RF4["Refactor: コードを整理"]
end
cycle1 --> cycle2
cycle2 --> cycle3
cycle3 --> cycle4
style R1 fill:#ffcccc,stroke:#cc0000,color:#000000
style R2 fill:#ffcccc,stroke:#cc0000,color:#000000
style R3 fill:#ffcccc,stroke:#cc0000,color:#000000
style R4 fill:#ffcccc,stroke:#cc0000,color:#000000
style G1 fill:#ccffcc,stroke:#00cc00,color:#000000
style G2 fill:#ccffcc,stroke:#00cc00,color:#000000
style G3 fill:#ccffcc,stroke:#00cc00,color:#000000
style G4 fill:#ccffcc,stroke:#00cc00,color:#000000
style RF1 fill:#cce5ff,stroke:#0066cc,color:#000000
style RF2 fill:#cce5ff,stroke:#0066cc,color:#000000
style RF3 fill:#cce5ff,stroke:#0066cc,color:#000000
style RF4 fill:#cce5ff,stroke:#0066cc,color:#000000それでは、JavaScriptとJavaの両方で実装を進めていきましょう。
サイクル1: 数値をそのまま返す#
最初のサイクルでは、最もシンプルなケースから始めます。「1を渡すと"1"を返す」というテストを書きます。
Red: 失敗するテストを書く#
まず、テストファイルを作成し、最初のテストを書きます。
JavaScript(Jest)の場合:
1
2
3
4
5
6
7
8
|
// fizzbuzz.test.js
const fizzBuzz = require('./fizzbuzz');
describe('FizzBuzz', () => {
test('1を渡すと"1"を返す', () => {
expect(fizzBuzz(1)).toBe('1');
});
});
|
Java(JUnit 5)の場合:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// FizzBuzzTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.assertEquals;
class FizzBuzzTest {
@Test
@DisplayName("1を渡すと'1'を返す")
void shouldReturnOneWhenGivenOne() {
FizzBuzz fizzBuzz = new FizzBuzz();
assertEquals("1", fizzBuzz.convert(1));
}
}
|
この時点でテストを実行すると、ファイルが存在しないためエラーになります。これがRedフェーズです。
1
2
3
4
5
|
# JavaScriptの場合
Cannot find module './fizzbuzz'
# Javaの場合
error: cannot find symbol class FizzBuzz
|
Green: テストを通す最小限のコードを書く#
次に、テストを通すための最小限のコードを書きます。ここで重要なのは、完璧なコードを書こうとしないことです。
JavaScript:
1
2
3
4
5
6
|
// fizzbuzz.js
function fizzBuzz(number) {
return '1';
}
module.exports = fizzBuzz;
|
Java:
1
2
3
4
5
6
|
// FizzBuzz.java
public class FizzBuzz {
public String convert(int number) {
return "1";
}
}
|
「え、“1"をハードコードするの?」と驚くかもしれません。これがTDDの核心です。テストを通す最小限のコードを書くのがGreenフェーズの目的です。この時点では、1つのテストしかないため、“1"を返すだけで十分なのです。
テストを実行すると、成功します。
1
2
3
|
PASS ./fizzbuzz.test.js
FizzBuzz
✓ 1を渡すと"1"を返す (2 ms)
|
追加テスト: 一般化への道#
「1を返すだけ」では明らかに不十分です。次のテストを追加して、実装を一般化しましょう。
JavaScript:
1
2
3
4
5
6
7
8
9
|
describe('FizzBuzz', () => {
test('1を渡すと"1"を返す', () => {
expect(fizzBuzz(1)).toBe('1');
});
test('2を渡すと"2"を返す', () => {
expect(fizzBuzz(2)).toBe('2');
});
});
|
Java:
1
2
3
4
5
6
|
@Test
@DisplayName("2を渡すと'2'を返す")
void shouldReturnTwoWhenGivenTwo() {
FizzBuzz fizzBuzz = new FizzBuzz();
assertEquals("2", fizzBuzz.convert(2));
}
|
このテストを実行すると、失敗します(Red)。
1
2
|
Expected: "2"
Received: "1"
|
テストを通すために、実装を修正します(Green)。
JavaScript:
1
2
3
4
5
|
function fizzBuzz(number) {
return String(number);
}
module.exports = fizzBuzz;
|
Java:
1
2
3
4
5
|
public class FizzBuzz {
public String convert(int number) {
return String.valueOf(number);
}
}
|
これで両方のテストが通ります。
サイクル2: 3の倍数で「Fizz」を返す#
次に、3の倍数のケースを実装します。
Red: 失敗するテストを書く#
JavaScript:
1
2
3
|
test('3を渡すと"Fizz"を返す', () => {
expect(fizzBuzz(3)).toBe('Fizz');
});
|
Java:
1
2
3
4
5
6
|
@Test
@DisplayName("3を渡すと'Fizz'を返す")
void shouldReturnFizzWhenGivenThree() {
FizzBuzz fizzBuzz = new FizzBuzz();
assertEquals("Fizz", fizzBuzz.convert(3));
}
|
テストを実行すると、失敗します。
1
2
|
Expected: "Fizz"
Received: "3"
|
Green: テストを通す最小限のコード#
JavaScript:
1
2
3
4
5
6
7
8
|
function fizzBuzz(number) {
if (number % 3 === 0) {
return 'Fizz';
}
return String(number);
}
module.exports = fizzBuzz;
|
Java:
1
2
3
4
5
6
7
8
|
public class FizzBuzz {
public String convert(int number) {
if (number % 3 == 0) {
return "Fizz";
}
return String.valueOf(number);
}
}
|
テストが通りました。さらに6(3の倍数)のケースも追加してテストが通ることを確認しましょう。
JavaScript:
1
2
3
|
test('6を渡すと"Fizz"を返す', () => {
expect(fizzBuzz(6)).toBe('Fizz');
});
|
Java:
1
2
3
4
5
6
|
@Test
@DisplayName("6を渡すと'Fizz'を返す")
void shouldReturnFizzWhenGivenSix() {
FizzBuzz fizzBuzz = new FizzBuzz();
assertEquals("Fizz", fizzBuzz.convert(6));
}
|
このテストは、コードを変更しなくても通ります。これは実装が正しく一般化されている証拠です。
サイクル3: 5の倍数で「Buzz」を返す#
同様に、5の倍数のケースを実装します。
Red: 失敗するテストを書く#
JavaScript:
1
2
3
|
test('5を渡すと"Buzz"を返す', () => {
expect(fizzBuzz(5)).toBe('Buzz');
});
|
Java:
1
2
3
4
5
6
|
@Test
@DisplayName("5を渡すと'Buzz'を返す")
void shouldReturnBuzzWhenGivenFive() {
FizzBuzz fizzBuzz = new FizzBuzz();
assertEquals("Buzz", fizzBuzz.convert(5));
}
|
Green: テストを通す最小限のコード#
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
|
function fizzBuzz(number) {
if (number % 3 === 0) {
return 'Fizz';
}
if (number % 5 === 0) {
return 'Buzz';
}
return String(number);
}
module.exports = fizzBuzz;
|
Java:
1
2
3
4
5
6
7
8
9
10
11
|
public class FizzBuzz {
public String convert(int number) {
if (number % 3 == 0) {
return "Fizz";
}
if (number % 5 == 0) {
return "Buzz";
}
return String.valueOf(number);
}
}
|
10(5の倍数)のテストも追加して確認します。
1
2
3
|
test('10を渡すと"Buzz"を返す', () => {
expect(fizzBuzz(10)).toBe('Buzz');
});
|
すべてのテストが通りました。
サイクル4: 15の倍数で「FizzBuzz」を返す#
最後に、3と5の両方の倍数のケースを実装します。
Red: 失敗するテストを書く#
JavaScript:
1
2
3
|
test('15を渡すと"FizzBuzz"を返す', () => {
expect(fizzBuzz(15)).toBe('FizzBuzz');
});
|
Java:
1
2
3
4
5
6
|
@Test
@DisplayName("15を渡すと'FizzBuzz'を返す")
void shouldReturnFizzBuzzWhenGivenFifteen() {
FizzBuzz fizzBuzz = new FizzBuzz();
assertEquals("FizzBuzz", fizzBuzz.convert(15));
}
|
テストを実行すると、失敗します。
1
2
|
Expected: "FizzBuzz"
Received: "Fizz"
|
現在の実装では、15は3の倍数の条件に最初にマッチするため、“Fizz"が返されてしまいます。
Green: テストを通す最小限のコード#
JavaScript:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function fizzBuzz(number) {
if (number % 3 === 0 && number % 5 === 0) {
return 'FizzBuzz';
}
if (number % 3 === 0) {
return 'Fizz';
}
if (number % 5 === 0) {
return 'Buzz';
}
return String(number);
}
module.exports = fizzBuzz;
|
Java:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
public class FizzBuzz {
public String convert(int number) {
if (number % 3 == 0 && number % 5 == 0) {
return "FizzBuzz";
}
if (number % 3 == 0) {
return "Fizz";
}
if (number % 5 == 0) {
return "Buzz";
}
return String.valueOf(number);
}
}
|
Refactor: コードを改善する#
テストが通ったら、Refactorフェーズでコードを改善します。現在の実装には以下の問題点があります。
number % 3 === 0のチェックが2箇所にある(重複)
number % 5 === 0のチェックも2箇所にある(重複)
- 条件の順番に依存している(15の倍数を最初にチェックする必要がある)
より良い設計にリファクタリングしましょう。
JavaScript(リファクタリング後):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function fizzBuzz(number) {
let result = '';
if (number % 3 === 0) {
result += 'Fizz';
}
if (number % 5 === 0) {
result += 'Buzz';
}
return result || String(number);
}
module.exports = fizzBuzz;
|
Java(リファクタリング後):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
public class FizzBuzz {
private static final int FIZZ_DIVISOR = 3;
private static final int BUZZ_DIVISOR = 5;
public String convert(int number) {
StringBuilder result = new StringBuilder();
if (isDivisibleBy(number, FIZZ_DIVISOR)) {
result.append("Fizz");
}
if (isDivisibleBy(number, BUZZ_DIVISOR)) {
result.append("Buzz");
}
return result.length() > 0 ? result.toString() : String.valueOf(number);
}
private boolean isDivisibleBy(int number, int divisor) {
return number % divisor == 0;
}
}
|
リファクタリング後もすべてのテストが通ることを確認します。
1
2
3
4
5
6
7
8
9
|
PASS ./fizzbuzz.test.js
FizzBuzz
✓ 1を渡すと"1"を返す (2 ms)
✓ 2を渡すと"2"を返す
✓ 3を渡すと"Fizz"を返す
✓ 5を渡すと"Buzz"を返す (1 ms)
✓ 6を渡すと"Fizz"を返す
✓ 10を渡すと"Buzz"を返す
✓ 15を渡すと"FizzBuzz"を返す
|
テストが通っているからこそ、安心してリファクタリングできるーこれがTDDの真価です。
完成版コード#
最終的なテストコードと実装コードを掲載します。
JavaScript版#
テストコード(fizzbuzz.test.js):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
const fizzBuzz = require('./fizzbuzz');
describe('FizzBuzz', () => {
describe('通常の数値', () => {
test('1を渡すと"1"を返す', () => {
expect(fizzBuzz(1)).toBe('1');
});
test('2を渡すと"2"を返す', () => {
expect(fizzBuzz(2)).toBe('2');
});
});
describe('3の倍数', () => {
test('3を渡すと"Fizz"を返す', () => {
expect(fizzBuzz(3)).toBe('Fizz');
});
test('6を渡すと"Fizz"を返す', () => {
expect(fizzBuzz(6)).toBe('Fizz');
});
test('9を渡すと"Fizz"を返す', () => {
expect(fizzBuzz(9)).toBe('Fizz');
});
});
describe('5の倍数', () => {
test('5を渡すと"Buzz"を返す', () => {
expect(fizzBuzz(5)).toBe('Buzz');
});
test('10を渡すと"Buzz"を返す', () => {
expect(fizzBuzz(10)).toBe('Buzz');
});
test('20を渡すと"Buzz"を返す', () => {
expect(fizzBuzz(20)).toBe('Buzz');
});
});
describe('3と5の倍数', () => {
test('15を渡すと"FizzBuzz"を返す', () => {
expect(fizzBuzz(15)).toBe('FizzBuzz');
});
test('30を渡すと"FizzBuzz"を返す', () => {
expect(fizzBuzz(30)).toBe('FizzBuzz');
});
test('45を渡すと"FizzBuzz"を返す', () => {
expect(fizzBuzz(45)).toBe('FizzBuzz');
});
});
});
|
実装コード(fizzbuzz.js):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
function fizzBuzz(number) {
let result = '';
if (number % 3 === 0) {
result += 'Fizz';
}
if (number % 5 === 0) {
result += 'Buzz';
}
return result || String(number);
}
module.exports = fizzBuzz;
|
Java版#
テストコード(FizzBuzzTest.java):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
|
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class FizzBuzzTest {
private FizzBuzz fizzBuzz;
@BeforeEach
void setUp() {
fizzBuzz = new FizzBuzz();
}
@Nested
@DisplayName("通常の数値")
class NormalNumbers {
@Test
@DisplayName("1を渡すと'1'を返す")
void shouldReturnOneWhenGivenOne() {
assertEquals("1", fizzBuzz.convert(1));
}
@Test
@DisplayName("2を渡すと'2'を返す")
void shouldReturnTwoWhenGivenTwo() {
assertEquals("2", fizzBuzz.convert(2));
}
}
@Nested
@DisplayName("3の倍数")
class MultiplesOfThree {
@Test
@DisplayName("3を渡すと'Fizz'を返す")
void shouldReturnFizzWhenGivenThree() {
assertEquals("Fizz", fizzBuzz.convert(3));
}
@Test
@DisplayName("6を渡すと'Fizz'を返す")
void shouldReturnFizzWhenGivenSix() {
assertEquals("Fizz", fizzBuzz.convert(6));
}
@Test
@DisplayName("9を渡すと'Fizz'を返す")
void shouldReturnFizzWhenGivenNine() {
assertEquals("Fizz", fizzBuzz.convert(9));
}
}
@Nested
@DisplayName("5の倍数")
class MultiplesOfFive {
@Test
@DisplayName("5を渡すと'Buzz'を返す")
void shouldReturnBuzzWhenGivenFive() {
assertEquals("Buzz", fizzBuzz.convert(5));
}
@Test
@DisplayName("10を渡すと'Buzz'を返す")
void shouldReturnBuzzWhenGivenTen() {
assertEquals("Buzz", fizzBuzz.convert(10));
}
@Test
@DisplayName("20を渡すと'Buzz'を返す")
void shouldReturnBuzzWhenGivenTwenty() {
assertEquals("Buzz", fizzBuzz.convert(20));
}
}
@Nested
@DisplayName("3と5の倍数")
class MultiplesOfThreeAndFive {
@Test
@DisplayName("15を渡すと'FizzBuzz'を返す")
void shouldReturnFizzBuzzWhenGivenFifteen() {
assertEquals("FizzBuzz", fizzBuzz.convert(15));
}
@Test
@DisplayName("30を渡すと'FizzBuzz'を返す")
void shouldReturnFizzBuzzWhenGivenThirty() {
assertEquals("FizzBuzz", fizzBuzz.convert(30));
}
@Test
@DisplayName("45を渡すと'FizzBuzz'を返す")
void shouldReturnFizzBuzzWhenGivenFortyFive() {
assertEquals("FizzBuzz", fizzBuzz.convert(45));
}
}
}
|
実装コード(FizzBuzz.java):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
public class FizzBuzz {
private static final int FIZZ_DIVISOR = 3;
private static final int BUZZ_DIVISOR = 5;
public String convert(int number) {
StringBuilder result = new StringBuilder();
if (isDivisibleBy(number, FIZZ_DIVISOR)) {
result.append("Fizz");
}
if (isDivisibleBy(number, BUZZ_DIVISOR)) {
result.append("Buzz");
}
return result.length() > 0 ? result.toString() : String.valueOf(number);
}
private boolean isDivisibleBy(int number, int divisor) {
return number % divisor == 0;
}
}
|
TDD実践のポイント#
FizzBuzz問題を通じてTDDを体験した結果、以下のポイントが重要であることがわかります。
テストリストを作成する#
実装に入る前にテストリストを作成することで、全体像を把握できます。また、どの順番でテストを追加すべきかを事前に計画できます。
最もシンプルなケースから始める#
最初から複雑なケース(15の倍数など)に取り組むのではなく、最もシンプルなケース(1→“1”)から始めることで、着実に進められます。
最小限のコードで通す#
Greenフェーズでは、テストを通す最小限のコードを書きます。“1"をハードコードするような「ずるい」実装でも構いません。次のテストで自然と一般化されていきます。
リファクタリングを怠らない#
テストが通ったら終わりではありません。Refactorフェーズでコードの品質を改善することで、長期的に保守しやすいコードになります。
小さなステップで進める#
1つのサイクルは数分で完了するのが理想です。大きな変更を一度にしようとせず、小さなステップを積み重ねることで、いつでも動くコードを維持できます。
発展課題#
FizzBuzzのTDD実装に慣れたら、以下の発展課題に挑戦してみてください。
課題1: 7の倍数で「Whizz」を追加#
7の倍数のときに「Whizz」を返すルールを追加します。例えば、21(3と7の倍数)は「FizzWhizz」、35(5と7の倍数)は「BuzzWhizz」、105(3と5と7の倍数)は「FizzBuzzWhizz」になります。
課題2: パラメタライズドテストへのリファクタリング#
同じパターンのテストが多いため、パラメタライズドテストを使ってリファクタリングしてみましょう。
JavaScript(Jest each):
1
2
3
4
5
6
7
8
9
|
test.each([
[1, '1'],
[2, '2'],
[3, 'Fizz'],
[5, 'Buzz'],
[15, 'FizzBuzz'],
])('%i を渡すと "%s" を返す', (input, expected) => {
expect(fizzBuzz(input)).toBe(expected);
});
|
Java(JUnit ParameterizedTest):
1
2
3
4
5
6
7
8
9
10
11
|
@ParameterizedTest
@CsvSource({
"1, 1",
"2, 2",
"3, Fizz",
"5, Buzz",
"15, FizzBuzz"
})
void shouldConvertNumberToFizzBuzz(int input, String expected) {
assertEquals(expected, fizzBuzz.convert(input));
}
|
まとめ#
本記事では、FizzBuzz問題を題材にTDDのRed-Green-Refactorサイクルを体験しました。
- Red: 失敗するテストを書いて、要件を明確化する
- Green: テストを通す最小限のコードを書く
- Refactor: テストに守られながらコードを改善する
この3つのステップを小さく速く繰り返すことで、常に動作するコードを維持しながら、段階的に機能を追加できます。
FizzBuzzはシンプルな問題ですが、TDDの本質を学ぶのに最適な題材です。この経験を土台に、より複雑な実務のコードにもTDDを適用してみてください。
参考リンク#