主要是參考 Uncle Bob 的 Bowling Game Kata ,然後配合 Python 的語法做修改。

原則 1:沒有測試程式,就不寫產品程式。
原則 2:只撰寫剛好無法通過的單元測試。
原則 3:只撰寫剛好能通過當前測試失敗的產品程式。

保齡球的規則

準備工作

Step 0

測試程式的檔案名稱為 bowling_game_test.py,內容如下:

import unittest

class BowlingGameTest(unittest.TestCase):
    pass

if __name__ == "__main__":
    unittest.main()

產品程式的檔案名稱為 bowling_game.py,目前沒有內容。

第一個測試方法

測試方法名稱:testGutterGame
測試每次投球都是洗溝(0分)的狀況(gutter game),其總分應為 0 分

step 1-1

撰寫測試程式,測試程式的檔案名稱為:bowling_game.py

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def testGutterGame(self):
        g = Game()

if __name__ == "__main__":
    unittest.main()

測試結果:FAILED
ModuleNotFoundError: No module named ‘Game’

step 1-2

建立產品程式 bowling_game.py

class Game:
    pass

再次執行測試
測試結果:OK

step 1-3

撰寫測試

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def testGutterGame(self):
        g = Game()
        for i in range(20):
            g.roll(0)

if __name__ == "__main__":
    unittest.main()

測試結果:FAILED
AttributeError: ‘Game’ object has no attribute ‘roll’

step 1-4

撰寫產品程式

class Game:
    def roll(self, pins):
        pass

測試結果:OK

step 1-5

撰寫測試程式

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def testGutterGame(self):
        g = Game()
        for i in range(20):
            g.roll(0)
        self.assertEqual(0, g.score())

if __name__ == "__main__":
    unittest.main()

測試結果:FAILED
AttributeError: ‘Game’ object has no attribute ‘score’

step 1-6

撰寫產品程式

class Game:
    def roll(self, pins):
        pass

    def score(self):
        return -1

測試結果:FAILED
AssertionError: 0 != -1

step 1-7

再次撰寫產品程式

class Game:
    def roll(self, pins):
        pass

    def score(self):
        return 0

測試結果:OK

第二個測試方法

測試方法名稱:testAllOnes
測試每次投球都是只打倒 1 個球瓶(1分)的狀況,總分應為 20 分

step 2-1

撰寫測試方法 testAllOnes()

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):

    def testGutterGame(self):
        g = Game()
        for i in range(20):
            g.roll(0)
        assert(0 == g.score())

    def testAllOnes(self):
        g = Game()
        for i in range(20):
            g.roll(1)
        self.assertEqual(20, g.score())

if __name__ == "__main__":
    unittest.main()

測試結果:FAILED
AssertionError: 20 != 0

step 2-2

改善 Game 類別中的方法

class Game:
    def __init__(self):
        self.the_score = 0

    def roll(self, pins):
        self.the_score += pins

    def score(self):
        return self.the_score

測試結果:OK

step 2-3

在這裏我們發現 2 個問題:
1、Game 物件的生成述敘述重複
2、for 迴圈重複

修改測試程式,對 Game 物件生成重複的程式碼進行重構。

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def testGutterGame(self):
        for i in range(20):
            self.g.roll(0)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        for i in range(20):
            self.g.roll(1)
        self.assertEqual(20, self.g.score())

if __name__ == "__main__":
    unittest.main()

測試結果:OK

setp 2-4

修改測試程式,對 for 迴圈重複的程式碼進行重構。

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def testGutterGame(self):
        n = 20
        pins = 0
        for i in range(n):
            self.g.roll(pins)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        for i in range(20):
            self.g.roll(1)
        self.assertEqual(20, self.g.score())

if __name__ == "__main__":
    unittest.main()

測試結果:OK

setp 2-5

繼續修改測試程式,對 for 迴圈重複的程式碼進行重構。
加入 rollMany 方法,將 for 迴圈從 testGutterGame 方法中抽取出來。

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def testGutterGame(self):
        n = 20
        pins = 0
        self.rollMany(n, pins)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        for i in range(20):
            self.g.roll(1)
        self.assertEqual(20, self.g.score())

    def rollMany(self, n:int, pins:int):
        for i in range(n):
            self.g.roll(pins)

