使用Intellij來實踐測試驅動開發 TDD Kata

使用Intellij來實踐測試驅動開發 TDD Kata

前言

本文描述瞭如何使用Intellij來實踐測試驅動開發(TDD Kata)。

編程環境:

創建Java Maven項目

IDEA中創建一個標準的含有JUnit的Java Maven項目的過程如下:

  1. File / New Project / Maven;
  2. 勾選"Create from archetype",選擇org.apache.maven.archetypes:maven-archetype-quickstart
  3. 輸入Name爲項目名稱,選擇Location爲項目路徑,展開Artifact Coordinates,輸入GroupId爲包路徑,ArtifactId默認爲項目名稱;
  4. 確認信息無誤後,開始創建項目;
  5. 點擊“Open Windows”打開項目;
  6. 在彈出框中選擇“Enable Auto-Import”;
  7. 編輯項目pom.xml,將maven.compiler.sourcemaven.compiler.target改爲1.8
  8. 右鍵選擇項目,Maven / Reimport;
  9. 刪除自動生成的App和AppTest類。

也可以自定義Maven archetype,通用運行Maven命令一鍵生成項目。

TheBowlingGame Kata

以Bob大叔的Bowling Game Kata爲例,講解如何通過IDEA來練習TDD Kata。

保齡球比賽計分規則:

  • 保齡球的計分不難,每一局(Game)總共有十格(Frame),每一格里面有兩次投球(Roll)。

  • 共有十支球瓶,要儘量在兩次投球之內把球瓶(Pin)全部擊倒,如果第一球就把全部的球瓶都擊倒了,也就是“STRIKE”,畫面出現“X”,就算完成一格了,所得分數就是10分再加下兩球的倒瓶數。

  • 但是如果第一球沒有全倒時,就要再打一球,如果剩下的球瓶全都擊倒,也就是“SPARE”,畫面出現“/”,也算完成一格,所得分數爲10分再加下一格第一球的倒瓶數。

  • 但是如果第二球也沒有把球瓶全部擊倒的話,那分數就是第一球加第二球倒的瓶數,再接着打下一格。

  • 依此類推直到第十格,但是第十格有三球,第十格時如果第一球或第二球將球瓶全部擊倒時,可再加打第三球。

參見:

在練習時只使用英文輸入法,避免頻繁切換輸入法,和避免在中文輸入法時IDEA快捷鍵不生效。

The Requirements

Write a class named Game that has two methods:

  • roll(pins : int) is called each time the player rolls a ball.The argument is the number of pins knocked down.
  • score() : int is called only at the very end of the game. It returns the total score for that game.

Step1: 創建項目

參見上面的“創建Java Maven項目”章節來創建項目:

  • 項目名稱:bowling-game-kata
  • GroupId: cn.xdevops.kata
  • ArtifactId: bowling-game-kata

Step2: 新建測試類

/src/test/java/cn.xdevops.kata目錄下創建測試類GameTest。

選擇/src/test/java/cn.xdevops.kata目錄:

# 新建
Ctrl + N

# 默認選擇新建Java Calss
Enter

# 輸入類名
GameTest

# 確認新建
Enter

# 光標跳到下一行
Shift + Enter

Step3: 編寫第1個測試方法

編寫一個最差的比賽結果(每次投球都沒有擊倒瓶子)的測試方法:

@Test
public void testGutterGame() {
  Game game = new Game();
}

因爲Game類還沒有創建,此時有編譯錯誤,先忽略。

Step4: 運行測試

將光標移動到GameTest類名一行:

# 運行測試
Shift + F10

因爲Game類還沒有創建,此時有編譯錯誤,所以測試失敗(紅)。

Step5: 修復編譯錯誤

將光標移動到Game類名上:

# 自動修復錯誤
Alter + Enter

# 默認選擇Create class
Enter

# 確認生成類
Enter

Step6: 再次運行測試

# 運行測試
Shift + F10

此時,測試通過(綠)。

Step7: 繼續修改測試方法

切換回測試類GameTest:

# 切換類
Command + E

# 切換爲上一個打開的類
Enter

在測試方法中增加邏輯:

@Test
public void testGutterGame() {
  Game game = new Game();
  for (int i = 0; i < 20; i ++) {
    game.roll(0);
  }
  assertEquals(0, game.score());
}

Step8: 修復編譯錯誤

因爲有很明顯的編譯錯誤,所以我們不再運行測試類,而是先修復編譯錯誤。

Alt + Enter來修復編譯錯誤:

  • Game類中創建roll()方法,修改參數名爲pins
  • 引入assertEquals
  • Game類中創建score()方法,返回值爲0

代碼示例:

public class Game {
    public void roll(int pins) {
    }

