本章介紹使用 xUnit 系列測試框架的模式,涵蓋斷言的撰寫、測試環境的建立與清理、測試方法的組織,以及例外測試和測試套件的管理。
Assertion#
問題: 如何檢查測試是否正確運作?
解法: 撰寫布林表達式來自動化你對程式碼是否正確的判斷。
全自動測試意味著所有人為判斷都必須被移除:
- 判斷結果必須是布林值——true 代表正常,false 代表異常
- 使用某種
assert()方法來檢查
要具體。 像 assertTrue(rectangle.area() != 0) 這樣的斷言太弱——任何非零值都能通過。如果面積應該是 50,就明確寫出:
assertEquals(50, rectangle.area());以黑箱思維撰寫斷言#
避免依賴內部實作來寫斷言。例如:
// 不好——依賴 status 的內部實作
Contract contract = new Contract();
contract.begin();
assertEquals(Running.class, contract.status.class);
// 好——透過公開行為驗證
assertEquals(..., contract.startDate()); // 如果狀態不是 Running 會拋例外重點: 想要白箱測試通常不是測試問題,而是設計問題。每次想用內部變數來驗證程式碼是否正確時,其實是改善設計的機會。如果暫時想不到好設計,先檢查變數、記下來日後再回來處理也無妨。
斷言訊息#
原始的 SUnit(Smalltalk 版)斷言很簡單——失敗就跳出 debugger。因為 Java 環境較常在批次模式下建置,加入斷言訊息是有意義的:
assertTrue("Should be true", false);
// 輸出:Assertion failed: Should be true是否所有斷言都需要訊息?試試兩種方式,看看投資在錯誤訊息上是否值得。
Fixture#
問題: 多個測試需要相同的物件設置,如何處理?
解法: 將測試中的區域變數轉為實例變數,覆寫 setUp() 進行初始化。
測試中物件設置的程式碼往往比操作和驗證的程式碼還多。重複的設置程式碼有壞處:
- 寫起來花時間(即使是複製貼上)
- 介面改動時需要改多處
但也有好處:
- 設置程式碼和斷言放在一起,測試從頭到尾可讀
xUnit 支援兩種風格。你可以把設置程式碼留在測試方法中(如果預期讀者不容易記住 fixture 物件),也可以提取到 setUp() 中:
// 提取前
public void testEmpty() {
Rectangle empty = new Rectangle(0, 0, 0, 0);
assertTrue(empty.isEmpty());
}
public void testWidth() {
Rectangle empty = new Rectangle(0, 0, 0, 0);
assertEquals(0.0, empty.getWidth(), 0.0);
}
// 提取後
private Rectangle empty;
public void setUp() {
empty = new Rectangle(0, 0, 0, 0);
}
public void testEmpty() {
assertTrue(empty.isEmpty());
}
public void testWidth() {
assertEquals(0.0, empty.getWidth(), 0.0);
}補充: TestCase 子類別和其實例之間的關係是 xUnit 最容易混淆的部分。每種不同的 fixture 應該是 TestCase 的新子類別。每個 fixture 被建立、使用一次、然後丟棄。測試類別與模型類別之間沒有簡單的一對一對應——有時一個 fixture 測試多個類別,有時一個模型類別需要多個 fixture。
External Fixture#
問題: 測試中使用的外部資源如何釋放?
解法: 覆寫 tearDown() 來釋放資源。
每個測試的目標是讓世界恢復到與執行前完全相同的狀態。例如測試中開啟了檔案,就必須確保關閉。如果用 try/finally 手動處理:
def testMethod(self):
try:
... # run the test
finally:
self.file.close()問題在於:finally 子句造成重複、容易忘記、還有三行與測試無關的噪音。
xUnit 保證 tearDown() 一定會在測試方法之後被呼叫(不管測試方法中發生什麼),所以可以寫成:
def setUp(self):
self.file = File("foobar").open()
def testMethod(self):
... # run the test
def tearDown(self):
self.file.close()注意: 如果
setUp()失敗,tearDown()不會被呼叫。
Test Method#
問題: 如何表示一個測試案例?
解法: 以 test 開頭的方法來表示。
系統中會有成百上千個測試,需要好的組織方式。物件導向語言有三個層次:Module/Package → Class → Method。既然用類別代表 fixture,測試自然就是該類別的方法。
命名#
方法名稱的其餘部分應該讓未來的讀者理解為什麼寫這個測試。例如 JUnit 中有個測試叫 testAssertPosInfinityNotEqualsNegInfinity——光從名稱就能推測曾經有個 bug 是浮點比較沒有區分正無窮和負無窮。
撰寫原則#
- 測試方法應該容易閱讀,基本上是直線程式碼
- 如果測試方法變長變複雜,就玩「Baby Steps」遊戲——寫出代表真正進展的最小測試
- 大約三行是最小的合理長度
技巧: Patrick Logan 的做法是先用註解建立大綱(outline),再逐步填入具體測試:
/* Adding to tuple spaces. */ /* Taking from tuple spaces. */ /** Taking a non-existent tuple. **/ /** Taking an existing tuple. **/ /** Taking multiple tuples. **/ /* Reading from tuple space. */大綱最終成為被測類別的契約(contract)文件。
Exception Test#
問題: 如何測試預期會拋出的例外?
解法: 捕捉預期的例外並忽略它,只在例外沒有被拋出時才標記失敗。
public void testMissingRate() {
try {
exchange.findRate("USD", "GBP");
fail();
} catch (IllegalArgumentException expected) {
}
}關鍵細節:
- 如果
findRate()沒有拋出例外,fail()會被執行,報告測試失敗 - 只捕捉特定的例外型別(
IllegalArgumentException),這樣如果拋出了錯誤類型的例外(包括斷言失敗),也會被通知
All Tests#
問題: 如何一次跑完所有測試?
解法: 為每個 package 建立一個測試套件,再建立一個聚合所有 package 套件的總套件。
理想情況是:新增一個 TestCase 子類別和測試方法後,下次跑全部測試時它就自動被包含。由於大多數 xUnit 實作和 IDE 不直接支援這一點,每個 package 應該宣告一個 AllTests 類別:
public class AllTests {
public static void main(String[] args) {
junit.swingui.TestRunner.run(AllTests.class);
}
public static Test suite() {
TestSuite result = new TestSuite("TFD tests");
result.addTestSuite(MoneyTest.class);
result.addTestSuite(ExchangeTest.class);
result.addTestSuite(IdentityRateTest.class);
return result;
}
}技巧: 給
AllTests一個main()方法,讓它可以直接從 IDE 或命令列執行。