模擬 JDBC 進行單元測試
1. 概述
在本教程中,我們將討論使用 JDBC 物件與資料庫互動的測試程式碼。首先,我們將使用 Mockito 來存根所有涉及取得 JDBC Connection
、建立Statement
、執行查詢和從結果集中檢索資料的**java.sql
物件**ResultSet.
之後,我們將分析這種方法的優缺點,並了解為什麼嵌套模擬通常會導致脆弱的測試。最後,我們將探索其他替代方案,例如選擇範圍更大的測試或重構程式碼以提高其可測試性。
2. 程式碼範例
對於本文中的程式碼範例,我們假設我們正在開發一個 Java 類,負責執行 SQL 查詢、過濾ResultSet
並將其對應到 Java 物件。
我們要測試的類別將會接收一個DataSource
作為依賴項,並且它將使用這個DataSource
來取得Connection,
建立Statement,
並執行查詢:
class CustomersService {
private final DataSource dataSource;
// constructor
public List<Customer> customersEligibleForOffers() throws SQLException {
try (
Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()
) {
ResultSet resultSet = stmt.executeQuery("SELECT * FROM Customers");
List<Customer> customers = new ArrayList<>();
while (resultSet.next()) {
Customer customer = mapCustomer(resultSet);
// more business logic ...
if (customer.status() == Status.ACTIVE
|| customer.status() == Status.LOYAL) {
customers.add(customer);
}
}
return customers;
}
}
private Customer mapCustomer(ResultSet resultSet) throws SQLException {
return new Customer(
resultSet.getInt("id"),
resultSet.getString("name"),
Status.valueOf(resultSet.getString("status"))
);
}
}
正如我們在customersEligibleForOffer()
方法中看到的,我們查詢Customers
表並以程式方式過濾掉status.
然後,我們利用ResultSet's
API 取得建立Customer
物件的相關資訊並傳回它們的清單。
3.模擬java.sql
對象
為了測試CustomerService
類別,我們可以嘗試模擬DataSource
依賴項。然而,我們很快就會意識到單一模擬物件是不夠的。發生這種情況的原因是DataSource
啟動了一個Connection
,它會建立一個Statement
,而該 Statement 會產生一個ResultSet
,其中包含我們想要存根的原始資料。
簡而言之,如果我們想使用 Mockito 從ResultSet
中存根數據,我們需要引入四個模擬:
@ExtendWith(MockitoExtension.class)
class JdbcMockingUnitTest {
@Mock
DataSource dataSource;
@Mock
Connection conn;
@Mock
Statement stmt;
@Mock
ResultSet resultSet;
@Test
void whenFetchingEligibleCustomers_thenTheyHaveCorrectStatus() throws Exception {
// ...
}
}
然後,我們將確保每個模擬在呼叫時都會傳回下一個模擬,從DataSource
一直到ResultSet
:
@Test
void whenFetchingEligibleCustomers_thenTheyHaveCorrectStatus() throws Exception {
CustomersService customersService = new CustomersService(dataSource);
when(dataSource.getConnection())
.thenReturn(conn);
when(conn.createStatement())
.thenReturn(stmt);
when(stmt.executeQuery("SELECT * FROM customers"))
.thenReturn(resultSet);
// ...
}
之後,我們將會加入ResultSet
本身的存根。我們將其配置為傳回三個具有不同狀態的客戶:
when(resultSet.next())
.thenReturn(true, true, true, false);
when(resultSet.getInt("id"))
.thenReturn(1, 2, 3);
when(resultSet.getString("name"))
.thenReturn("Alice", "Bob", "John");
when(resultSet.getString("status"))
.thenReturn("LOYAL", "ACTIVE", "INACTIVE");
最後,我們將進行一些斷言。在這種情況下,我們預計只有Alice
和Bob
會被回報為有資格獲得優惠的客戶。
我們來看看整個測試:
@Test
void whenFetchingEligibleCustomers_thenTheyHaveCorrectStatus() throws Exception {
//given
CustomersService customersService = new CustomersService(dataSource);
when(dataSource.getConnection())
.thenReturn(conn);
when(conn.createStatement())
.thenReturn(stmt);
when(stmt.executeQuery("SELECT * FROM customers"))
.thenReturn(resultSet);
when(resultSet.next())
.thenReturn(true, true, true, false);
when(resultSet.getInt("id"))
.thenReturn(1, 2, 3);
when(resultSet.getString("name"))
.thenReturn("Alice", "Bob", "John");
when(resultSet.getString("status"))
.thenReturn("LOYAL", "ACTIVE", "INACTIVE");
// when
List<Customer> eligibleCustomers = customersService.customersEligibleForOffers();
// then
assertThat(eligibleCustomers).containsExactlyInAnyOrder(
new Customer(1, "Alice", Status.LOYAL),
new Customer(2, "Bob", Status.ACTIVE)
);
}
就是這樣!我們現在可以執行測試並驗證被測試的元件是否正確過濾和映射ResultSet
資料。
4.缺點
儘管我們的解決方案允許測試過濾和映射邏輯,但測試冗長而脆弱。雖然我們可以提取一些輔助方法來提高可讀性,但測試與實現緊密耦合。
巢狀模擬需要嚴格的方法呼叫順序。這使得測試變得脆弱,即使重構不會改變我們函數的行為,測試也會失敗。這種脆弱性的出現是因為我們的元件與它作為依賴項接收的DataSource
緊密耦合。如果我們重構程式碼以與任何模擬物件進行不同的交互,那麼即使底層行為保持不變,測試也會失敗。
簡而言之,模擬不允許我們改變獲取連接的方式、使用的語句類型或 SQL 查詢本身——否則會破壞測試。如果我們切換到更高層級的 API 與資料庫互動(如JdbcTemplate
或JdbcClient
),也會發生同樣的情況。
有時,過於脆弱或難以編寫的測驗會揭示更深層的設計問題。在我們的案例中,它們暴露了我們的自訂邏輯和持久層內部之間缺乏明確的分離——違反了單一責任原則。
5.替代方案
我們可以透過測試更大的範圍來避免模擬返回其他模擬的反模式。我們不需要編寫單元測試和模擬 JDBC 對象,而是可以編寫整合測試並利用嵌入式 H2 資料庫或 Testcontainers 等工具進行資料庫互動。
另一方面,我們可以重構程式碼來分離兩個不同的職責。例如,我們可以應用依賴倒置原則,確保CustomerServiceV2
依賴從資料庫取得資料的接口,而不是直接依賴java.sql
API。通常,我們會建立一個自訂接口,但為了簡潔起見,我們將使用 Java 的Supplier
:
class CustomersServiceV2 {
private final Supplier<List<Customer>> findAllCustomers;
// constructor
public List<Customer> customersEligibleForOffers() {
return findAllCustomers.get()
.stream()
.filter(customer -> customer.status() == Status.ACTIVE
|| customer.status() == Status.LOYAL)
.toList();
}
}
接下來,我們將建立一個實作Supplier<List<Customer>>
介面的類別,並在內部使用 JDBC 來取得和映射資料:
class AllCustomers implements Supplier<List<Customer>> {
private final DataSource dataSource;
// constructor
@Override
public List<Customer> get() {
try (
Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()
) {
ResultSet resultSet = stmt.executeQuery("SELECT * FROM customers");
List<Customer> customers = new ArrayList<>();
while (resultSet.next()) {
customers.add(mapCustomer(resultSet));
}
return customers;
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
private Customer mapCustomer(ResultSet resultSet) throws SQLException {
// ...
}
}
由於我們的領域服務與持久性機制分離,我們可以自由地測試我們的業務邏輯,而無需為每個 JDBC 物件建立模擬。此外, Supplier
是一個功能接口,這意味著我們根本不需要模擬就可以測試CustomerServiceV2
!相反,我們只需使用 lambda 表達式提供Supplier
的不同的內聯測試實作:
@Test
void whenFetchingEligibleCustomersFromV2_thenTheyHaveCorrectStatus() {
// given
List<Customer> allCustomers = List.of(
new Customer(1, "Alice", Status.LOYAL),
new Customer(2, "Bob", Status.ACTIVE),
new Customer(3, "John", Status.INACTIVE)
);
CustomersServiceV2 service = new CustomersServiceV2(() -> allCustomers);
// when
List<Customer> eligibleCustomers = service.customersEligibleForOffers();
// then
assertThat(eligibleCustomers).containsExactlyInAnyOrder(
new Customer(1, "Alice", Status.LOYAL),
new Customer(2, "Bob", Status.ACTIVE)
);
}
這些替代方案使我們能夠重構程式碼或改變從資料庫取得資料的方式,而不會影響測試結果。我們可以看出,這兩種方法都更關注系統行為,因此可以進行更穩健的測試。
6. 結論
在本文中,我們學習如何使用 Mockito 測試使用 JDBC 與資料庫互動的程式碼。我們發現java.sql
API 要求我們創建多個嵌套的模擬 - 並且我們解釋了為什麼這可能被認為是一種不好的做法。
隨後,我們重構了程式碼,以獲得更易於測試的解決方案,並在不使用任何模擬的情況下對其進行了驗證。此外,我們還討論了使用嵌入式 H2 資料庫或 Testcontainers 等專用工具,使我們能夠真正測試我們的程式碼和資料庫之間的整合。
與往常一樣,文章中的所有程式碼範例都可以在 GitHub 上找到。