本章介紹撰寫測試的具體技巧,涵蓋如何拆分過大的測試、如何處理外部依賴、如何驗證物件間的溝通、如何測試錯誤路徑,以及如何管理程式設計階段的收尾。
Child Test#
問題: 寫出的測試太大,需要一次改動太多東西才能通過,怎麼辦?
解法: 寫一個更小的測試來代表大測試中壞掉的部分,先讓小測試通過,再回來處理大測試。
紅/綠/重構的節奏至關重要。當你不小心寫了一個需要多處改動才能通過的測試時:
- 先反省——為什麼太大了?下次怎麼避免?
- 刪除(或暫時改名讓它不被執行)那個太大的測試
- 拆分出更小的子測試,逐一實作
- 最後重新引入原本的大測試
注意: 長時間面對紅燈(即使只有十分鐘)會帶來不安感。如果你發現自己同時有兩個失敗的測試,注意觀察這對你的程式設計行為和心理狀態有什麼影響。
Mock Object#
問題: 如何測試依賴昂貴或複雜資源的物件?
解法: 建立一個假的資源版本,回傳常數值。
典型範例是資料庫——啟動慢、難以保持乾淨、綁定網路位置,還容易出錯。解法是大部分時間不用真實資料庫,改用一個行為像資料庫但實際存在記憶體中的物件:
public void testOrderLookup() {
Database db = new MockDatabase();
db.expectQuery("select order_no from Order where cust_no is 123");
db.returnResult(new String[] {"Order 2", "Order 3"});
...
}Mock Object 的優勢:
- 效能與可靠性: 不依賴外部資源
- 可讀性: 測試從頭到尾都能看懂。相較之下,如果用充滿真實資料的測試資料庫,「為什麼結果是 14」這件事很難理解
- 促進好設計: Mock Object 逼你認真考慮每個物件的可見性(visibility),降低耦合度
重點: 使用 Mock Object 意味著你不能輕易把昂貴資源放在全域變數(即使偽裝成 Singleton)。Kent Beck 分享了一個經驗——原本以為要修改數百個方法才能把 Exchange 改成參數傳遞,實際上只改了十幾個方法,還順便改善了其他設計面向。
Mock Object 帶來一個風險:Mock 的行為可能跟真實物件不一致。對策是為 Mock 準備一組測試,等真實物件可用時也對它跑同樣的測試。
Self Shunt#
問題: 如何測試一個物件是否正確地與另一個物件溝通?
解法: 讓被測物件直接與測試案例溝通,而不是與它預期的目標物件溝通。
假設要驗證 TestResult 能正確通知 listener。傳統做法是建立一個獨立的 listener 物件來計數:
def testNotification(self):
result = TestResult()
listener = ResultListener()
result.addListener(listener)
WasRun("testMethod").run(result)
assert 1 == listener.count但其實可以讓測試案例本身充當 listener(測試案例變成一種 Mock Object):
def testNotification(self):
self.count = 0
result = TestResult()
result.addListener(self)
WasRun("testMethod").run(result)
assert 1 == self.count
def startTest(self):
self.count = self.count + 1Self Shunt 的測試通常更容易閱讀——計數從 0 變成 1 的過程全在同一個地方,不需要跳到另一個類別。
補充: Self Shunt 可能需要搭配 Extract Interface 來取得要實作的介面。在 Java 中你必須實作介面的所有方法(即使大部分是空的),因此介面要盡量窄。在動態型別語言中,只需實作實際被呼叫的方法即可。
Log String#
問題: 如何測試訊息的呼叫順序是否正確?
解法: 用一個字串當日誌,每次呼叫方法時附加到字串上。
xUnit 中的範例:我們預期 Template Method 按 setUp() → 測試方法 → tearDown() 順序呼叫。
def testTemplateMethod(self):
test = WasRun("testMethod")
result = TestResult()
test.run(result)
assert("setUp testMethod tearDown " == test.log)實作:
class WasRun:
def setUp(self):
self.log = "setUp "
def testMethod(self):
self.log = self.log + "testMethod "
def tearDown(self):
self.log = self.log + "tearDown "技巧: Log String 特別適合測試 Observer 模式中通知的順序。如果你只關心哪些通知被發出但不在意順序,可以改用集合(set)比較。Log String 也很適合與 Self Shunt 搭配使用。
Crash Test Dummy#
問題: 如何測試不太可能被觸發的錯誤處理程式碼?
解法: 用一個特殊物件來模擬錯誤,該物件會拋出例外而非執行真正的工作。
沒被測試的程式碼就當作不能用——這是安全的假設。例如,要測試檔案系統滿了的情況,不需要真的填滿磁碟,只需一個假的檔案物件:
private class FullFile extends File {
public FullFile(String path) {
super(path);
}
public boolean createNewFile() throws IOException {
throw new IOException();
}
}
public void testFileSystemError() {
File f = new FullFile("foo");
try {
saveAs(f);
fail();
} catch (IOException e) {
}
}Crash Test Dummy 類似 Mock Object,但不需要模擬整個物件。Java 的匿名內部類別很適合只覆寫一個方法來模擬特定錯誤:
public void testFileSystemError() {
File f = new File("foo") {
public boolean createNewFile() throws IOException {
throw new IOException();
}
};
try {
saveAs(f);
fail();
} catch (IOException e) {
}
}Broken Test#
問題: 獨自開發時,該怎麼結束一天的工作?
解法: 留下一個失敗的測試。
這個技巧來自 Richard Gabriel 的寫作方法——在句子寫到一半時停筆。回來時看到半完成的句子,馬上就能想起當時的思路。
同樣地,結束獨自開發時寫一個會失敗的測試:
- 回來時有明確的起點
- 有具體的書籤幫助回憶思路
- 讓測試通過通常很快,迅速回到勝利的節奏
補充: 一開始你可能會擔心留著失敗的測試過夜,但程式本來就還沒完成。失敗的測試只是讓未完成的狀態更明確——快速恢復開發脈絡的價值遠超過那一點不安。
Clean Check-in#
問題: 在團隊中開發時,該怎麼結束一天的工作?
解法: 確保所有測試都通過後再 check in。
與 Broken Test 的精神相反——當你對團隊負責時,隊友需要從一個確定、可靠的狀態開始工作。
簽入時跑的測試套件可能比開發時每分鐘跑的更廣泛。如果整合套件中有測試失敗:
- 最嚴格的規則: 丟掉你的工作,重新來過。失敗的測試是很強的證據,說明你不夠了解你剛寫的東西。這個規則會促使大家更頻繁地 check in(先到先贏,不用擔心丟失工作)
- 稍微寬鬆: 花幾分鐘嘗試修復,修不好就重來
注意: 為了讓測試套件通過而註解掉測試是絕對禁止的行為。