2
0

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.

TDDに取り組むため実践イメージと本質的イメージを押さえる

Posted at

はじめに

TDDに取り組むため、そして他者にもTDDに対する本質的なところのイメージを共有するために、本記事を記す。
※イメージ重視。一部まとめ終わっていない箇所ありますがご容赦ください。

TDDとは

テスト技法ではなく、設計/分析技法

  • 価値
    • インクリメンタルな設計を促す開発手法
    • チームに良いリズムを生む
    • 開発に自信と信頼をもたらす
  • 原則
    • 実装する前にテストを作ること
    • 問題を放置せず少しずつ前進させること
    • 大事なのは完璧さより自信を持てる状態を保つこと
  • ルール
    • (自動化された)テストが失敗したときのみ、新しいコードを書く
    • 重複を除去する

インクリメンタルな設計とは

  • できるだけ無駄なく価値あるものを作るために
  • 開発対象への理解が進むほど正しい判断が可能になるため
  • 何を作るか正しい判断ができるまでは決めないでおくが
  • 最低限必要な機能から少しずつ開発を進め設計自体も少しずつ進化させつつ
  • 開発対象への理解度を少しずつ上げることで、正しい判断をできるようにする

適したケース:アジャイル
・作りたいものが決まっていない/分かっていない
・開発チームが開発対象を十分に理解していない
・開発対象が複雑で正しい設計を誰も知らない

流れ

  • Red   :失敗するテストを書く(機能追加/条件追加/バグ再現 etc.)
  • Green  :テストを成功させる(最小限のテストを書く、重複コードを書く等の罪を犯してよい)
  • Refactor :動作を保ったまま設計を洗練する(テストを通すために書いた重複除去)

上記のサイクルを回すことで、リズミカルかつ自信と信頼に基づき、インクリメンタルな設計を行っていく

行ううえでのポイント

  • TODOリストに必要なテストを書く
    • 着手する前に必要になりそうなテストを書きだす(現時点で認知できるものだけでいい)
    • 「すぐにやる」「あとでやる」「まったくやる必要なし」の3つリストに振り分ける
    • 実装しなければならない振舞いを考えられるだけ書き出し、まだ実装がない操作をリストに加える
    • 今書いたコードを綺麗にするためのリファクタも書きだす
    • 新しいテストを思い付いたらリストに書く。リファクタも同様
  • 機能は実際に必要となるまでは追加しない方が良い
    • きっと後で必要になるだろう→9割無駄になる
    • 今必要とするもの以上の機能を追加すると設計が複雑になる
    • 必要ない機能を追加すると、他メンバーが読む時間やドキュメントへの記載などリソースが奪われるだけ
  • コードを早く・バグを減らす
    • 最適な方法は、実装するコードを必要最低限に留めること
  • 静的設計と動的設計を相互に行き来きすることで、設計を洗練していく
    • 静的設計(責任を設計)
      • インターフェース
        • 問題を最も単純化できるクラス/メソッド構成
      • 名称(クラス/メソッド等)
        • 責任内容を端的に表現
    • 動的設計(仕事の正しさを設計)
      • 振舞い
        • 処理内容:どういう手順でどのクラス/メソッドを使い処理を行うか
      • 事前条件/事後条件
        • In/Out:事前条件に対して結果(事後条件)は何か

実践イメージのサンプル

題材:ボウリング点数計算
使用環境:

  • IntelliJ Community版
  • Java 11 ※古くて申し訳ない
  • Gradle ※下2つのライブラリ取得/管理とビルド実行のために
  • JUnit5
  • AssertJ

まずは簡単かつ先に進む道筋足り得るものから

全てガーターのケース

スタートライン

最初に追加するテストは、「何もしないテスト」を書く

Red

Untitled 2.png

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

クラス追加
Untitled 3.png

public class BowlingGame {

}

最小のメソッドを追加

Red

全投球ガーターのテストケースにする

Untitled 4.png

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

Untitled 5.png

public class BowlingGame {

+ public void recordOneShot(int pins) {
+ }

+ public int score() {
+   return 0;
+ }
}

全部1ピンだけ倒したケース

Red
Untitled 7.png

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

Untitled 8.png

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

Untitled 9.png

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;
  }
}

Untitled 10.png

  • 既存テスト失敗したら通るように修正
    • 原因 → 合計点が10の時にフラグONにしたから
    • 回避策 → 条件変更:1回前のピン数との合計が10になったらフラグON

Untitled 11.png

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

Untitled 12.png

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

Untitled 13.png

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.複雑なテストケース:ストライク/連続ストライク

ストライク

Red
Untitled 14.png

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);
    }
  }
}
  • 修正
    • 原因:ストライクの判断ができない
    • 回避策:現ショットより後の得点加算枠数を設ける

Green
Untitled 15.png

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連続

Red
Untitled 17.png

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);
    }
  }
}

Green
Untitled 18.png


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連続のテストケース追加

Green
Untitled 19.png


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回

Red
Untitled 20.png

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の条件追加

