為什麼優秀的開發者會寫出糟糕的單元測試
原文由 Michael Lynch 于 發布,訂閱此部落格
恭喜你!你終於寫了夠多的程式碼,賺到了一棟海邊別墅。你聘請了彼得.基廷(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
把前置設定程式碼內聯進來,對單一測試來說當然很好,但如果我有很多測試呢?難道每次都要重複一樣的程式碼嗎?做好心理準備,因為我接下來要提倡 複製貼上式程式設計。
以下是同一個類別的另一個測試:
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'))對於嚴格遵守 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 行。這麼多的樣板程式碼,已經嚴重干擾了你真正想測試的行為。
你自然的直覺可能是把所有無趣的程式碼都委派給測試輔助方法,但你應該先問一個更關鍵的問題:為什麼這個系統會這麼難測試?
過多的樣板程式碼往往是架構薄弱的徵兆。例如,上面的測試就揭露了幾個 設計異味:
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'))除非讀者去找出藏在輔助方法裡的 150,否則他們無法理解為什麼最終分數應該是 175。這個輔助方法也透過把對 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 繪製
隨機一篇部落格


留言
登入後參與討論