    public int score() {
        return 0;
    }
}

按下Shift + F10運行測試,測試通過(綠)。

Step9: 編寫第2個測試方法

增加一個簡單的測試方法,假設每次投球都擊倒1個瓶子。

有了前面的經驗,我們可以很快地寫出這個測試方法。

代碼示例:

@Test
public void testAllOnes() {
  Game game = new Game();
  for (int i = 0; i < 20; i ++) {
    game.roll(1);
  }
  assertEquals(20, game.score());
}

按下Shift + F10運行測試,因爲我們還沒有實現該功能,測試失敗(紅)。

Step10: 修復測試失敗

很容易想到,只要將每次投球擊倒的瓶子數量累加來作爲最後的分數就可以了。

代碼示例:

public class Game {
    private int score = 0;

    public void roll(int pins) {
        score += pins;
    }

    public int score() {
        return score;
    }
}

按下Shift + F10運行測試,測試通過(綠)。

Step11: 重構測試類

因爲測試類中存在了重複代碼,因此在繼續編寫新的測試方法前,需要先重構測試類。

將每個測試方法中的創建Game實例的語句抽取出來,寫成JUnit的setUp()方法。

public class GameTest {

    private Game game;

    @Before
    public void setUp() {
        game = new Game();
    }

    @Test
    public void testGutterGame() {
        for (int i = 0; i < 20; i ++) {
            game.roll(0);
        }
        assertEquals(0, game.score());
    }

    @Test
    public void testAllOnes() {
        for (int i = 0; i < 20; i ++) {
            game.roll(1);
        }
        assertEquals(20, game.score());
    }
}

按下Shift + F10運行測試,確保在重構後,測試仍然通過(綠)。

Step12: 繼續重構測試類

將測試類中的投20個球,每個球都擊倒一樣瓶子的方法抽取成一個方法:

# 選擇重複代碼

# 提取方法
Alt + Comand + M

# 輸入方法
rollMany

# Refactor
Enter

# 選擇使用原方法簽名或接受推薦的方法簽名
Enter

# 選擇是否替換其他重複代碼
Enter

修改rollMany()方法,支持傳入指定的球數,並修改方法參數(Shift + F6):

private void rollMany(int rolls, int pins) {
  for (int i = 0; i < rolls; i++) {
    game.roll(pins);
  }
}

修改調用rollMany()方法的語句,傳入指定的球數爲20。

完整代碼:

public class GameTest {

    private Game game;

    @Before
    public void setUp() {
        game = new Game();
    }

    @Test
    public void testGutterGame() {
        rollMany(20, 0);
        assertEquals(0, game.score());
    }

    private void rollMany(int rolls, int pins) {
        for (int i = 0; i < rolls; i++) {
            game.roll(pins);
        }
    }

    @Test
    public void testAllOnes() {
        rollMany(20, 1);
        assertEquals(20, game.score());
    }
}

按下Shift + F10運行測試,確保在重構後,測試仍然通過(綠)。

Step12: 編寫第3個測試方法

增加一個測試方法,來測試第一個Frame爲Spare的情況。

@Test
public void testOneSpare() {
  game.roll(4);
  game.roll(6); // spare
  game.roll(3);
  rollMany(17, 0);
  assertEquals(16, game.score());
}

在這個例子中,第一個Frame爲Spare,所以第一個Frame的得分要加上該Frame的下一個球擊倒的球數(Spare bonus)。爲簡單起見,假設後面17個球都沒有擊倒瓶子。

按下Shift + F10運行測試,測試失敗(紅),因爲Game類還沒有考慮加上Spare bonus的情況。

Step13: 修復測試失敗

在保齡球比賽中:

  • 如果一個Frame是Spare,該Frame的得分要加上下一個球擊倒的瓶子數(Spare bonus);
  • 如果一個Frame是Strike,該Frame的得分要加上下兩個球擊倒的瓶子數(Strike bonus)。

所以,我們首先要記住每次投球所擊倒的瓶子數,因爲最多投球21次(每Frame最多投2球,最後一個Frame最多投3個球),所以用一個長度爲21的int數組來記。

再用一個變量來記住當前是第幾個球。

代碼示例:

private int[] rolls = new int[21];
private int currentRoll = 0;

在每次投球時記住該次投球擊倒的瓶子數,並記錄當前是第幾個球:

public void roll(int pins) {
  rolls[currentRoll] = pins;
  currentRoll ++;
}

在計算比賽得分時,累加全部球擊倒的瓶子數加上Spare bonus和Strike bonus。

先累加全部球擊倒的瓶子數:

public class Game {
    private int[] rolls = new int[21];
    private int currentRoll = 0;

    public void roll(int pins) {
        rolls[currentRoll] = pins;
        currentRoll ++;
    }