Green
Untitled 21.png

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連続+スペア

Red
Untitled 22.png

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の条件追加

Green
Untitled 23.png

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;
+   }
+ }
}

フレーム毎の点数取得

Red
Untitled 24.png

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);
    }
  }
}

Green
Untitled 25.png

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

Untitled 27.png

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);
  }
}

Green
Untitled 28.png

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);
    }
  }
}

Untitled 29.png

BowlingGameにFrame組み込み2(責務の分離)

  • 静的設計見直し:Frameクラスに以下を委譲する
    • 投球記録
    • フレーム完了判定
    • 投球結果判定(スペア/ストライク etc.)
    • ストライク/スペア後の得点加算判定/記録
    • 点数返却

※ MECE (Mutually Exclusive and Collectively Exhaustive):重複なく漏れなく という視点でクラス設計俯瞰視

※現時点で明確になっている機能だけに着目してリファクタを行う

フレーム完了判定

Red

Untitled 30.png

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();
+ }
}

Green
Untitled 31.png

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;
+ }
}

フレーム完了判定(ストライク)

Red
Untitled 32.png

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();
+ }
}

Green
Untitled 33.png

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点

Red
Untitled 34.png

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);
    }
  }
}

Green
Untitled 35.png

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

}

投球結果判定

スペア

Red
Untitled 36.png

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();
+ }
}

Green
Untitled 37.png

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;
+ }

}
ストライク

Red
Untitled 38.png

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();
+ }
}

Green
Untitled 39.png

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に投球判定を組み込む

スペア

Untitled 40.png

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;
    }
  }
}
ストライク

Untitled 41.png

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に移す

Red
Untitled 42.png

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);
+ }
}

Green
Untitled 43.png

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にボーナス得点加算判定/記録を組み込む

スペア

Red
Untitled 44.png

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);
+ }
}

Green
Untitled 45.png

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
}
ストライク

Red
Untitled 46.png

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);
  }
}

Green
Untitled 47.png

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連続)

Red
Untitled 48.png

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);
+ }
}

Green
Untitled 49.png

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の本質的イメージ

あくまで私の理解のためあしからず。

抽象的なイメージ

まず(雑な画像で申し訳ないが)画像から以下のようなイメージをして欲しい。

  • 以下の一番外の円が開発対象全体であり、その中にはいくつもの問題/課題(以下画像のグレーの〇)が存在している = 開発対象は問題/課題の集合体(塊)
  • 円の中央に進めば進むほど対象の核心となるような問題が存在している
  • 対象への理解が乏しく、開発者は外側から見える問題/課題しか認識できていない

image.png

上記を踏まえ、実際に開発を進めていくとなると、対象への理解が乏しい場合、この塊に対して、

  • 何から手を付けていけばいいか分からない
  • どう進めていけばいいか分からない

となると思う。そこに対してTDDは、
外側から1つずつ解けるものから順番に解いていく。それが一見回り道だったしても。
最短ルートで問題達を解決していけるのが理想だが、まだ見えてない内側の部分がある状態で、そもそも最短ルートで解いていくというのは現実的に不可能である。また、最短だと考えて複数の問題を同時に解こうとするとかえってややこしくなり余計に時間がかかるし、複雑な作り込みをしてしまうことは往々にして起きるだろう。

だからこそ、小さい粒度に分解し、1つずつ問題を解いていく。

1ずつ解きほどいていくことで、少しずつ核心に近づくとともに対象への理解を進めていく。核心もしくはそれに近しい問題に到達する頃には対象への理解が深まり、その問題に対して正しい判断をすることができる状態になる(だからこそ、正しい判断ができるまでは決めないでおく。正しい判断がまだできない状態で仮決めてしてもそれは概ね余分な作り込みになってしまうから)。

実際に実装する時には、どの問題を解くかというのをテストケースを書くことで定義/宣言するのだと思う。

TDDのアプローチのイメージ

上記を踏まえて、TDDのアプローチに紐づけて考えると、TDDは、

小さい粒度の目標(目指すべきゴールや解決したい問題)をテストケースを書くことで定め、目の前の1つの目標だけに注力する。これを繰り返し行っていく。

それを、静的設計と動的設計さえも分割して行うことで、

  • 静的設計 ≒ ロジック/アルゴリズムだけを考える作業 ( Red → Green の時 )
  • 動的設計 ≒ クラス/インターフェース設計だけを考える作業 ( Refactor の時 )

片方をやるときは片方に注力でき、Red/Green/Refactorのサイクルを回すことで(開発者の力量によるが)ある程度設計の質を高い状態に保ちつつ、設計/実装を洗練していけるのだと考える。

また、Refactor時は以下を担保に得られる安心と信頼に基づいているため、動的設計を考える作業に注力できる。

  • 目標を達成するために必要なロジックを理解/実装したという実績
  • テストというOK/NGを判定してくれる絶対的指標

と私は理解した。以上

参考

本:これからはじめるTDD テスト駆動開発入門

本:テスト駆動開発

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?