if __name__ == "__main__":
    unittest.main()

測試結果:OK

setp 2-6

繼續修改測試程式,對 for 迴圈重複的程式碼進行重構。
將不需要的 n 和 pins 變數從 testGutterGame 方法中抽取出來。

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def testGutterGame(self):
        self.rollMany(20, 0)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        for i in range(20):
            self.g.roll(1)
        self.assertEqual(20, self.g.score())

    def rollMany(self, n:int, pins:int):
        for i in range(n):
            self.g.roll(pins)

if __name__ == "__main__":
    unittest.main()

測試結果:OK

setp 2-7

繼續修改測試程式,對 for 迴圈重複的程式碼進行重構。
利用 rollMany 方法,將 for 迴圈從 testAllOnes 方法中抽取出來。

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def testGutterGame(self):
        self.rollMany(20, 0)
        self.assertEqual(0,  self.g.score())

    def testAllOnes(self):
        self.rollMany(20, 1)
        self.assertEqual(20,  self.g.score())

    def rollMany(self, n: int, pins: int):
        for i in range(n):
            self.g.roll(pins)

if __name__ == "__main__":
    unittest.main()

測試結果:OK

setp 2-8

繼續修改測試程式,調整 rollMany 方法在程式中的位置。

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def rollMany(self, n:int, pins:int):
        for i in range(n):
            self.g.roll(pins)

    def testGutterGame(self):
        self.rollMany(20, 0)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        self.rollMany(20, 1)
        self.assertEqual(20, self.g.score())

    def rollMany(self, n:int, pins:int):
        for i in range(n):
            self.g.roll(pins)

if __name__ == "__main__":
    unittest.main()

測試結果:OK

第三個測試方法

測試方法名稱:testOneSpare
測試打了一個 Spare 的狀況

step 3-1

撰寫第 3 個測試方法 testOneSpare()

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def rollMany(self, n:int, pins:int):
        for i in range(n):
            self.g.roll(pins)

    def testGutterGame(self):
        self.rollMany(20, 0)
        assert(0 == self.g.score())

    def testAllOnes(self):
        self.rollMany(20, 1)
        self.assertEqual(20, self.g.score())

    def testOneSpare(self):
        self.g.roll(5)
        self.g.roll(5)  # spare
        self.g.roll(3)
        self.rollMany(17, 0)
        self.assertEqual(16, self.g.score())

if __name__ == "__main__":
    unittest.main()

測試結果:FAILED
AssertionError: 16 != 13

在這裏有個小問題,我們在程式碼中用註解來標示打了一個 spare,理論上,程式應該用函式或方法來自我說明,這個問題稍後再找機會來重構。

step 3-2

重構產品程式的設計邏輯,處理其中不合理的部分。

產品程式碼中,不合理的部分有 2:
1、roll 方法實際上會計算分數,但我們無法從其名稱得知。
2、score 方法實際上並沒有計算分數,但是其名稱卻會讓我們覺得計算分數的功能是在此實作的。

我們先將程式碼復原到綠燈的階段,產品程式碼修改如下:

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def rollMany(self, n:int, pins:int):
        for i in range(n):
            self.g.roll(pins)

    def testGutterGame(self):
        self.rollMany(20, 0)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        self.rollMany(20, 1)
        self.assertEqual(20, self.g.score())

    # def testOneSpare(self):
    #     self.g.roll(5)
    #     self.g.roll(5)  # spare
    #     self.g.roll(3)
    #     self.rollMany(17, 0)
    #     assert(16 == self.g.score())

if __name__ == "__main__":
    unittest.main()

測試結果:OK

step 3-3

修改產品程式碼,處理 roll 方法。

class Game:
    def __init__(self):
        self.the_score = 0
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.the_score += pins
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        return self.the_score

測試結果:OK

step 3-4

