本章是全書最核心的章節之一,探討讓單元測試真正有價值的三大支柱:可信賴性(Trustworthiness)、可維護性(Maintainability) 與 可讀性(Readability)。無論你的測試組織得多好、數量多龐大,如果無法信任、維護或閱讀它們,測試就毫無價值。缺少任何一根支柱,其他兩根也會迅速崩塌。
mindmap
root((好測試的三大支柱))
可信賴性
測試通過代表程式碼正確
測試失敗代表真正的問題
可維護性
測試公開契約
移除重複
避免過度指定
可讀性
清晰的命名
有意義的變數名
分離斷言與動作8.1 撰寫可信賴的測試#
一個可信賴的測試,是指當它通過時你不會想「讓我用 debugger 再確認一下」,當它失敗時你不會說「噢,本來就會失敗」。你相信測試結果反映了程式碼的真實狀態,並且能據此採取行動。
作者提出了幾項讓測試更值得信賴的指引:
- 決定何時移除或修改測試
- 避免測試中的邏輯
- 每個測試只測一個關注點
- 將單元測試與整合測試分離
- 透過 code review 與 code coverage 確保品質
8.1.1 決定何時移除或修改測試#
測試一旦就位且通過,原則上不應輕易改動或移除——它們是你的安全網。但測試突然失敗時,可能有以下原因:
- Production bugs:production code 有 bug。這是最理想的情況,修復 production code 即可,不要動測試。
- Test bugs:測試本身有 bug。測試中的 bug 非常難偵測,因為大家預設測試是正確的。修復時應遵循三步驟:
- 修復測試中的 bug
- 確認測試在該失敗時會失敗
- 確認測試在該通過時會通過
- Semantics or API changes:被測物件的使用方式改變(例如新增了
Initialize()方法必須先呼叫),但功能本身不變。此時需要更新測試以匹配新的語意。作者建議使用 factory method 來集中建立物件,當語意改變時只需修改一處。 - Conflicting or invalid tests:需求衝突導致新舊測試互斥。應確認哪個需求有效,移除(而非註解掉)無效的測試。
當語意或 API 改變時,使用 factory method 建立被測物件。這樣語意再次改變時,只需修改一個小方法,而非所有測試。
此外,即使沒有問題,也有正當理由修改測試:
- 重新命名或重構測試以提升可讀性
- 消除重複的測試
8.1.2 避免測試中的邏輯#
測試中包含越多邏輯,出現 bug 的機率就呈指數增長。單元測試應該是一系列方法呼叫加上斷言呼叫,不應包含任何控制流程。
如果你的測試中出現以下任何一項,就代表它包含了不該有的邏輯:
switch、if、else語句foreach、for、while迴圈
包含邏輯的測試會帶來以下問題:
- 更難閱讀和理解
- 更難重現(想像多執行緒測試或帶隨機數的測試)
- 更容易包含 bug 或測試錯誤的東西
- 因為做了多件事,更難命名
不要在測試的斷言中動態計算預期值——這會複製 production code 的邏輯,如果 production code 有 bug,測試也會有同樣的 bug 而照樣通過。應該直接使用寫死的預期值。
例如,以下測試有問題:
[Test]
public void ProductionLogicProblem()
{
string user = "USER";
string greeting = "GREETING";
string actual = MessageBuilder.Build(user, greeting);
Assert.AreEqual(user + greeting, actual); // 動態計算預期值
}較好的寫法:
[Test]
public void ProductionLogicProblem()
{
string actual = MessageBuilder.Build("user", "greeting");
Assert.AreEqual("user greeting", actual); // 寫死的預期值
}如果測試的工具方法中必須包含複雜邏輯,至少要為這些工具方法撰寫測試。
8.1.3 只測試一個關注點#
一個 concern(關注點)是指一個工作單元的單一最終結果:一個回傳值、一個系統狀態變更、或一個第三方呼叫。如果你的測試對多個物件進行斷言,或同時檢查回傳值和系統狀態變更,很可能就是在測試多個關注點。
測試多個關注點的壞處:
- 命名困難:幾乎不可能為測試取一個好名字來準確描述測試內容
- 診斷困難:在多數測試框架中,第一個斷言失敗時會拋出例外,後續斷言根本不會執行,你無法得知其他關注點的狀態
[Test]
public void IsValid_WhenValid_ReturnsTrueAndRemembersItLater()
{
LogAnalyzer logan = MakeDefaultAnalyzer();
Assert.IsTrue(logan.IsValid("abc"));
Assert.IsTrue(logan.WasLastCallValid); // 第一個斷言失敗時永遠不會執行
}這個測試應該拆成兩個獨立的測試,各自有明確的名稱。判斷的方式是:如果第一個斷言失敗了,你還在乎第二個嗎?如果在乎,就該拆開。
8.1.4 分離單元測試與整合測試#
如同第七章討論的「安全綠色區域」,如果開發者不信任測試能開箱即用、輕鬆且一致地執行,他們就不會去跑測試。建立一個獨立的單元測試專案,其中的測試只在記憶體中執行、保持一致性且可重複執行,這就創造了一個安全的綠色區域。
8.1.5 透過 code review 與 code coverage 確保品質#
100% 的程式碼覆蓋率加上測試與 code review,代表你擁有一張安全網,同時團隊也能從知識分享中受益。
作者強調的 code review 是指兩個人坐在一起(或透過遠端工具)即時查看和討論同一段程式碼,而非只是在工具上留言。Code review 也是一種創造可讀、高品質程式碼的技術。
關於 code coverage:
- 低於 20% 表示你缺少大量測試
- 使用自動化工具(如 dotCover、NCover、NCrunch 等)來監控覆蓋率
- 可透過手動檢查來驗證測試是否測對了東西:註解掉你認為未被覆蓋的 production code,執行所有測試——如果全部通過,代表你缺少測試或現有測試測錯了東西
100% code coverage 但沒有 code review 毫無意義——那些測試可能根本沒有斷言。人們傾向於做任何事來達成指標,而非真正確保品質。
8.2 撰寫可維護的測試#
可維護性是大多數開發者在撰寫單元測試時面臨的核心問題。隨著時間推移,測試越來越難維護和理解,每一個小改動都可能破壞某個測試。本節涵蓋只針對公開契約測試、移除重複、正確使用 setup 方法、強制測試隔離、避免多重斷言、比較物件,以及避免過度指定。
8.2.1 測試 private 或 protected 方法#
Private 或 protected 方法之所以存在,通常是為了隱藏實作細節,讓實作可以改變而不影響最終功能。當你測試 private 方法時,你是在測試系統的內部契約,而內部契約是動態的,會隨重構而改變。
對測試而言,你只需關心公開契約(整體功能)。測試 private 方法可能導致即使整體功能正確,測試也會因為內部實作改變而破壞。
沒有 private 方法是憑空存在的——總有一個 public 方法最終會呼叫到它。應該透過 public API 來測試 private 方法的功能。
如果 private 方法確實值得獨立測試,有幾種處理方式:
| 做法 | 說明 |
|---|---|
| 設為 public | 這並非壞事,讓方法有明確的公開行為契約 |
| 抽取到新 class | 如果方法包含大量獨立邏輯,可以抽取到新類別中單獨測試 |
| 設為 static | 不使用類別變數的方法可設為 static,變成工具方法 |
| 設為 internal | 最後手段,使用 [InternalsVisibleTo("TestAssembly")] 讓測試可以存取 |
避免使用 Visual Studio 的 Private Accessor 工具——它使用反射來呼叫 private 方法,產出的程式碼難以維護和閱讀。
8.2.2 移除重複#
測試程式碼中的重複和 production code 中的一樣有害(甚至更有害)。DRY 原則在測試程式碼中同樣適用。重複的程式碼意味著當你測試的某一面向需要改變時,有更多程式碼需要修改。
例如,當 LogAnalyzer 的語意改變,新增了 Initialize() 必須在使用前呼叫的要求,所有直接建立 LogAnalyzer 的測試都會壞掉,需要逐一修改。
使用 helper method 移除重複:
private LogAnalyzer GetNewAnalyzer()
{
LogAnalyzer analyzer = new LogAnalyzer();
analyzer.Initialize();
return analyzer;
}所有測試都呼叫這個 factory method,當語意再次改變時只需修改一處。
使用 [SetUp] 移除重複:
[SetUp]
public void Setup()
{
logan = new LogAnalyzer();
logan.Initialize();
}但使用 setup 方法移除重複並非總是最佳方案,下一節會詳細說明。
8.2.3 以可維護的方式使用 setup 方法#
Setup() 方法容易使用但也容易被濫用。它有以下限制:
- 只能在需要初始化時使用
- 不一定是移除重複的最佳選擇(重複不只是建立和初始化物件,有時是斷言邏輯的重複)
- 不能有參數或回傳值
- 不能當作帶回傳值的 factory method 使用
- 應只包含適用於所有測試的程式碼
開發者濫用 setup 方法的常見方式:
在 setup 中初始化只有部分測試使用的物件:這是大忌。setup 方法很快就會塞滿只對某些測試有意義的物件,讀者需要來回跳轉才能理解測試在做什麼。
setup 程式碼冗長難懂:解決方案是將初始化邏輯重構成從 setup 呼叫的 helper method。但要注意過度重構(over-refactoring)可能反而降低可讀性。
在 setup 中設定 fakes(mock/stub):不建議。每個測試應透過呼叫 helper method 來建立自己的 mock 和 stub,讓讀者在測試中就能看到完整情境,不需要跳到 setup 方法才能理解。
作者已經停止在他寫的測試中使用 setup 方法。他建議只使用 factory 和 helper method,讓測試程式碼乾淨可讀,就像 production code 一樣。如果你所有的測試看起來都很像,可以考慮使用 parameterized tests(如 NUnit 的
[TestCase])來取代 setup 方法。
8.2.4 強制測試隔離#
測試隔離不足是作者在顧問工作中見到的最大測試阻礙因素。每個測試應該在自己的小世界中獨立執行,不依賴也不影響其他測試。
有幾種「測試壞味道」暗示隔離性被破壞:
| 壞味道 | 說明 |
|---|---|
| Constrained test order | 測試期望以特定順序執行 |
| Hidden test call | 測試呼叫其他測試 |
| Shared-state corruption | 測試共享記憶體狀態但沒有回復 |
| External shared-state corruption | 整合測試共享外部資源但沒有回復 |
反模式:受限的測試順序
當測試依賴其他測試所建立的狀態(例如期望前一個測試已經呼叫了 Initialize()),在框架以不同順序執行測試時就會失敗。大多數測試框架(NUnit、JUnit、MbUnit)不保證測試執行順序。
反模式:隱藏的測試呼叫
一個測試直接呼叫另一個測試方法,導致兩者產生依賴,破壞了各自的隔離性。解決方案是將共用的程式碼重構到第三個方法中,而非直接呼叫另一個測試。
反模式:共享狀態損壞
兩種表現:
- 測試修改了共享資源(記憶體或外部資源如資料庫)但沒有清理
- 測試沒有在執行前設定所需的初始狀態
[TestFixture]
public class SharedStateCorruption
{
Person person = new Person(); // 共享狀態
[Test]
public void Test1()
{
person.AddNumber("055-4556684(34)");
string found = person.FindPhoneStartingWith("055");
Assert.AreEqual("055-4556684(34)", found);
}
[Test]
public void Test2()
{
string found = person.FindPhoneStartingWith("0");
Assert.IsNull(found); // 會失敗,因為 Test1 已經加了號碼
}
}解決方案:
- 每個測試前設定狀態:必須做的基本實踐,使用 setup 方法或 helper method 確保狀態正確
- 避免共享狀態:盡量讓每個測試使用獨立的物件實例
- 謹慎管理靜態狀態:使用 setup/teardown 方法清理,或在測試中直接重設
8.2.5 避免對不同關注點的多重斷言#
當一個測試包含多個斷言且每個斷言測試不同的關注點時,第一個失敗的斷言會拋出例外,後續斷言永遠不會執行。
[Test]
public void CheckVariousSumResultsIgnoringHigherThan1001()
{
Assert.AreEqual(3, Sum(1001, 1, 2));
Assert.AreEqual(3, Sum(1, 1001, 2)); // 第一個失敗時不會執行
Assert.AreEqual(3, Sum(1, 2, 1001)); // 同上
}解決方案有三種:
- 為每個斷言建立獨立測試
- 使用 parameterized tests:推薦的做法
[TestCase(1001, 1, 2, 3)]
[TestCase(1, 1001, 2, 3)]
[TestCase(1, 2, 1001, 3)]
public void Sum_HigherThan1000_Ignored(int x, int y, int z, int expected)
{
Assert.AreEqual(expected, Sum(x, y, z));
}使用 [TestCase] 的好處是:即使其中一個案例失敗,其他案例仍然會執行,你可以看到所有案例的通過/失敗全貌。
- 用 try-catch 包裹斷言:不推薦,parameterized tests 是更好的方式
這裡的「多重斷言」是指測試不同的關注點。如果你是在驗證同一個物件的多個屬性(例如 person 的 name、age 等),多個斷言是可以的——因為那是同一個關注點的不同面向。
8.2.6 比較物件#
當測試同一個物件的多個屬性時,與其寫多個 Assert.AreEqual,不如建立一個預期物件,然後用單一斷言比較兩個物件:
[Test]
public void Analyze_SimpleStringLine_UsesDefaultTabDelimiterToParseFields2()
{
LogAnalyzer log = new LogAnalyzer();
AnalyzedOutput expected = new AnalyzedOutput();
expected.AddLine("10:05", "Open", "Roy");
AnalyzedOutput output = log.Analyze("10:05\tOpen\tRoy");
Assert.AreEqual(expected, output); // 比較整個物件
}這種做法的優點是更容易理解測試的意圖,且能認出這是一個應該整體通過的邏輯區塊。
使用物件比較時,被比較的物件必須覆寫
Equals()方法,否則比較不會正確運作。同時建議覆寫ToString()方法,讓測試失敗時的錯誤訊息更有意義,而非只顯示類型名稱。
8.2.7 避免過度指定#
過度指定(Overspecification) 的測試包含了對被測單元應如何實作其內部行為的假設,而非只驗證最終行為是否正確。過度指定的測試非常脆弱。
常見的過度指定形式:
驗證純粹的內部狀態:測試物件的內部狀態(如 internal delimiter)而非公開功能,內部狀態隨時可能改變。
// 過度指定的測試
[Test]
public void Initialize_WhenCalled_SetsDefaultDelimiterIsTabDelimiter()
{
LogAnalyzer log = new LogAnalyzer();
Assert.AreEqual(null, log.GetInternalDefaultDelimiter());
log.Initialize();
Assert.AreEqual('\t', log.GetInternalDefaultDelimiter());
}把 stub 當 mock 用:如果你使用 stub 來提供假資料,又斷言 stub 被呼叫了,這就是過度指定。測試應該讓被測方法執行其內部演算法並測試結果值,只要最終值正確,不應關心內部是否呼叫了某個方法。
// 過度指定:不應驗證 stub 是否被呼叫
A.CallTo(() => fakeData.GetUserByName("UserNameThatDoesNotExist"))
.MustHaveHappened(); // 這行不該存在假設不必要的順序或精確匹配:當只需要部分匹配時,使用 string.Contains() 而非 string.Equals(),使用集合的「包含」檢查而非「位於特定位置」檢查。
8.3 撰寫可讀的測試#
沒有可讀性,你寫的測試幾乎毫無意義。可讀性是撰寫測試的人和數月後需要閱讀它的人之間的連結。測試是故事,告訴下一代程式設計師這個應用程式是由什麼組成、從哪裡開始的。
8.3.1 命名單元測試#
命名標準很重要,因為它們提供了舒適的規則和模板,描述測試在做什麼。測試名稱應包含三個部分:
- 被測方法的名稱:讓你輕鬆看出測試的是哪個邏輯,也方便 IDE 中的導航和自動補全
- 測試的情境:「with」的部分,例如「當我用 null 值呼叫方法 X 時」
- 情境觸發時的預期行為:用白話描述方法應該做什麼或回傳什麼
常見的寫法是用底線分隔三個部分:MethodUnderTest_Scenario_Behavior()
[Test]
public void AnalyzeFile_FileWith3LinesAndFileProvider_ReadsFileUsingProvider()
{
//...
}移除任何一個部分都會迫使讀者去閱讀測試程式碼才能理解測試目的。主要目標是讓讀者不需要閱讀測試程式碼就能理解測試在做什麼。
8.3.2 命名變數#
測試中的變數命名和 production code 一樣重要(甚至更重要),因為測試也是 API 的一種文件形式。良好的變數名稱讓閱讀者能迅速理解你要證明什麼。
反例——不可讀的測試:
[Test]
public void BadlyNamedTest()
{
LogAnalyzer log = new LogAnalyzer();
int result = log.GetLineCount("abc.txt");
Assert.AreEqual(-100, result); // -100 是什麼意思?
}改善後:
[Test]
public void BadlyNamedTest()
{
LogAnalyzer log = new LogAnalyzer();
int result = log.GetLineCount("abc.txt");
const int COULD_NOT_READ_FILE = -100;
Assert.AreEqual(COULD_NOT_READ_FILE, result); // 意圖清晰
}8.3.3 撰寫有意義的斷言訊息#
盡量避免撰寫自訂斷言訊息——如果你需要它,通常代表測試名稱或變數名稱不夠清楚。但如果真的需要,請記住以下要點:
- 不要重複測試框架已經會輸出到主控台的內容
- 不要重複測試名稱已經解釋的內容
- 如果沒有有用的話要說,就什麼都別說
- 描述應該發生什麼或什麼沒有發生,以及可能的時機
反例:
Assert.AreEqual(COULD_NOT_READ_FILE, result,
"result was {0} instead of {1}",
result, COULD_NOT_READ_FILE);
// 輸出只是重複了框架已經顯示的資訊較好的訊息:
Calling GetLineCount() for a non-existing file should have returned a COULD_NOT_READ_FILE.8.3.4 分離斷言與動作#
為了可讀性,避免在同一行中同時執行方法呼叫和斷言:
// 好的寫法:分離動作與斷言
int result = log.GetLineCount("abc.txt");
Assert.AreEqual(COULD_NOT_READ_FILE, result);
// 不好的寫法:合在一起難以閱讀
Assert.AreEqual(COULD_NOT_READ_FILE, log.GetLineCount("abc.txt"));8.3.5 Setup 與 teardown 的可讀性#
Setup 和 teardown 方法可能被濫用到讓測試變得不可讀。特別是在 setup 中設定 mock 和 stub 時,閱讀測試的人可能根本不知道有 mock 物件的存在,也不知道預期是什麼。
較好的做法是直接在測試中初始化 mock 物件及其預期行為,或將建立 mock 的邏輯重構到 helper method 中,由每個測試呼叫。這樣閱讀測試的人能在測試中看到完整的設定。
作者曾多次撰寫完全沒有 setup 方法的測試類別,每個測試都呼叫 helper method。這些測試類別仍然保持可讀和可維護。
8.4 總結#
很少有開發者一開始就能寫出可信賴的測試。這需要紀律和想像力。一個你能信任的測試起初像是難以捉摸的野獸,但當你做對了,你會立刻感受到差異。
本章的核心要點是:測試會隨著被測系統成長和改變。可維護性的主題近年來受到越來越多關注,但在單元測試和 TDD 文獻中仍未被充分覆蓋。學習單元測試的第一步是掌握基礎知識(什麼是單元測試、如何撰寫),第二步則是精進技術以改善你所寫程式碼的各個面向,包括可維護性和可讀性。本章(以及本書大部分內容)聚焦的正是這關鍵的第二步。
最終,道理很簡單:可讀性與可維護性及可信賴性相輔相成。能讀懂你測試的人就能維護它們,也會在測試通過時信任它們。當這一點達成時,你就準備好面對變化、面對程式碼需要修改的時刻——因為你會知道什麼時候出了問題。