    public int score() {
        int score = 0;
        for (int i = 0; i < rolls.length;i ++) {
            score += rolls[i];
        }
        return score;
    }
}

按下Shift + F10運行測試,發現其他不需要計算bonus的測試方法測試通過,但是需要計算Spare bonus的方法仍然測試不通過。

Step14: 繼續修復測試失敗

繼續實現計算Spare bonus的邏輯:

  • Spare時,該Frame的得分爲10分加上下一次投球擊倒的瓶子數;
  • 其他情況時,該Frame的得分爲兩次投球擊倒的瓶子總數。(先不考慮Strike的情況)
public int score() {
  int score = 0;
  int rollIndex = 0;
  for (int frame = 0; frame < 10; frame ++) {
    if (rolls[rollIndex] + rolls[rollIndex + 1] == 10) {
      // spare: sum of balls in frame is 10
      // spare bonus: balls of next roll
      score += 10 + rolls[rollIndex + 2];
      // move to next frame
      rollIndex += 2;
    } else {
      // other: sum of balls in frame
      score += rolls[rollIndex] + rolls[rollIndex + 1];
      // move to next frame
      rollIndex += 2;
    }
  }
  return score;
}

按下Shift + F10運行測試,測試通過(綠)。

Step15: 重構實現類

重構Game類的score()方法,去除註釋,讓代碼能夠“自描述”。

提取出一個專門的方法用來判斷是否爲Spare:

 private boolean isSpare(int rollIndex) {
   return rolls[rollIndex] + rolls[rollIndex + 1] == 10;
 }

按下Shift + F10運行測試,測試通過(綠)。

去掉Spare部分的註釋:

public int score() {
  int score = 0;
  int rollIndex = 0;
  for (int frame = 0; frame < 10; frame++) {
    if (isSpare(rollIndex)) {
      score += 10 + rolls[rollIndex + 2];
      // move to next frame
      rollIndex += 2;
    } else {
      // other: sum of balls in frame
      score += rolls[rollIndex] + rolls[rollIndex + 1];
      // move to next frame
      rollIndex += 2;
    }
  }
  return score;
}

按下Shift + F10運行測試,測試通過(綠)。

Step16: 重構測試類

重構GameTest類的testOneSpare()方法。

提取出一個專門的rollSpare()方法。

private void rollSpare() {
  game.roll(4);
  game.roll(6);
}

按下Shift + F10運行測試,測試通過(綠)。

重構後的testOneSpare()方法:

@Test
public void testOneSpare() {
  rollSpare();
  game.roll(3);
  rollMany(17, 0);
  assertEquals(16, game.score());
}

Step17: 編寫第4個測試方法

增加一個測試方法,來測試第一個Frame爲Strike的情況。

@Test
public void testOneStrike() {
  game.roll(10); // strike
  game.roll(3);
  game.roll(4);
  rollMany(16, 0);
  assertEquals(24, game.score());
}

在這個例子中,第一個Frame爲Strike,所以第一個Frame的得分要加上該Frame的下兩個球擊倒的球數(Strike bonus)。爲簡單起見,假設後面16個球都沒有擊倒瓶子。

按下Shift + F10運行測試,測試失敗(紅),因爲Game類還沒有考慮加上Strike bonus的情況。

Step18: 修復測試失敗

增加計算Strik bonus的情況。

public int score() {
  int score = 0;
  int rollIndex = 0;
  for (int frame = 0; frame < 10; frame++) {
    if (rolls[rollIndex] == 10) {
      score += 10 + rolls[rollIndex + 1] + rolls[rollIndex + 2];
      rollIndex += 1;
    } else if (isSpare(rollIndex)) {
      score += 10 + rolls[rollIndex + 2];
      // move to next frame
      rollIndex += 2;
    } else {
      // other: sum of balls in frame
      score += rolls[rollIndex] + rolls[rollIndex + 1];
      // move to next frame
      rollIndex += 2;
    }
  }
  return score;
}

按下Shift + F10運行測試,測試通過(綠)。

Step19: 重構實現類

提取出一個專門的方法用來判斷是否爲Strike:

private boolean isStrike(int rollIndex) {
  return rolls[rollIndex] == 10;
}

按下Shift + F10運行測試,測試通過(綠)。

Step20: 繼續重構實現類

提取出一個專門的方法用來計算Strike bonus。

private int strikeBonus(int rollIndex) {
  return rolls[rollIndex + 1] + rolls[rollIndex + 2];
}

按下Shift + F10運行測試,測試通過(綠)。

Step21: 繼續重構實現類

提取出一個專門的方法用來計算Spare bonus。

private int spareBonus(int rollIndex) {
  return rolls[rollIndex + 2];
}