修改產品程式碼,處理 score 方法

class Game:
    def __init__(self):
        self.the_score = 0
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.the_score += pins
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        score = 0
        for i in self.rolls:
            score += i
        return score

測試結果:OK

step 3-5

修改產品程式碼,處理不必要的程式碼(the_score 相關程式碼)

class Game:
    def __init__(self):
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        score = 0
        for i in self.rolls:
            score += i
        return score

測試結果:OK

step 3-6

修改測試程式,重新啟用 testOneSpare ,查看是否得到預期的錯誤

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def rollMany(self, n:int, pins:int):
        for i in range(n):
            self.g.roll(pins)

    def testGutterGame(self):
        self.rollMany(20, 0)
        assert(0 == self.g.score())

    def testAllOnes(self):
        self.rollMany(20, 1)
        assert(20 == self.g.score())

    def testOneSpare(self):
        self.g.roll(5)
        self.g.roll(5)  # spare
        self.g.roll(3)
        self.rollMany(17, 0)
        assert(16 == self.g.score())

if __name__ == "__main__":
    unittest.main()

執行結果:FAILED
assert(16 == self.g.score()) AssertionError
得到預期的錯誤

step 3-7

修改產品程式,試著找到解決問題的方法

class Game:
    def __init__(self):
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        score = 0
        k = 0
        while k < len(self.rolls) - 1:
            if self.rolls[k] + self.rolls[k+1] == 10:
                score += self.rolls[k]
                score += self.rolls[k+2]
            else:
                score += self.rolls[k]
            k += 1
        return score

測試結果:OK
測試結果雖然 ok,但是邏輯上是有問題的,所以得嘗試其他的方法。

step 3-8

將產品程式中修改的部分去除,回復 step 3-5 的狀態,再測試是否正常。

class Game:
    def __init__(self):
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        score = 0
        for i in self.rolls:
            score += i
        return score

另外,測試程式亦需回復到 step 3-5 的狀態

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def rollMany(self, n:int, pins:int):
        for i in range(n):
            self.g.roll(pins)

    def testGutterGame(self):
        self.rollMany(20, 0)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        self.rollMany(20, 1)
        self.assert(20, self.g.score())

    # def testOneSpare(self):
    #     self.g.roll(5)
    #     self.g.roll(5)  # spare
    #     self.g.roll(3)
    #     self.rollMany(17, 0)
    #     assert(16 == self.g.score())

if __name__ == "__main__":
    unittest.main()

測試結果:OK
己經成功的將 step 3-6 及 step 3-7 的修改復原至 step 3-5 的狀態。

step 3-9

修改產品程式,再次嘗試其他的解決方法

class Game:
    def __init__(self):
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        score = 0
        i = 0
        for frame in range(10):
            score += self.rolls[i] + self.rolls[i+1]
            i += 2
        return score

測試結果:OK
這表示這個邏輯對 testGutterGame 及 testAllOnes 兩個測試方法是對的。

step 3-10

將測試程式中,testOneSpare 的部分啟用,測試 step 3-9 的解決邏輯,是否可行。

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def rollMany(self, n:int, pins:int):
        for i in range(n):
            self.g.roll(pins)

    def testGutterGame(self):
        self.rollMany(20, 0)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        self.rollMany(20, 1)
        self.assertEqual(20, self.g.score())

    def testOneSpare(self):
        self.g.roll(5)
        self.g.roll(5)  # spare
        self.g.roll(3)
        self.rollMany(17, 0)
        self.assertEqual(16, self.g.score())

if __name__ == "__main__":
    unittest.main()

測試結果:FAILED
解法邏輯還是有點問題,我們得再想想別的方法。

step 3-11

修改產品程式

class Game:
    def __init__(self):
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        score = 0
        i = 0
        for frame in range(10):
            if self.rolls[i] + self.rolls[i+1] == 10: # spare
                score += 10 + self.rolls[i+2]
            else:
                score += self.rolls[i] + self.rolls[i + 1]
            i += 2
        return score

