為什麼優秀的開發者會寫出糟糕的單元測試
恭喜!你終於寫了多到足以買下一棟海邊別墅的程式碼。你聘請了以摩天大樓聞名全球的建築師 Peter Keating(彼得·基廷),他向你保證,對於你的海濱建案已經有了絕妙的規劃。
幾個月後,你來到盛大的落成發表會。你的新家是一棟氣勢逼人的五層樓龐然巨物,由鋼鐵、混凝土與反光玻璃構成。當你穿過旋轉門,將沙子踩進富麗堂皇的大理石地板時,你看到裡面是一座接待櫃檯,後方是一整排電梯。上了樓,你的主臥室與三間客房,不過是四間相連的辦公室隔間。
專家建築師彼得·基廷完全無法理解你為何失望。「我可是遵循了所有的最佳實務,」他防衛性地告訴你。牆壁厚達三英尺,因為結構完整性至關重要。因此,你的房子比旁邊那些通風明亮的房子更好。你或許沒有面海的大片落地窗,但基廷告訴你,那種窗戶不符合最佳實務——它們會降低能源效率,還會讓辦公室裡的人分心。
軟體開發者在面對單元測試時,也經常抱持同樣錯誤的思維。他們機械式地套用在正式程式碼中學到的所有「規則」,卻未曾檢視這些規則是否適用於測試。結果,他們就在海灘上蓋起了摩天大樓。
測試程式碼和其他程式碼不一樣
正式環境的應用程式通常包含數千到數百萬行程式碼。它們龐大到人類無法一次完全理解。為了管理複雜度,程式語言設計者提供了函式與類別階層等機制,讓開發者得以用抽象化的方式思考。
好的正式程式碼達成了封裝。它讓讀者能輕鬆瀏覽龐大的系統,需要時可深入細節,也可提升到更高的抽象層次。
測試程式碼則是另一回事。一個好的單元測試通常小到開發者可以一次掌握所有邏輯。為測試程式碼加上多層抽象反而會增加其複雜度。測試是一種診斷工具,因此應該盡可能簡單、直白。
想像一把尺。它以同樣的形式存在了數百年,因為它簡單明瞭、易於判讀。假設我發明了一把以「抽象尺規單位」來測量的新尺。要將「尺規單位」轉換為英吋或公分,你還得查閱另一張對照表。
如果我把這種尺拿給木匠,他大概會直接拿尺往我臉上拍下去。為一個本來就提供清晰、明確資訊的工具再加上一層抽象,實在荒謬至極。
好的測試程式碼也是如此。它應該直接給出清楚的結果,而不強迫讀者在多層間接中跳來跳去。開發者往往忽略了這一點,因為這與他們學習撰寫正式程式碼的方式截然不同。
優秀開發者寫出的糟糕測試
我經常看到原本才華洋溢的開發者寫出如下的測試:
def test_initial_score(self):
initial_score = self.account_manager.get_score(username='joe123')
self.assertEqual(150.0, initial_score)這個測試在做什麼?它為名稱為 joe123 的使用者取得「分數」,並驗證該分數為 150。此時,你應該會有以下疑問:
joe123這個帳號是從哪裡來的?- 為什麼我會預期
joe123的分數是 150?
也許答案就在 setUp 方法中,這個方法是測試框架在執行每個測試函式前會呼叫的:
def setUp(self):
database = MockDatabase()
database.add_row({
'username': 'joe123',
'score': 150.0
})
self.account_manager = AccountManager(database)好了,setUp 方法建立了一個分數為 150 的 joe123 使用者,這解釋了為什麼 test_initial_score 會預期這些數值。現在,天下太平了,對吧?
錯,這是一個糟糕的測試。
讓讀者停留在你的測試函式中
當你撰寫測試時,請想想下一個看到測試失敗的開發者。他們不想閱讀你的整個測試套件,更不想去讀一整棵測試工具的繼承樹。
如果測試失敗,讀者應該能夠從上到下直線閱讀測試函式就能診斷問題。如果他們必須跳出測試去閱讀其他輔助程式碼,這個測試就沒有盡到職責。
有鑑於此,以下是上一節測試的重寫版本:
def test_initial_score(self):
database = MockDatabase()
database.add_row({
'username': 'joe123',
'score': 150.0
})
account_manager = AccountManager(database)
initial_score = account_manager.get_score(username='joe123')
self.assertEqual(150.0, initial_score)我所做的只是把 setUp 方法中的程式碼內聯進來,但效果卻有天壤之別。現在,讀者需要的一切都在測試中一目了然。它也遵循了 arrange, act, assert(安排、操作、斷言) 結構,讓測試的每個階段都清晰可辨。
敢於違反 DRY(不要重複自己)
將設定程式碼內聯對於單一測試來說很好,但如果我有很多測試呢?難道每次都要重複那段程式碼嗎?做好心理準備,因為我接下來要提倡 copy/paste programming(複製貼上式程式設計)。
以下是同一個類別的另一個測試:
def test_increase_score(self):
database = MockDatabase() # <
database.add_row({ # <
'username': 'joe123', # <--- Copy/pasted from
'score': 150.0 # <--- previous test
}) # <
account_manager = AccountManager(database) # <
account_manager.adjust_score(username='joe123',
adjustment=25.0)
self.assertEqual(175.0,
account_manager.get_score(username='joe123'))對於嚴格遵守 principle of DRY(不要重複自己原則) 的信奉者來說,以上程式碼令人毛骨悚然。我明目張膽地重複自己;我一字不漏地從前一個測試複製了六行。更糟的是,我還主張我這種違反 DRY 的測試,比那些毫無重複程式碼的測試更好。這怎麼可能?
如果你能在不重複程式碼的情況下寫出清晰的測試,那當然最理想,但請記住,消除重複只是手段,而非目的。最終目標是清晰、簡單的測試。
在盲目地將 DRY 套用到測試之前,請先想想當測試失敗時,什麼才能讓問題一目了然。重構或許能減少重複,但也增加了複雜度,並可能在出錯時掩蓋資訊。
新增輔助方法前請三思
也許你還能忍受在每個測試中複製貼上六行程式碼,但如果 AccountManager 需要更多設定程式碼呢?
def test_increase_score(self):
# vvvvvvvvvvvvvvvvvvvvv Beginning of boilerplate code vvvvvvvvvvvvvvvvvvvvv
user_database = MockDatabase()
user_database.add_row({
'username': 'joe123',
'score': 150.0
})
privilege_database = MockDatabase()
privilege_database.add_row({
'privilege': 'upvote',
'minimum_score': 200.0
})
privilege_manager = PrivilegeManager(privilege_database)
url_downloader = UrlDownloader()
account_manager = AccountManager(user_database,
privilege_manager,
url_downloader)
# ^^^^^^^^^^^^^^^^^^^^^ End of boilerplate code ^^^^^^^^^^^^^^^^^^^^^^^^^^^
account_manager.adjust_score(username='joe123',
adjustment=25.0)
self.assertEqual(175.0,
account_manager.get_score(username='joe123'))光是為了取得一個 AccountManager 實例並開始測試,就要寫 15 行。這麼多的樣板程式碼,已經喧賓奪主,模糊了你真正想測試的行為。
你的直覺可能會想把所有無趣的程式碼委派給測試輔助方法,但你應該先問一個更關鍵的問題:為什麼這個系統會這麼難測試?
過多的樣板程式碼往往是架構薄弱的徵兆。例如,上面的測試就揭示了幾個 design smells(設計異味):
account_manager = AccountManager(user_database,
privilege_manager,
url_downloader)AccountManager 直接存取 user_database,但它的下一個參數卻是 privilege_manager,也就是 privilege_database 的包裝物件。為什麼它要在兩種不同的抽象層次上操作?而那個「URL 下載器」又是做什麼的?那顯然與其他兩個參數在概念上相去甚遠。
在這種情況下,重構 AccountManager 才能解決根本問題,而加入輔助方法只會掩蓋症狀。
如果你需要輔助方法,請負責任地撰寫
你並非總是有重構正式類別以提升可測試性的自由。有時候,輔助方法是唯一的選擇,因此當你需要它們時,請把它們寫好。
一個有效的輔助方法應符合「讓讀者停留在你的測試函式中」的原則。只要不影響讀者對測試的理解,將樣板程式碼抽取到輔助函式中是沒問題的。
具體來說,輔助方法不應該:
- 隱藏關鍵數值
- 與受測物件互動
以下是一個違反這些準則的輔助方法範例:
def add_dummy_account(self): # <- Helper method
dummy_account = Account(username='joe123',
name='Joe Bloggs',
email='[email protected]',
score=150.0)
# BAD: Helper method hides a call to the object under test
self.account_manager.add_account(dummy_account)
def test_increase_score(self):
self.account_manager = AccountManager()
self.add_dummy_account()
account_manager.adjust_score(username='joe123',
adjustment=25.0)
self.assertEqual(175.0, # BAD: Relies on value set in helper method
account_manager.get_score(username='joe123'))讀者無法理解為什麼最終分數應該是 175,除非他們去找出藏在輔助方法中的 150。這個輔助方法也透過隱藏對 add_account 的呼叫,模糊了 account_manager 的行為,而非將所有互動保留在測試函式本身。
以下是修正這些問題後的重寫版本:
def make_dummy_account(self, username, score):
return Account(username=username,
name='Dummy User', # <- OK: Buries values but they're
email='[email protected]', # <- irrelevant to the test
score=score)
def test_increase_score(self):
account_manager = AccountManager()
account_manager.add_account(
make_dummy_account(
username='joe123', # <- GOOD: Relevant values stay
score=150.0)) # <- in the test
account_manager.adjust_score(username='joe123',
adjustment=25.0)
self.assertEqual(175.0,
account_manager.get_score(username='joe123'))它仍然在輔助方法中隱藏了一些數值,但那些數值與測試無關。它也將 add_account 的呼叫拉回到測試中,讓讀者可以輕易追蹤發生在 account_manager 上的一切。
盡情使用冗長的測試名稱
在正式程式碼中,你會偏好看到以下哪一個函式名稱?
userExistsAndTheirAccountIsInGoodStandingWithAllBillsPaidisAccountActive
第一個傳達了更多資訊,卻帶來了 57 個字元名稱的負擔。大多數開發者寧願犧牲一點精確度,換取像 isAccountActive 這樣簡潔、近乎完美的名稱(Java 開發者除外,對他們而言,這兩個名稱都短得令人難以忍受)。
對於測試函式,有一個關鍵因素改變了這個權衡:你永遠不會撰寫對測試函式的呼叫。開發者只會輸入一次測試名稱——就在函式簽章中。有鑑於此,簡潔固然仍然重要,但在測試程式碼中,它的重要性遠不如在正式程式碼中。
每當測試失敗時,測試名稱是你最先看到的東西,因此它應該盡可能傳達更多資訊。例如,思考以下這個正式類別:
class Tokenizer {
public:
Tokenizer(std::unique_ptr<TextStream> stream);
std::unique_ptr<Token> NextToken();
private:
std::unique_ptr<TextStream> stream_;
};假設你執行了測試套件,並在輸出中看到這一行:
[ FAILED ] TokenizerTests.TestNextToken (6 ms)你會知道測試失敗的原因嗎?可能不會。
TestNextToken 的失敗告訴你,你把 NextToken() 方法搞砸了,但在一個只有單一公開方法的類別中,這樣的資訊毫無意義。要診斷失敗原因,你還得去閱讀測試的實作。
相反地,如果你看到的是這樣呢:
[ FAILED ] TokenizerTests.ReturnsNullptrWhenStreamIsEmpty (6 ms)一個名為 ReturnsNullptrWhenStreamIsEmpty 的函式在其他情境下會顯得過於冗長,但在測試中卻是個好名稱。如果你看到它失敗,你會立刻知道這個類別沒有正確處理空資料流。你很可能在完全不閱讀測試實作的情況下就能修復錯誤。這正是好測試名稱的標誌。
擁抱魔術數字
「不要使用魔術數字。」
這是程式設計世界裡的「不要跟陌生人說話」。許多優秀的開發者對這條教訓內化得如此深刻,以至於從未思考過魔術數字在什麼時候反而能改善程式碼。
作為複習,「魔術數字」是指程式碼中出現、卻未說明其代表意義的數值或字串。以下是一個例子:
calculate_pay(80) # <-- Magic number程式設計師都同意,正式程式碼中的魔術數字是非常糟糕的事,因此他們會用具名常數來取代,例如:
HOURS_PER_WEEK = 40
WEEKS_PER_PAY_PERIOD = 2
calculate_pay(hours=HOURS_PER_WEEK * WEEKS_PER_PAY_PERIOD)不幸的是,人們誤以為魔術數字也會削弱測試程式碼,但事實恰恰相反。
思考以下測試:
def test_add_hours(self):
TEST_STARTING_HOURS = 72.0
TEST_HOURS_INCREASE = 8.0
hours_tracker = BillableHoursTracker(initial_hours=TEST_STARTING_HOURS)
hours_tracker.add_hours(TEST_HOURS_INCREASE)
expected_billable_hours = TEST_STARTING_HOURS + TEST_HOURS_INCREASE
self.assertEqual(expected_billable_hours, hours_tracker.billable_hours())如果你深信魔術數字萬惡不赦,上面的測試在你眼中看來就是正確的。72.0 和 8.0 都有了具名常數,所以沒人能指責這個測試使用了魔術數字。
但是,就暫且放下你的宗教信仰,品嚐一下魔術數字這顆禁果吧:
def test_add_hours(self):
hours_tracker = BillableHoursTracker(initial_hours=72.0)
hours_tracker.add_hours(8.0)
self.assertEqual(80.0, hours_tracker.billable_hours())它更簡單,程式碼少了一半。而且更直觀——讀者不必在函式中跳來跳去追蹤常數的名稱。
當我看到開發者在測試程式碼中定義常數時,通常是出於對 DRY 的誤用,或是因為他們害怕使用魔術數字。然而,測試很少有必要宣告常數,這麼做反而會讓測試更難理解。
結論
要寫出卓越的測試,開發者必須讓工程決策與測試程式碼的目標保持一致。最重要的是,測試應該最大化簡潔性,同時最小化抽象。一個好的測試能讓讀者在完全不離開測試函式的情況下,就理解預期行為並診斷問題。
封面插圖由 Loraine Yow(蘿琳·尤)繪製
隨機一篇部落格

