JUnit 讓測試變得簡單,但「會用工具」不等於「會測試」。許多團隊擁有大量通過的測試,卻在生產環境頻現 Bug。本文揭示五大常見 JUnit 反模式,剖析成因並給出改進方案,助你從「寫測試」進階到「寫好測試」。
現象:只驗證正常輸入下的預期輸出,忽略邊界條件、異常輸入、錯誤處理路徑。
危害:極易產生「誤報」。例如階乘函數 factorial(3)=6 通過測試,但實現可能是 return 6 或 用加法代替乘法,單一測試無法發現。
public class FactorialTest {
@Test
public void testHappyPath() {
assertEquals(6, Factorial.eval(3));
assertEquals(120, Factorial.eval(5)); // 多個正交測試點
}
@Test
public void testBoundary() {
assertEquals(1, Factorial.eval(0)); // 0! = 1
assertEquals(1, Factorial.eval(1)); // 邊界值
}
@Test(expected = IllegalArgumentException.class)
public void testInvalidInput() {
Factorial.eval(-3); // 負數拋出異常
}
@Test
public void testOverflow() {
// 大數溢出行為驗證
assertEquals(-3258495067890909184L, Factorial.eval(20)); // long 溢出具體值
}
}
現象:為了驗證邏輯,破壞封裝訪問私有字段,或測試 equals/hashCode/toString 等易測方法,而忽略核心業務契約。
案例:測試 Stack 的 push 方法時,直接檢查內部 elements 列表:
// 錯誤:破壞封裝
List elements = stack.getElementsList(); // 公開內部結構
assertEquals(expectedElement, elements.get(0));
// 正確:驗證 push-pop 契約
@Test
public void testPushPopContract() {
Stack stack = new Stack();
Object item = new Object();
assertEquals(item, stack.push(item).pop()); // 契約:push 後 pop 返回同一物件
assertTrue(stack.isEmpty());
}
@Test
public void testFILOContract() {
Stack stack = new Stack();
Object first = new Object();
Object second = new Object();
stack.push(first);
stack.push(second);
assertEquals(second, stack.pop()); // 契約:後進先出
assertEquals(first, stack.pop());
assertTrue(stack.isEmpty());
}
原則:測試「它做什麼」,而非「怎麼做」。契約測試天然支援重構,內部實現變更不影響測試。
現象:測試代碼充滿 I/O 操作、字節解析、偏移計算,邏輯比被測代碼更複雜,難以閱讀、維護、驗證正確性。
// 過度複雜:測試混雜文件讀取、字節解析、位運算
File file = new File("customer.rec");
FileInputStream fis = new FileInputStream(file);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[16];
int numRead;
while ((numRead = fis.read(buffer)) >= 0) {
baos.write(buffer, 0, numRead);
}
byte[] record = baos.toByteArray();
// ... 大量位運算、字符串截取、字段偏移計算
// 簡潔測試:意圖清晰,委派基礎設施處理複雜性
@Test
public void testRecordContainsCorrectCustomerData() {
Customer customer = new Customer(1001, "Estragon", items);
BillingCenter.processCustomer(customer);
RecordFileFacade records = new RecordFileFacade("customer.rec");
assertEquals(1, records.getTotalRecords());
RecordFacade record = records.get(0);
assertEquals("Estragon", record.getName());
assertEquals(1001, record.getId());
for (int i = 0; i < 4; i++) {
assertEquals(expectedItems[i], record.getItemName(i));
}
}
基礎設施類:RecordFileFacade 負責文件讀取與記錄分割,RecordFacade 負責單記錄解析。這些類本身可獨立測試,形成分層測試金字塔。
現象:大量相似測試代碼複製粘貼,設置邏輯重複,維護成本高。
@BeforeEach / @BeforeAll 提取公共設置@ParameterizedTest)覆蓋多種輸入組合現象:測試方法命名無意義(test1())、無清晰的 Given-When-Then 結構、斷言訊息缺失。
@Test
@DisplayName("轉帳金額超過餘額時應拋出異常")
public void transfer_insufficientFunds_throwsException() {
// Given
Account from = new Account("A", 100);
Account to = new Account("B", 0);
// When & Then
assertThrows(InsufficientFundsException.class, () -> {
transferService.transfer(from, to, 150);
}, "餘額不足時應拋出異常");
}
1000 個低質量通過測試,不如 100 個高質量測試。測試代碼是產品代碼的「活文檔」與「安全網」,值得同等的設計投入。從今天開始,拒絕反模式,寫出經得起重構、能真正發現缺陷的好測試。