測試結果:OK
終於通過測試方法了,但是產品程式中還有 2 個地方有點毛病:
1、score 方法中的變數 i ,其命名無法代表其實際意義。
2、score 方法中的 spare 註解,應該用更有意義的方式來取代。

step 3-12

重構產品程式,用有意義的變數名稱(frame_index)取代變數 i

class Game:
    def __init__(self):
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        score = 0
        frame_index = 0
        for frame in range(10):
            if self.rolls[frame_index] + self.rolls[frame_index+1] == 10:  # spare
                score += 10 + self.rolls[frame_index+2]
            else:
                score += self.rolls[frame_index] + self.rolls[frame_index+1]
            frame_index += 2
        return score

測試結果:OK

step 3-12

重構產品程式,用有意義的方式取代 spare 註解

class Game:
    def __init__(self):
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        score = 0
        frame_index = 0
        for frame in range(10):
            if self.is_spare(frame_index):
                score += 10 + self.rolls[frame_index+2]
            else:
                score += self.rolls[frame_index] + self.rolls[frame_index+1]
            frame_index += 2
        return score

    def is_spare(self, frame_index:int):
        return self.rolls[frame_index] + self.rolls[frame_index+1] == 10

測試結果:OK

step 3-14

重構測試程式,用有意義的方式取代 spare 註解

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def rollMany(self, n: int, pins: int):
        for i in range(n):
            self.g.roll(pins)

    def testGutterGame(self):
        self.rollMany(20, 0)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        self.rollMany(20, 1)
        self.assertEqual(20, self.g.score())

    def testOneSpare(self):
        self.rollSpare()
        self.g.roll(3)
        self.rollMany(17, 0)
        self.assertEqual(16, self.g.score())

    def rollSpare(self):
        self.g.roll(5)
        self.g.roll(5)

if __name__ == "__main__":
    unittest.main()

測試結果:OK

第四個測試方法

測試方法名稱:testOneStrike
測試打了一個 Strike 的狀況

step 4-1

撰寫第 4 個測試方法 testOneStrike()

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def rollMany(self, n: int, pins: int):
        for i in range(n):
            self.g.roll(pins)

    def testGutterGame(self):
        self.rollMany(20, 0)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        self.rollMany(20, 1)
        self.assertEqual(20, self.g.score())

    def testOneSpare(self):
        self.rollSpare()
        self.g.roll(3)
        self.rollMany(17, 0)
        self.assertEqual(16, self.g.score())

    def testOneStrike(self):
        self.g.roll(10)  # strike
        self.g.roll(3)
        self.g.roll(4)
        self.rollMany(16, 0)
        self.assertEqual(24, self.g.score())

    def rollSpare(self):
        self.g.roll(5)
        self.g.roll(5)

if __name__ == "__main__":
    unittest.main()

