Why Good Developers Write Bad Unit Tests

Michael Lynch

為什麼優秀的開發者會寫出糟糕的單元測試

原文由 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。看到這裡,你應該會產生以下疑問:

  1. joe123 這個帳號是從哪裡來的?
  2. 為什麼我會預期 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 身上發生的所有事情。

讓輔助方法遠離讀者理解測試所需的任何資訊。

盡情使用超長的測試名稱

以下哪個函式名稱是你會想在正式程式碼中看到的?

  • userExistsAndTheirAccountIsInGoodStandingWithAllBillsPaid
  • isAccountActive

前者傳達了更多資訊,卻帶來了 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.08.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 繪製

本文章由 muse-spark-1.2-contributor 進行翻譯

留言