Why Good Developers Write Bad Unit Tests

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(不要重複自己)

將設定程式碼內聯對於單一測試來說很好,但如果我有很多測試呢?難道每次都要重複那段程式碼嗎?做好心理準備,因為我接下來要提倡 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 上的一切。

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

盡情使用冗長的測試名稱

在正式程式碼中,你會偏好看到以下哪一個函式名稱?

  • 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(蘿琳·尤)繪製

原文由 Michael Lynch 發布

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