はじめに
TDDに取り組むため、そして他者にもTDDに対する本質的なところのイメージを共有するために、本記事を記す。
※イメージ重視。一部まとめ終わっていない箇所ありますがご容赦ください。
TDDとは
テスト技法ではなく、設計/分析技法
- 価値
- インクリメンタルな設計を促す開発手法
- チームに良いリズムを生む
- 開発に自信と信頼をもたらす
- 原則
- 実装する前にテストを作ること
- 問題を放置せず少しずつ前進させること
- 大事なのは完璧さより自信を持てる状態を保つこと
- ルール
- (自動化された)テストが失敗したときのみ、新しいコードを書く
- 重複を除去する
インクリメンタルな設計とは
- できるだけ無駄なく価値あるものを作るために
- 開発対象への理解が進むほど正しい判断が可能になるため
- 何を作るか正しい判断ができるまでは決めないでおくが
- 最低限必要な機能から少しずつ開発を進め設計自体も少しずつ進化させつつ
- 開発対象への理解度を少しずつ上げることで、正しい判断をできるようにする
適したケース:アジャイル
・作りたいものが決まっていない/分かっていない
・開発チームが開発対象を十分に理解していない
・開発対象が複雑で正しい設計を誰も知らない
流れ
- Red :失敗するテストを書く(機能追加/条件追加/バグ再現 etc.)
- Green :テストを成功させる(最小限のテストを書く、重複コードを書く等の罪を犯してよい)
- Refactor :動作を保ったまま設計を洗練する(テストを通すために書いた重複除去)
上記のサイクルを回すことで、リズミカルかつ自信と信頼に基づき、インクリメンタルな設計を行っていく
行ううえでのポイント
- TODOリストに必要なテストを書く
- 着手する前に必要になりそうなテストを書きだす(現時点で認知できるものだけでいい)
- 「すぐにやる」「あとでやる」「まったくやる必要なし」の3つリストに振り分ける
- 実装しなければならない振舞いを考えられるだけ書き出し、まだ実装がない操作をリストに加える
- 今書いたコードを綺麗にするためのリファクタも書きだす
- 新しいテストを思い付いたらリストに書く。リファクタも同様
- 機能は実際に必要となるまでは追加しない方が良い
- きっと後で必要になるだろう→9割無駄になる
- 今必要とするもの以上の機能を追加すると設計が複雑になる
- 必要ない機能を追加すると、他メンバーが読む時間やドキュメントへの記載などリソースが奪われるだけ
- コードを早く・バグを減らす
- 最適な方法は、実装するコードを必要最低限に留めること
- 静的設計と動的設計を相互に行き来きすることで、設計を洗練していく
- 静的設計(責任を設計)
- インターフェース
- 問題を最も単純化できるクラス/メソッド構成
- 名称(クラス/メソッド等)
- 責任内容を端的に表現
- インターフェース
- 動的設計(仕事の正しさを設計)
- 振舞い
- 処理内容:どういう手順でどのクラス/メソッドを使い処理を行うか
- 事前条件/事後条件
- In/Out:事前条件に対して結果(事後条件)は何か
- 振舞い
- 静的設計(責任を設計)
実践イメージのサンプル
題材:ボウリング点数計算
使用環境:
- IntelliJ Community版
- Java 11 ※古くて申し訳ない
- Gradle ※下2つのライブラリ取得/管理とビルド実行のために
- JUnit5
- AssertJ
まずは簡単かつ先に進む道筋足り得るものから
全てガーターのケース
スタートライン
最初に追加するテストは、「何もしないテスト」を書く
Red
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
@Test
public void test_all_Garter() {
var game = new BowlingGame();
assertThat(game).isNotNull();
}
}
Green
public class BowlingGame {
}
最小のメソッドを追加
Red
全投球ガーターのテストケースにする
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
@Test
public void test_all_Garter() {
var game = new BowlingGame();
+ for(int count = 0; count < 20; count++) {
+ game.recordOneShot(0);
+ }
- assertThat(game).isNotNull();
+ assertThat(game.getTotalScore()).isEqualTo(0);
}
}
Green
public class BowlingGame {
+ public void recordOneShot(int pins) {
+ }
+ public int score() {
+ return 0;
+ }
}
全部1ピンだけ倒したケース
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
// ry
+ @Test
+ public void test_all_one_pin() {
+ var game = new BowlingGame();
+ for (int count = 0; count < 20; count++) {
+ game.recordOneShot(1);
+ }
+ assertThat(game.score()).isEqualTo(20);
+ }
}
Green
public class BowlingGame {
private int totalScore = 0;
+ public void recordOneShot(int pins) {
+ totalScore += pins;
+ }
+ public int getTotalScore() {
+ return totalScore;
+ }
}
不吉なにおいがしたらリファクタ
コードの「不吉なにおい」をきっかけに行う
- 重複コード
- 行数が長いメソッド
- 多すぎる引数 etc.
※テストコードも対象
// テストコードリファクタ(重複コード除去:メソッド抽出)
import static org.assertj.core.api.Assertions.assertThat;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
+ private BowlingGame game;
+
+ @BeforeEach
+ public void setup() {
+ game = new BowlingGame();
+ }
@Test
public void test_test_all_Garter() {
- var game = new BowlingGame();
- for(int count = 0; count < 20; count++) {
- game.recordOneShot(0);
- }
+ recordSamePinsManyShot(20, 0);
assertThat(game.getTotalScore()).isEqualTo(0);
}
@Test
public void test_all_one_pin() {
- var game = new BowlingGame();
- for (int count = 0; count < 20; count++) {
- game.recordOneShot(1);
- }
+ recordSamePinsManyShot(20, 1);
assertThat(game.getTotalScore()).isEqualTo(20);
}
+ private void recordSamePinsManyShot(int shotCount, int pins) {
+ for (int count = 0; count < shotCount; count++) {
+ game.recordOneShot(pins);
+ }
+ }
}
少し複雑なケース:スペア(次の回のピン数が加算される)
Red
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
private BowlingGame game;
@BeforeEach
public void setup() {
game = new BowlingGame();
}
// ry
+ @Test
+ public void test_spare() {
+ game.recordOneShot(4);
+ game.recordOneShot(6); // スペア 10+5=15
+ game.recordOneShot(5);
+ recordSamePinsManyShot(17, 0);
+ assertThat(game.getTotalScore()).isEqualTo(20);
+ }
private void recordSamePinsManyShot(int shotCount, int pins) {
for (int count = 0; count < shotCount; count++) {
game.recordOneShot(pins);
}
}
}
修正する際の考え方
-
修正方法を考える
- 原因を考える → 前の投球状態を覚えてないから
- 回避策を考える → スペアフラグを用意
- ※この時、他の問題は考えない(ストライク、10フレーム目など)インクリメンタルにやる
-
手順を考える
- スペアフラグ追加
- 合計点が10点→フラグON、フラグがONなら加算
↓ 修正してみる
public class BowlingGame {
private int totalScore = 0;
+ private boolean isSpare = false;
public void recordOneShot(int pins) {
totalScore += pins;
+ if (isSpare) {
+ totalScore += pins;
+ isSpare = false;
+ }
+ if (totalScore == 10) {
+ isSpare = true;
+ }
}
public int getTotalScore() {
return totalScore;
}
}
- 既存テスト失敗したら通るように修正
- 原因 → 合計点が10の時にフラグONにしたから
- 回避策 → 条件変更:1回前のピン数との合計が10になったらフラグON
package bowling;
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
+ private int beforePins = 0;
public void recordOneShot(int pins) {
totalScore += pins;
if (isSpare) {
totalScore += pins;
isSpare = false;
}
- if (totalScore == 10) {
+ if (pins + beforePins == 10) {
isSpare = true;
}
+ beforePins = pins;
}
public int getTotalScore() {
return totalScore;
}
}
今の実装の死角をテストしてみる
Red
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
private BowlingGame game;
@BeforeEach
public void setup() {
game = new BowlingGame();
}
// ry
+ @Test
+ public void test_not_spare_when_before_and_current_total_10() {
+ game.recordOneShot(2);
+ game.recordOneShot(6);
+ game.recordOneShot(4);
+ game.recordOneShot(7);
+ recordSamePinsManyShot(16, 0);
+ assertThat(game.getTotalScore()).isEqualTo(19);
+ }
private void recordSamePinsManyShot(int shotCount, int pins) {
for (int count = 0; count < shotCount; count++) {
game.recordOneShot(pins);
}
}
}
Green
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private int beforePins = 0;
private int shotCount = 1;
public void recordOneShot(int pins) {
totalScore += pins;
if (isSpare) {
totalScore += pins;
isSpare = false;
}
- if (pins + beforePins == 10) {
+ if (shotCount == 2 && pins + beforePins == 10) {
isSpare = true;
}
beforePins = pins;
+ shotCount = shotCount == 1 ? 2 : 1;
}
public int getTotalScore() {
return totalScore;
}
}
5.複雑なテストケース:ストライク/連続ストライク
ストライク
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
private BowlingGame game;
@BeforeEach
public void setup() {
game = new BowlingGame();
}
// ry
+ @Test
+ public void test_strike() {
+ game.recordOneShot(10);
+ game.recordOneShot(3);
+ game.recordOneShot(4);
+ game.recordOneShot(2);
+ recordSamePinsManyShot(15, 0);
+ assertThat(game.getTotalScore()).isEqualTo(26);
+ }
private void recordSamePinsManyShot(int shotCount, int pins) {
for (int count = 0; count < shotCount; count++) {
game.recordOneShot(pins);
}
}
}
- 修正
- 原因:ストライクの判断ができない
- 回避策:現ショットより後の得点加算枠数を設ける
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private int beforePins = 0;
private int shotCount = 1;
+ private int strikeAddScoreCount = 0;
public void recordOneShot(int pins) {
totalScore += pins;
if (isSpare) {
totalScore += pins;
isSpare = false;
}
if (shotCount == 2 && pins + beforePins == 10) {
isSpare = true;
}
+ if (strikeAddScoreCount > 0) {
+ totalScore += pins;
+ strikeAddScoreCount -= 1;
+ }
+ if (pins == 10) {
+ strikeAddScoreCount = 2;
+ }
beforePins = pins;
shotCount = shotCount == 1 ? 2 : 1;
}
public int getTotalScore() {
return totalScore;
}
}
定期的にリファクタリング
メソッド抽出
package bowling;
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private int beforePins = 0;
private int shotCount = 1;
private int strikeAddScoreCount = 0;
public void recordOneShot(int pins) {
totalScore += pins;
- if (isSpare) {
- totalScore += pins;
- isSpare = false;
- }
- if (shotCount == 2 && pins + beforePins == 10) {
- isSpare = true;
- }
-
- if (strikeAddScoreCount > 0) {
- totalScore += pins;
- strikeAddScoreCount -= 1;
- }
- if (pins == 10) {
- strikeAddScoreCount = 2;
- }
+ calcSpareAddScore(pins);
+ calcStrikeAddScore(pins);
beforePins = pins;
shotCount = shotCount == 1 ? 2 : 1;
}
+ public int getTotalScore() {
+ return totalScore;
+ }
+
+ private void calcSpareAddScore(int pins) {
+ if (isSpare) {
+ totalScore += pins;
+ isSpare = false;
+ }
+ if (shotCount == 2 && pins + beforePins == 10) {
+ isSpare = true;
+ }
+ }
+
+ private void calcStrikeAddScore(int pins) {
+ if (strikeAddScoreCount > 0) {
+ totalScore += pins;
+ strikeAddScoreCount -= 1;
+ }
+ if (pins == 10) {
+ strikeAddScoreCount = 2;
+ }
+ }
}
ストライク2連続
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
private BowlingGame game;
@BeforeEach
public void setup() {
game = new BowlingGame();
}
// ry
+ @Test
+ public void test_strike_double() {
+ game.recordOneShot(10);
+ game.recordOneShot(10);
+ game.recordOneShot(4);
+ game.recordOneShot(2);
+ recordSamePinsManyShot(14, 0);
+ assertThat(game.getTotalScore()).isEqualTo(46);
+ }
private void recordSamePinsManyShot(int shotCount, int pins) {
for (int count = 0; count < shotCount; count++) {
game.recordOneShot(pins);
}
}
}
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private int beforePins = 0;
private int shotCount = 1;
private int strikeAddScoreCount = 0;
+ private int doubleAddScoreCount = 0;
public void recordOneShot(int pins) {
totalScore += pins;
calcSpareAddScore(pins);
calcStrikeAddScore(pins);
beforePins = pins;
shotCount = shotCount == 1 ? 2 : 1;
}
public int getTotalScore() {
return totalScore;
}
private void calcSpareAddScore(int pins) {
if (isSpare) {
totalScore += pins;
isSpare = false;
}
if (shotCount == 2 && pins + beforePins == 10) {
isSpare = true;
}
}
private void calcStrikeAddScore(int pins) {
if (strikeAddScoreCount > 0) {
totalScore += pins;
strikeAddScoreCount -= 1;
}
+ if (doubleAddScoreCount > 0) {
+ totalScore += pins;
+ doubleAddScoreCount -= 1;
+ }
if (pins == 10) {
+ if (strikeAddScoreCount == 0) {
strikeAddScoreCount = 2;
+ } else {
+ doubleAddScoreCount = 2;
+ }
}
}
}
ストライク3連続のテストケース追加
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
private BowlingGame game;
@BeforeEach
public void setup() {
game = new BowlingGame();
}
// ry
+ @Test
+ public void test_strike_turkey() {
+ game.recordOneShot(10);
+ game.recordOneShot(10);
+ game.recordOneShot(10);
+ game.recordOneShot(4);
+ game.recordOneShot(2);
+ recordSamePinsManyShot(12, 0);
+ assertThat(game.getTotalScore()).isEqualTo(76);
+ }
private void recordSamePinsManyShot(int shotCount, int pins) {
for (int count = 0; count < shotCount; count++) {
game.recordOneShot(pins);
}
}
}
複雑なケース(続):ストライクとスペアの複合
ストライク1回&スペア1回
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
private BowlingGame game;
@BeforeEach
public void setup() {
game = new BowlingGame();
}
// ry
+ @Test
+ public void test_strike_and_spare() {
+ game.recordOneShot(10);
+ game.recordOneShot(6);
+ game.recordOneShot(4);
+ game.recordOneShot(2);
+ recordSamePinsManyShot(15, 0);
+ assertThat(game.getTotalScore()).isEqualTo(34);
+ }
private void recordSamePinsManyShot(int shotCount, int pins) {
for (int count = 0; count < shotCount; count++) {
game.recordOneShot(pins);
}
}
}
- 原因:ストライクの後、shotCountが1に戻らない
- 回避策:shotCountの条件追加
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private int beforePins = 0;
private int shotCount = 1;
private int strikeAddScoreCount = 0;
private int doubleAddScoreCount = 0;
public void recordOneShot(int pins) {
totalScore += pins;
calcSpareAddScore(pins);
calcStrikeAddScore(pins);
beforePins = pins;
- shotCount = shotCount == 1 ? 2 : 1;
+ shotCount = shotCount == 1 && strikeAddScoreCount < 2 ? 2 : 1;
}
public int getTotalScore() {
return totalScore;
}
private void calcSpareAddScore(int pins) {
if (isSpare) {
totalScore += pins;
isSpare = false;
}
if (shotCount == 2 && pins + beforePins == 10) {
isSpare = true;
}
}
private void calcStrikeAddScore(int pins) {
if (strikeAddScoreCount > 0) {
totalScore += pins;
strikeAddScoreCount -= 1;
}
if (doubleAddScoreCount > 0) {
totalScore += pins;
doubleAddScoreCount -= 1;
}
if (pins == 10) {
if (strikeAddScoreCount == 0) {
strikeAddScoreCount = 2;
} else {
doubleAddScoreCount = 2;
}
}
}
}
ストライク2連続+スペア
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
private BowlingGame game;
@BeforeEach
public void setup() {
game = new BowlingGame();
}
// ry
+ @Test
+ public void test_strike_double_and_spare() {
+ game.recordOneShot(10);
+ game.recordOneShot(10);
+ game.recordOneShot(6);
+ game.recordOneShot(4);
+ game.recordOneShot(2);
+ recordSamePinsManyShot(13, 0);
+ assertThat(game.getTotalScore()).isEqualTo(26+20+12+2);
+ }
private void recordSamePinsManyShot(int shotCount, int pins) {
for (int count = 0; count < shotCount; count++) {
game.recordOneShot(pins);
}
}
}
- 原因:ストライク2連続の時も shotCountが1に戻らない
- 回避策:同様にshotCountの条件追加
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private int beforePins = 0;
private int shotCount = 1;
private int strikeAddScoreCount = 0;
private int doubleAddScoreCount = 0;
public void recordOneShot(int pins) {
totalScore += pins;
calcSpareAddScore(pins);
calcStrikeAddScore(pins);
beforePins = pins;
- shotCount = shotCount == 1 && strikeAddScoreCount < 2 ? 2 : 1;
+ shotCount = shotCount == 1 && strikeAddScoreCount < 2 && doubleAddScoreCount < 2 ? 2 : 1;
}
public int getTotalScore() {
return totalScore;
}
private void calcSpareAddScore(int pins) {
if (isSpare) {
totalScore += pins;
isSpare = false;
}
if (shotCount == 2 && pins + beforePins == 10) {
isSpare = true;
}
}
private void calcStrikeAddScore(int pins) {
if (strikeAddScoreCount > 0) {
totalScore += pins;
strikeAddScoreCount -= 1;
}
if (doubleAddScoreCount > 0) {
totalScore += pins;
doubleAddScoreCount -= 1;
}
if (pins == 10) {
if (strikeAddScoreCount == 0) {
strikeAddScoreCount = 2;
} else {
doubleAddScoreCount = 2;
}
}
}
}
リファクタ(メソッド抽出)
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private int beforePins = 0;
private int shotCount = 1;
private int strikeAddScoreCount = 0;
private int doubleAddScoreCount = 0;
public void recordOneShot(int pins) {
totalScore += pins;
calcSpareAddScore(pins);
calcStrikeAddScore(pins);
beforePins = pins;
shotCount = shotCount == 1 && strikeAddScoreCount < 2 && doubleAddScoreCount < 2 ? 2 : 1;
}
public int getTotalScore() {
return totalScore;
}
private void calcSpareAddScore(int pins) {
if (isSpare) {
totalScore += pins;
isSpare = false;
}
checkSpare(pins);
}
private void checkSpare(int pins) {
if (shotCount == 2 && pins + beforePins == 10) {
isSpare = true;
}
}
private void calcStrikeAddScore(int pins) {
- if (strikeAddScoreCount > 0) {
- totalScore += pins;
- strikeAddScoreCount -= 1;
- }
- if (doubleAddScoreCount > 0) {
- totalScore += pins;
- doubleAddScoreCount -= 1;
- }
- if (pins == 10) {
- if (strikeAddScoreCount == 0) {
- strikeAddScoreCount = 2;
- } else {
- doubleAddScoreCount = 2;
- }
- }
+ addStrikeScore(pins);
+ addDoubleScore(pins);
+ if (isStrike(pins)) {
+ recognizeStrikeAddCount();
+ }
}
+ private void addStrikeScore(int pins) {
+ if (strikeAddScoreCount > 0) {
+ totalScore += pins;
+ strikeAddScoreCount -= 1;
+ }
+ }
+
+ private void addDoubleScore(int pins) {
+ if (doubleAddScoreCount > 0) {
+ totalScore += pins;
+ doubleAddScoreCount -= 1;
+ }
+ }
+
+ private boolean isStrike(int pins) {
+ return pins == 10;
+ }
+
+ private void recognizeStrikeAddCount() {
+ if (strikeAddScoreCount == 0) {
+ strikeAddScoreCount = 2;
+ } else {
+ doubleAddScoreCount = 2;
+ }
+ }
}
フレーム毎の点数取得
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
private BowlingGame game;
@BeforeEach
public void setup() {
game = new BowlingGame();
}
// ry
+ @Test
+ public void test_one_frame_score_when_all_garter() {
+ recordSamePinsManyShot(20, 0);
+ assertThat(game.getFrameScore(1)).isEqualTo(0);
+ }
private void recordSamePinsManyShot(int shotCount, int pins) {
for (int count = 0; count < shotCount; count++) {
game.recordOneShot(pins);
}
}
}
public BowlingGame {
// ry
+ public int getFrameScore(int frameNum) {
+ return 0;
+ }
}
手詰まり感が出てきたら静的設計を見直し
- 計算が必要なテストケースを追加しようとすると…これまでの繰り返しになる?
- 手詰まり感が出てきた場合は、静的設計を見直す
※動的設計と静的設計を相互に行うことで、設計が徐々に洗練される
Frameクラス導入
public class Frame {
public void recordOneShot(int pins) {
}
public int getScore(int frameNum) {
return 0;
}
}
全投球1ピンだけ倒した場合
Red
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class FrameTest {
private Frame frame;
@BeforeEach
public void setup() {
frame = new Frame();
}
@Test
public void test_one_frame_score_when_all_one_pin() {
frame.recordOneShot(1);
assertThat(frame.getScore(1)).isEqualTo(1);
}
}
public class Frame {
private int score = 0;
public void recordOneShot(int pins) {
+ score += pins;
}
public int getScore(int frameNum) {
- return 0;
+ return score;
}
}
BowlingGameにFrame組み込み
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private int beforePins = 0;
private int shotCount = 1;
private int strikeAddScoreCount = 0;
private int doubleAddScoreCount = 0;
+ private Frame frame = new Frame();
// ry
+ public int getFrameScore(int frameNum) {
+ return frame.getScore(frameNum);
+ }
// ry
}
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
private BowlingGame game;
@BeforeEach
public void setup() {
game = new BowlingGame();
}
// ry
+ @Test
+ public void test_one_frame_score_when_all_garter() {
+ recordSamePinsManyShot(20, 0);
+ assertThat(game.getFrameScore(1)).isEqualTo(0);
+ }
private void recordSamePinsManyShot(int shotCount, int pins) {
for (int count = 0; count < shotCount; count++) {
game.recordOneShot(pins);
}
}
}
BowlingGameにFrame組み込み2(責務の分離)
- 静的設計見直し:Frameクラスに以下を委譲する
- 投球記録
- フレーム完了判定
- 投球結果判定(スペア/ストライク etc.)
- ストライク/スペア後の得点加算判定/記録
- 点数返却
※ MECE (Mutually Exclusive and Collectively Exhaustive):重複なく漏れなく という視点でクラス設計俯瞰視
※現時点で明確になっている機能だけに着目してリファクタを行う
フレーム完了判定
Red
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class FrameTest {
private Frame frame;
@BeforeEach
public void setup() {
frame = new Frame();
}
// ry
+ @Test
+ public void test_frame_finish_two_shot() {
+ frame.recordOneShot(1);
+ assertThat(frame.isFinished()).isFalse();
+ frame.recordOneShot(1);
+ assertThat(frame.isFinished()).isTrue();
+ }
}
package bowling;
public class Frame {
private int score = 0;
+ private int shotCount = 0;
public void recordOneShot(int pins) {
score += pins;
+ shotCount++;
}
public int getScore(int frameNum) {
return score;
}
+ public boolean isFinished() {
+ return shotCount >= 2;
+ }
}
フレーム完了判定(ストライク)
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class FrameTest {
private Frame frame;
@BeforeEach
public void setup() {
frame = new Frame();
}
// ry
+ @Test
+ public void test_frame_finish_strike() {
+ var frame = new Frame();
+ frame.recordOneShot(10);
+ assertThat(frame.isFinished()).isTrue();
+ }
}
public class Frame {
private int score = 0;
private int shotCount = 0;
public void recordOneShot(int pins) {
score += pins;
shotCount++;
}
public int getScore(int frameNum) {
return score;
}
public boolean isFinished() {
- return shotCount >= 2;
+ return shotCount >= 2 || score >= 10;
}
}
BowlingGameにフレーム判定組み込み
全投球1ピンだと全フレーム2点
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class BowlingGameTest {
private BowlingGame game;
@BeforeEach
public void setup() {
game = new BowlingGame();
}
// ry
+ @Test
+ public void test_one_frame_score_is_2_when_all_one_pin() {
+ recordSamePinsManyShot(20, 1);
+ for (int frameNum = 0; frameNum < 10; frameNum++) {
+ assertThat(game.getFrameScore(frameNum + 1)).isEqualTo(2);
+ }
+ }
private void recordSamePinsManyShot(int shotCount, int pins) {
for (int count = 0; count < shotCount; count++) {
game.recordOneShot(pins);
}
}
}
import java.util.LinkedList;
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private int beforePins = 0;
private int shotCount = 1;
private int strikeAddScoreCount = 0;
private int doubleAddScoreCount = 0;
- private Frame frame = new Frame();
+ private final LinkedList<Frame> frames = new LinkedList<>() {
+ {
+ add(new Frame());
+ }
+ };
public void recordOneShot(int pins) {
+ var frame = frames.getLast();
+ frame.recordOneShot(pins);
totalScore += pins;
calcSpareAddScore(pins);
calcStrikeAddScore(pins);
beforePins = pins;
shotCount = shotCount == 1 && strikeAddScoreCount < 2 && doubleAddScoreCount < 2 ? 2 : 1;
+ if (frame.isFinished()) {
+ frames.add(new Frame());
+ }
}
public int getTotalScore() {
return totalScore;
}
+ public int getFrameScore(int frameNum) {
+ return frames.get(frameNum - 1).getScore();
+ }
// ry
}
投球結果判定
スペア
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
public class FrameTest {
private Frame frame;
@BeforeEach
public void setup() {
frame = new Frame();
}
// ry
+ @Test
+ public void test_spare_when_defect_10_pins_in_second_shot_of_frame() {
+ frame.recordOneShot(5);
+ assertThat(frame.isSpare()).isFalse();
+ frame.recordOneShot(5);
+ assertThat(frame.isSpare()).isTrue();
+ }
}
public class Frame {
private int score = 0;
private int shotCount = 0;
public void recordOneShot(int pins) {
score += pins;
shotCount++;
}
public int getScore() {
return score;
}
public boolean isFinished() {
return shotCount >= 2 || score >= 10;
}
+ public boolean isSpare() {
+ return score == 10 && shotCount >= 2;
+ }
}
ストライク
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class FrameTest {
private Frame frame;
@BeforeEach
public void setup() {
frame = new Frame();
}
//ry
+ @Test
+ public void test_strike_when_defect_10_pins_in_first_shot_of_frame() {
+ assertThat(frame.isStrike()).isFalse();
+ frame.recordOneShot(10);
+ assertThat(frame.isStrike()).isTrue();
+ }
}
public class Frame {
private int score = 0;
private int shotCount = 0;
public void recordOneShot(int pins) {
score += pins;
shotCount++;
}
public int getScore() {
return score;
}
public boolean isFinished() {
return shotCount >= 2 || score >= 10;
}
public boolean isSpare() {
return score == 10 && shotCount >= 2;
}
+ public boolean isStrike() {
+ return score == 10 && shotCount == 1;
+ }
}
BowlingGameに投球判定を組み込む
スペア
import java.util.LinkedList;
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private int strikeAddScoreCount = 0;
private int doubleAddScoreCount = 0;
private final LinkedList<Frame> frames = new LinkedList<>() {
{
add(new Frame());
}
};
public void recordOneShot(int pins) {
var frame = frames.getLast();
frame.recordOneShot(pins);
totalScore += pins;
calcSpareAddScore(pins);
calcStrikeAddScore(pins);
if (frame.isFinished()) {
frames.add(new Frame());
}
}
public int getTotalScore() {
return totalScore;
}
public int getFrameScore(int frameNum) {
return frames.get(frameNum - 1).getScore();
}
private void calcSpareAddScore(int pins) {
if (isSpare) {
totalScore += pins;
isSpare = false;
}
- checkSpare(pins);
+ if (frames.getLast().isSpare()) {
+ isSpare = true;
+ }
}
- private void checkSpare(int pins) {
- if (shotCount == 2 && pins + beforePins == 10) {
- isSpare = true;
- }
- }
private void calcStrikeAddScore(int pins) {
addStrikeScore(pins);
addDoubleScore(pins);
if (isStrike(pins)) {
recognizeStrikeAddCount();
}
}
private void addStrikeScore(int pins) {
if (strikeAddScoreCount > 0) {
totalScore += pins;
strikeAddScoreCount -= 1;
}
}
private void addDoubleScore(int pins) {
if (doubleAddScoreCount > 0) {
totalScore += pins;
doubleAddScoreCount -= 1;
}
}
private boolean isStrike(int pins) {
return pins == 10;
}
private void recognizeStrikeAddCount() {
if (strikeAddScoreCount == 0) {
strikeAddScoreCount = 2;
} else {
doubleAddScoreCount = 2;
}
}
}
ストライク
import java.util.LinkedList;
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private int strikeAddScoreCount = 0;
private int doubleAddScoreCount = 0;
private final LinkedList<Frame> frames = new LinkedList<>() {
{
add(new Frame());
}
};
public void recordOneShot(int pins) {
var frame = frames.getLast();
frame.recordOneShot(pins);
totalScore += pins;
calcSpareAddScore(pins);
calcStrikeAddScore(pins);
if (frame.isFinished()) {
frames.add(new Frame());
}
}
public int getTotalScore() {
return totalScore;
}
public int getFrameScore(int frameNum) {
return frames.get(frameNum - 1).getScore();
}
private void calcSpareAddScore(int pins) {
if (isSpare) {
totalScore += pins;
isSpare = false;
}
if (frames.getLast().isSpare()) {
isSpare = true;
}
}
private void calcStrikeAddScore(int pins) {
addStrikeScore(pins);
addDoubleScore(pins);
- if (isStrike(pins)) {
+ if (frames.getLast().isStrike()) {
recognizeStrikeAddCount();
}
}
private void addStrikeScore(int pins) {
if (strikeAddScoreCount > 0) {
totalScore += pins;
strikeAddScoreCount -= 1;
}
}
private void addDoubleScore(int pins) {
if (doubleAddScoreCount > 0) {
totalScore += pins;
doubleAddScoreCount -= 1;
}
}
private void recognizeStrikeAddCount() {
if (strikeAddScoreCount == 0) {
strikeAddScoreCount = 2;
} else {
doubleAddScoreCount = 2;
}
}
}
ストライク/スペア後の(ボーナス)得点加算判定/記録
Frameに移す
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class FrameTest {
private Frame frame;
@BeforeEach
public void setup() {
frame = new Frame();
}
// ry
+ @Test
+ public void test_spare_add_bonus_score() {
+ frame.recordOneShot(5);
+ frame.recordOneShot(5);
+ frame.addBonusScore(5);
+ assertThat(frame.getScore()).isEqualTo(15);
+ }
}
package bowling;
public class Frame {
private int score = 0;
private int shotCount = 0;
public void recordOneShot(int pins) {
score += pins;
shotCount++;
}
public int getScore() {
return score;
}
public boolean isFinished() {
return shotCount >= 2 || score >= 10;
}
public boolean isSpare() {
return score == 10 && shotCount >= 2;
}
public boolean isStrike() {
return score == 10 && shotCount == 1;
}
+ public void addBonusScore(int bonusScore) {
+ score += bonusScore;
+ }
}
BowlingGameにボーナス得点加算判定/記録を組み込む
スペア
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class FrameTest {
private Frame frame;
@BeforeEach
public void setup() {
frame = new Frame();
}
// ry
+ @Test
+ public void test_spare_frame_score_add_next_pins() {
+ game.recordOneShot(4);
+ game.recordOneShot(6);
+ game.recordOneShot(5);
+ assertThat(game.getFrameScore(1)).isEqualTo(15);
+ assertThat(game.getTotalScore()).isEqualTo(20);
+ }
}
import java.util.LinkedList;
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
+ private Frame spareFrame = null;
private int strikeAddScoreCount = 0;
private int doubleAddScoreCount = 0;
private final LinkedList<Frame> frames = new LinkedList<>() {
{
add(new Frame());
}
};
// ry
private void calcSpareAddScore(int pins) {
if (isSpare) {
totalScore += pins;
isSpare = false;
+ spareFrame.addBonusScore(pins);
+ spareFrame = null;
}
if (frames.getLast().isSpare()) {
isSpare = true;
+ spareFrame = frames.getLast();
}
}
// ry
}
ストライク
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class FrameTest {
private Frame frame;
@BeforeEach
public void setup() {
frame = new Frame();
}
// ry
@Test
public void test_strike_frame_score_add_twice_next_pins() {
game.recordOneShot(10);
game.recordOneShot(3);
game.recordOneShot(4);
game.recordOneShot(2);
recordSamePinsManyShot(15, 0);
assertThat(game.getTotalScore()).isEqualTo(26);
assertThat(game.getFrameScore(1)).isEqualTo(17);
}
}
import java.util.LinkedList;
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private Frame spareFrame = null;
+ private Frame strikeFrame = null;
private int strikeAddScoreCount = 0;
private int doubleAddScoreCount = 0;
private final LinkedList<Frame> frames = new LinkedList<>() {
{
add(new Frame());
}
};
// ry
private void addStrikeScore(int pins) {
if (strikeAddScoreCount > 0) {
totalScore += pins;
strikeAddScoreCount -= 1;
+ strikeFrame.addBonusScore(pins);
}
}
private void addDoubleScore(int pins) {
if (doubleAddScoreCount > 0) {
totalScore += pins;
doubleAddScoreCount -= 1;
}
}
private void recognizeStrikeAddCount() {
if (strikeAddScoreCount == 0) {
strikeAddScoreCount = 2;
+ strikeFrame = frames.getLast();
} else {
doubleAddScoreCount = 2;
}
}
}
ストライク(2連続)
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class FrameTest {
private Frame frame;
@BeforeEach
public void setup() {
frame = new Frame();
}
// ry
+ @Test
+ public void test_strike_double_frame_score() {
+ game.recordOneShot(10);
+ game.recordOneShot(10);
+ game.recordOneShot(4);
+ game.recordOneShot(2);
+ recordSamePinsManyShot(14, 0);
+ assertThat(game.getTotalScore()).isEqualTo(46);
+ assertThat(game.getFrameScore(1)).isEqualTo(24);
+ assertThat(game.getFrameScore(2)).isEqualTo(16);
+ }
}
import java.util.LinkedList;
public class BowlingGame {
private int totalScore = 0;
private boolean isSpare = false;
private Frame spareFrame = null;
private Frame strikeFrame = null;
+ private Frame doubleFrame = null;
private int strikeAddScoreCount = 0;
private int doubleAddScoreCount = 0;
private final LinkedList<Frame> frames = new LinkedList<>() {
{
add(new Frame());
}
};
// ry
private void addDoubleScore(int pins) {
if (doubleAddScoreCount > 0) {
totalScore += pins;
doubleAddScoreCount -= 1;
+ doubleFrame.addBonusScore(pins);
}
}
private void recognizeStrikeAddCount() {
if (strikeAddScoreCount == 0) {
strikeAddScoreCount = 2;
strikeFrame = frames.getLast();
} else {
doubleAddScoreCount = 2;
+ doubleFrame = frames.getLast();
}
}
}
続くが保留
次は、strikeAddScoreCount、doubleAddScoreCount による判定も Frameに移す
ここまででも、ある程度イメージできるかと思われるため一旦ここまで(気力尽き)
TDDの本質的イメージ
あくまで私の理解のためあしからず。
抽象的なイメージ
まず(雑な画像で申し訳ないが)画像から以下のようなイメージをして欲しい。
- 以下の一番外の円が開発対象全体であり、その中にはいくつもの問題/課題(以下画像のグレーの〇)が存在している = 開発対象は問題/課題の集合体(塊)
- 円の中央に進めば進むほど対象の核心となるような問題が存在している
- 対象への理解が乏しく、開発者は外側から見える問題/課題しか認識できていない
上記を踏まえ、実際に開発を進めていくとなると、対象への理解が乏しい場合、この塊に対して、
- 何から手を付けていけばいいか分からない
- どう進めていけばいいか分からない
となると思う。そこに対してTDDは、
外側から1つずつ解けるものから順番に解いていく。それが一見回り道だったしても。
最短ルートで問題達を解決していけるのが理想だが、まだ見えてない内側の部分がある状態で、そもそも最短ルートで解いていくというのは現実的に不可能である。また、最短だと考えて複数の問題を同時に解こうとするとかえってややこしくなり余計に時間がかかるし、複雑な作り込みをしてしまうことは往々にして起きるだろう。
だからこそ、小さい粒度に分解し、1つずつ問題を解いていく。
1ずつ解きほどいていくことで、少しずつ核心に近づくとともに対象への理解を進めていく。核心もしくはそれに近しい問題に到達する頃には対象への理解が深まり、その問題に対して正しい判断をすることができる状態になる(だからこそ、正しい判断ができるまでは決めないでおく。正しい判断がまだできない状態で仮決めてしてもそれは概ね余分な作り込みになってしまうから)。
実際に実装する時には、どの問題を解くかというのをテストケースを書くことで定義/宣言するのだと思う。
TDDのアプローチのイメージ
上記を踏まえて、TDDのアプローチに紐づけて考えると、TDDは、
小さい粒度の目標(目指すべきゴールや解決したい問題)をテストケースを書くことで定め、目の前の1つの目標だけに注力する。これを繰り返し行っていく。
それを、静的設計と動的設計さえも分割して行うことで、
- 静的設計 ≒ ロジック/アルゴリズムだけを考える作業 ( Red → Green の時 )
- 動的設計 ≒ クラス/インターフェース設計だけを考える作業 ( Refactor の時 )
片方をやるときは片方に注力でき、Red/Green/Refactorのサイクルを回すことで(開発者の力量によるが)ある程度設計の質を高い状態に保ちつつ、設計/実装を洗練していけるのだと考える。
また、Refactor時は以下を担保に得られる安心と信頼に基づいているため、動的設計を考える作業に注力できる。
- 目標を達成するために必要なロジックを理解/実装したという実績
- テストというOK/NGを判定してくれる絶対的指標
と私は理解した。以上