按下Shift + F10運行測試,測試通過(綠)。

Step22: 繼續重構實現類

提取出一個專門的方法用來計算普通的Frame的得分。

private int sumOfBallsInFrame(int rollIndex) {
  return rolls[rollIndex] + rolls[rollIndex + 1];
}

按下Shift + F10運行測試,測試通過(綠)。

Step23: 重構測試類

重構GameTest類的testOneStrike()方法。

提取出一個專門的rollStrike()方法。

private void rollStrike() {
  game.roll(10);
}

按下Shift + F10運行測試,測試通過(綠)。

重構後的testOneStrike()方法:

@Test
public void testOneStrike() {
  rollStrike();
  game.roll(3);
  game.roll(4);
  rollMany(16, 0);
  assertEquals(24, game.score());
}

Step24: 編寫第5個測試方法

增加一個測試方法,來測試最完美的情況,也就是連續12個球都把10個瓶子擊倒了,該局比賽得分爲300分。

@Test
public void testPerfectGame() {
  rollMany(12, 10);
  assertEquals(300, game.score());
}

按下Shift + F10運行測試,測試通過(綠)。

Step25: 重構實現類

將Game類的一些hardcode改爲爲常量。

直接將hardcode改爲常量,再用Alt + Enter自動生成常量。

按下Shift + F10運行測試,測試通過(綠)。

再去除多餘的註釋。

按下Shift + F10運行測試,測試通過(綠)。

重構後的代碼:

package cn.xdevops.kata;

public class Game {
    private static final int MAX_ROLL_NUM = 21;
    private static final int MAX_FRAME_NUM = 10;
    private static final int MAX_PIN_NUM = 10;

    private int[] rolls = new int[MAX_ROLL_NUM];
    private int currentRoll = 0;

    public void roll(int pins) {
        rolls[currentRoll] = pins;
        currentRoll++;
    }

    public int score() {
        int score = 0;
        int rollIndex = 0;
        for (int frame = 0; frame < MAX_FRAME_NUM; frame++) {
            if (isStrike(rollIndex)) {
                score += MAX_PIN_NUM + strikeBonus(rollIndex);
                rollIndex += 1;
            } else if (isSpare(rollIndex)) {
                score += MAX_PIN_NUM + spareBonus(rollIndex);
                rollIndex += 2;
            } else {
                score += sumOfBallsInFrame(rollIndex);
                rollIndex += 2;
            }
        }
        return score;
    }

    private int sumOfBallsInFrame(int rollIndex) {
        return rolls[rollIndex] + rolls[rollIndex + 1];
    }

    private int spareBonus(int rollIndex) {
        return rolls[rollIndex + 2];
    }

    private int strikeBonus(int rollIndex) {
        return rolls[rollIndex + 1] + rolls[rollIndex + 2];
    }

    private boolean isStrike(int rollIndex) {
        return rolls[rollIndex] == MAX_PIN_NUM;
    }

    private boolean isSpare(int rollIndex) {
        return rolls[rollIndex] + rolls[rollIndex + 1] == MAX_PIN_NUM;
    }
}

至此,一個功能正確,且代碼整潔的BowlingGame Kata就完成了。

TDD Kata小結

TDD心法小結;

  • 紅-綠-重構,不斷迭代;

  • 小步快跑(步子大了容易走火入魔)

  • 增加新功能時不重構,重構時不增加新功能

剛開始練習TDD Kata時步子要小,可以慢一點。等熟練後,就要刻意練習加快速度,可以用秒錶來統計Kata用時。

一般來說一個Kata的用時,不應該超過1個番茄鍾(25分鐘)。可以通過以下方法提升速度:

  • 只用英文輸入法,用英文寫註釋;
  • 熟悉Intellij快捷鍵操作,儘量少用鼠標;
  • 熟練掌握編程語言的語法和常用API(編程時不去搜索語法);
  • 平時有意練習打字速度;
  • 多多練習不同的Kata,用不同的編程語言來實現。

Intellij常用快捷鍵

編程中常用的快捷鍵:

名稱 快捷鍵(Mac)
新建 Ctrl + N
切換 Command + E
自動修復 Alt + Enter
運行 Shift + F10
重命名 Shift + F6
提取方法 Alt + Command + M
提取變量 Alt + Command + V
光標跳到下一行 Shift + Enter
光標跳到行首 Fn + Left
光標跳到行尾 Fn + Right
註釋/取消註釋 Command + /
格式化 Alt + Command + L

按下CTRL + SHIFT + A快速查找相應Action (菜單)。

參見

如何提升打字速度

可以在https://typing.io/lessons上測試和練習多種編程語言的打字速度。

參考文檔

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章