最後更新日期:2022 年 11 月 21 日
Table of Contents
單元測試 (unit test) 概述
Python 上的 unittest 模組主要包括四個部份:
測試案例(Test case):測試的最小單元。
測試設備(Test fixture):執行一或多個測試前必要的預備資源,以及相關的清除資源動作。
測試套件(Test suite):一組測試案例、測試套件或者是兩者的組合。
測試執行器(Test runnerh)「負責執行測試並提供測試結果的元件。
主要是參考 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 影片
如果你覺得觀看文章還是不太了解,可以看一下我錄製的影片,或許有些幫助。
[尚未完成]
Comments