專案需使用 JDBC 呼叫 Oracle 存儲過程 LUCK_LOAD_COMMON(?, ?),過往經驗多為 Hibernate/Spring 測試 (框架內部正確性無需驗證)。JDBC 直接操作資料庫連線、預存程序呼叫、參數綁定、結果集處理,**EasyMock 記錄-回放模式**可精確驗證呼叫序列與參數。
public String getNumber() {
CallableStatement cstmt = null;
try {
cstmt = _conn.prepareCall("{call LUCK_LOAD_COMMON(?,?)}");
cstmt.setString(1, "1");
cstmt.registerOutParameter(2, java.sql.Types.VARCHAR);
cstmt.executeUpdate();
return cstmt.getString(2);
} catch (Exception e) {
GxDebug.logException(e);
e.printStackTrace();
return null;
} finally {
if (cstmt != null) try { cstmt.close(); } catch (Exception ignore) {}
}
}
import static org.easymock.EasyMock.*;
import org.easymock.classextension.MockControl; // 類別擴充支援介面/類別 Mock
import java.sql.*;
public class JdbcDaoTest {
private MockControl conControl;
private MockControl cstmtControl;
private Connection conn;
private CallableStatement cstmt;
private JdbcDao dao; // 被測 DAO,構造器注入 Connection
@Before
public void setUp() {
// 建立 Mock 控制器
conControl = MockControl.createControl(Connection.class);
cstmtControl = MockControl.createControl(CallableStatement.class);
// 獲取 Mock 物件
conn = (Connection) conControl.getMock();
cstmt = (CallableStatement) cstmtControl.getMock();
// DAO 注入 Mock Connection
dao = new JdbcDao(conn);
}
@Test
public void testGetNumber_Success() {
// ========== 記錄階段 ==========
// 1. Connection.prepareCall() 返回 Mock CallableStatement
conn.prepareCall("{call LUCK_LOAD_COMMON(?,?)}");
conControl.setReturnValue(cstmt);
// 2. CallableStatement 參數綁定序列
cstmt.setString(1, "1");
cstmt.registerOutParameter(2, Types.VARCHAR);
// 3. 執行更新 (無返回值驗證)
cstmt.executeUpdate();
cstmtControl.setReturnValue(1); // executeUpdate 返回影響行數
// 4. 獲取輸出參數
cstmt.getString(2);
cstmtControl.setReturnValue("5,4,3"); // 模擬存儲過程返回值
// 5. 關閉資源
cstmt.close();
// 切換至回放模式 (順序重要:先子物件後父物件)
cstmtControl.replay();
conControl.replay();
// ========== 回放階段 ==========
String result = dao.getNumber();
// ========== 驗證 ==========
assertEquals("5,4,3", result);
conControl.verify();
cstmtControl.verify();
}
@Test
public void testGetNumber_SQL_String_Mismatch() {
// 記錄階段:預期正確 SQL
conn.prepareCall("{call LUCK_LOAD_COMMON(?,?)}");
conControl.setReturnValue(cstmt);
conControl.replay();
// 回放階段:被測代碼若 SQL 字串不同 (如多一個參數)
// dao.getNumber() 內部會呼叫 prepareCall("{call LUCK_LOAD_COMMON(?,?,?)}")
// 驗證階段:EasyMock 拋出 AssertionFailedError
// Unexpected method call prepareCall("{call LUCK_LOAD_COMMON(?,?)}"):
// prepareCall("{call LUCK_LOAD_COMMON(?,?)}"): expected: 0, actual: 1
// prepareCall("{call LUCK_LOAD_COMMON(?,?,?)}"): expected: 1, actual: 0
}
}
| 技術點 | 說明 | 最佳實踐 |
|---|---|---|
| MockControl vs EasyMock.createMock() | MockControl 提供更精細控制 (replay/verify/reset 分離) | 複雜序列驗證用 MockControl,簡單場景用 createMock() |
| 介面 vs 類別 Mock | JDBC API 為介面 (Connection, CallableStatement),標準 EasyMock 即可 | 若需 Mock 具體類別 (如 OraclePreparedStatement),需 EasyMock ClassExtension |
| 參數匹配精度 | 預設 equals() 比較,SQL 字串必須完全一致 (含空格、大小寫) | 使用 eq()、anyString()、aryEq() 等匹配器放寬/收緊條件 |
| void 方法處理 | cstmt.close() 無返回值,記錄階段直接呼叫即可 | 無需 setReturnValue,但需在記錄序列中包含 |
| 異常模擬 | cstmtControl.setThrowable(new SQLException("DB down")) | 測試 DAO 異常處理邏輯 (try-catch-finally 資源釋放) |
| 驗證順序 | 先 verify 子物件 (cstmt),再 verify 父物件 (conn) | 符合依賴關係,避免遺漏驗證 |
原文作者感嘆:「測試代碼比實現代碼還多」。這是 JDBC 程式化 API 特性導致的必然結果:
| 策略 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|
| 封裝 JDBC 模板類 | 測試模板而非每個 DAO、複用 Mock 邏輯 | 增加抽象層、可能掩蓋具體 SQL 錯誤 | 大量相似 JDBC 操作專案 |
| 使用 Spring JdbcTemplate / SimpleJdbcCall | 框架封裝 JDBC 細節、測試關注業務參數 | 引入 Spring 依賴、仍需 Mock JdbcTemplate | Spring 生態專案 |
| 整合測試替代單元測試 | 真實資料庫驗證端到端正確性、SQL 執行計畫 | 需測試資料庫、速度慢、環境依賴 | 關鍵業務邏輯、CI 階段 |
| DbUnit + 真實資料庫 | 資料集載入/驗證、狀態隔離 | 配置複雜、維護成本高 | 複雜資料邏輯驗證 |
| 契約測試 | 驗證 DAO 介面契約而非實現細節 | 無法驗證具體 SQL/參數正確性 | 微服務介面契約驗證 |
EasyMock 以記錄-回放模式精確驗證 JDBC 呼叫序列、參數綁定、返回值處理,能發現 SQL 字串不匹配、參數索引錯誤、資源未釋放等靜態分析難以發現的問題。但 JDBC 程式化 API 導致測試代碼冗長、脆弱、維護成本高。**建議策略**:核心/複雜存儲過程呼叫寫 EasyMock 單元測試,常規 CRUD 採用 Spring JdbcTemplate + 整合測試,建立分層測試金字塔。若專案大量使用 JDBC 直連,考慮引入 Spring JDBC 或 MyBatis 封裝,從源頭減少樣板代碼與測試負擔。