測試結果:FAILED
assert (24 == self.g.score() AssertionError

step 4-2

修改產品程式碼

class Game:
    def __init__(self):
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        score = 0
        frame_index = 0
        for frame in range(10):
            if self.rolls[frame_index] == 10:  # strike
                score += 10 + self.rolls[frame_index+1] + self.rolls[frame_index+2]
                frame_index += 1
            elif self.is_spare(frame_index):
                score += 10 + self.rolls[frame_index+2]
                frame_index += 2
            else:
                score += self.rolls[frame_index] + self.rolls[frame_index+1]
                frame_index += 2
        return score

    def is_spare(self, frame_index:int):
        return self.rolls[frame_index] + self.rolls[frame_index+1] == 10

測試結果:OK

step 4-3

重構產品程式

class Game:
    def __init__(self):
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        score = 0
        frame_index = 0
        for frame in range(10):
            if self.rolls[frame_index] == 10:  # strike
                score += 10 + self.strike_bonus(frame_index)
                frame_index += 1
            elif self.is_spare(frame_index):
                score += 10 + self.spare_bonus(frame_index)
                frame_index += 2
            else:
                score += self.sum_of_balls_in_frame(frame_index)
                frame_index += 2
        return score

    def is_spare(self, frame_index:int):
        return self.rolls[frame_index] + self.rolls[frame_index+1] == 10

    def sum_of_balls_in_frame(self, frame_index: int):
        return self.rolls[frame_index] + self.rolls[frame_index+1]

    def spare_bonus(self, frame_index):
        return self.rolls[frame_index + 2]

    def strike_bonus(self, frame_index):
        return self.rolls[frame_index + 1] + self.rolls[frame_index + 2]

測試結果:OK

step 4-4

繼續重構產品程式

class Game:
    def __init__(self):
        self.rolls = []
        for i in range(21):
            self.rolls.append(0)
        self.currentRoll = 0

    def roll(self, pins):
        self.rolls[self.currentRoll] = pins
        self.currentRoll += 1

    def score(self):
        score = 0
        frame_index = 0
        for frame in range(10):
            if self.is_strike(frame_index):
                score += 10 + self.strike_bonus(frame_index)
                frame_index += 1
            elif self.is_spare(frame_index):
                score += 10 + self.spare_bonus(frame_index)
                frame_index += 2
            else:
                score += self.sum_of_balls_in_frame(frame_index)
                frame_index += 2
        return score

    def is_strike(self, frame_index: int):
        return self.rolls[frame_index] == 10

    def is_spare(self, frame_index: int):
        return self.rolls[frame_index] + self.rolls[frame_index+1] == 10

    def sum_of_balls_in_frame(self, frame_index: int):
        return self.rolls[frame_index] + self.rolls[frame_index+1]

    def spare_bonus(self, frame_index):
        return self.rolls[frame_index + 2]

    def strike_bonus(self, frame_index):
        return self.rolls[frame_index + 1] + self.rolls[frame_index + 2]

測試結果:OK

step 4-5

重構測試程式

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def rollMany(self, n: int, pins: int):
        for i in range(n):
            self.g.roll(pins)

    def testGutterGame(self):
        self.rollMany(20, 0)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        self.rollMany(20, 1)
        self.assertEqual(20, self.g.score())

    def testOneSpare(self):
        self.rollSpare()
        self.g.roll(3)
        self.rollMany(17, 0)
        self.assertEqual(16, self.g.score())

    def testOneStrike(self):
        self.rollStrike()
        self.g.roll(3)
        self.g.roll(4)
        self.rollMany(16, 0)
        self.assertEqual(24, self.g.score())

    def rollStrike(self):
        self.g.roll(10)

    def rollSpare(self):
        self.g.roll(5)
        self.g.roll(5)

if __name__ == "__main__":
    unittest.main()

測試結果:OK

step 5-1

撰寫第 5 個測試方法 testPerfectGame()

import unittest
from bowling_game import Game

class BowlingGameTest(unittest.TestCase):
    def setUp(self):
        self.g = Game()

    def rollMany(self, n: int, pins: int):
        for i in range(n):
            self.g.roll(pins)

    def testGutterGame(self):
        self.rollMany(20, 0)
        self.assertEqual(0, self.g.score())

    def testAllOnes(self):
        self.rollMany(20, 1)
        self.assertEqual(20, self.g.score())

    def testOneSpare(self):
        self.rollSpare()
        self.g.roll(3)
        self.rollMany(17, 0)
        self.assertEqual(16, self.g.score())

    def testOneStrike(self):
        self.rollStrike()
        self.g.roll(3)
        self.g.roll(4)
        self.rollMany(16, 0)
        self.assertEqual(24, self.g.score())

    def testPerfectGame(self):
        self.rollMany(12, 10)
        self.assertEqual(300, self.g.score())

    def rollStrike(self):
        self.g.roll(10)

    def rollSpare(self):
        self.g.roll(5)
        self.g.roll(5)

if __name__ == "__main__":
    unittest.main()

測試結果:OK

完成這個 kata !!

YOUTUBE 影片

如果你覺得觀看文章還是不太了解,可以看一下我錄製的影片,或許有些幫助。
[尚未完成]

Last modified: 2019-02-21

Author

Comments

Write a Reply or Comment

Your email address will not be published.