第三部分:理論三
阿新 • • 發佈:2021-10-04
第三部分:理論三
編寫可測試程式碼案例實戰
測試類
- Transaction 是經過抽象簡化之後的一個電商系統的交易類,用來記錄每筆訂單交易的情況。
- execute() 函式負責執行轉賬操作,將錢從買家的錢包轉到賣家的錢包中。
- 在execute() 中,真正的轉賬操作是通過呼叫 WalletRpcService RPC 服務來完成的。
- 在execute() 中,還涉及一個分散式鎖 DistributedLock 單例類,用來避免 Transaction 併發執行,導致使用者的錢被重複轉出。
Transaction 類:
public class Transaction { private String id; private Long buyerId; private Long sellerId; private Long productId; private String orderId; private Long createTimestamp; private Double amount; private STATUS status; private String walletTransactionId; // ...get() methods... public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) { if (preAssignedId != null && !preAssignedId.isEmpty()) { this.id = preAssignedId; } else { this.id = IdGenerator.generateTransactionId(); } if (!this.id.startWith("t_")) { this.id = "t_" + preAssignedId; } this.buyerId = buyerId; this.sellerId = sellerId; this.productId = productId; this.orderId = orderId; this.status = STATUS.TO_BE_EXECUTD; this.createTimestamp = System.currentTimestamp(); } public boolean execute() throws InvalidTransactionException { if ((buyerId == null || (sellerId == null || amount < 0.0) { throw new InvalidTransactionException(...); } if (status == STATUS.EXECUTED) return true; boolean isLocked = false; try { isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(isLocked); if (!isLocked) { return false; // 鎖定未成功,返回 false,job 兜底執行 } if (status == STATUS.EXECUTED) return true; // double check long executionInvokedTimestamp = System.currentTimestamp(); if (executionInvokedTimestamp - createdTimestap > 14days) { this.status = STATUS.EXPIRED; return false; } WalletRpcService walletRpcService = new WalletRpcService(); String walletTransactionId = walletRpcService.moveMoney(id, buyerId, status); if (walletTransactionId != null) { this.walletTransactionId = walletTransactionId; this.status = STATUS.EXECUTED; return true; } else { this.status = STATUS.FAILED; return false; } } finally { if (isLocked) { RedisDistributedLock.getSingletonIntance().unlockTransction(id); } } } }
測試用例
- 正常情況下,交易執行成功,回填用於對賬(交易與錢包的交易流水)用的 walletTransactionId,交易狀態設定為 EXECUTED,函式返回 true。
- buyerId、sellerId 為 null、amount 小於 0,返回 InvalidTransactionException。
- 交易已過期(createTimestamp 超過 14 天),交易狀態設定為 EXPIRED,返回 false。
- 交易已經執行了(status==EXECUTED),不再重複執行轉錢邏輯,返回 true。
- 錢包(WalletRpcService)轉錢失敗,交易狀態設定為 FAILED,函式返回 false。
- 交易正在執行著,不會被重複執行,函式直接返回 false。
實現測試用例1
- 向類Transaction 中賦值buyerId、sellerId、productId、orderId,執行transaction.execute()。
- 此用例有以下四個問題:
- 如果要讓這個單元測試能夠執行,我們需要搭建 Redis 服務和 Wallet RPC 服務。搭建和維護的成本比較高。
- 我們還需要保證將偽造的 transaction 資料傳送給 Wallet RPC 服務之後,能夠正確返回我們期望的結果,然而 Wallet RPC 服務有可能是第三方(另一個團隊開發維護的)的服務,並不是我們可控的。換句話說,並不是我們想讓它返回什麼資料就返回什麼。
- Transaction 的執行跟 Redis、RPC 服務通訊,需要走網路,耗時可能會比較長,對單元測試本身的執行效能也會有影響。
- 網路的中斷、超時、Redis、RPC 服務的不可用,都會影響單元測試的執行。
- 通過繼承 WalletRpcService 類,並且重寫其中的 moveMoney() 函式的方式來實現 mock。
- 通過 mock (見下文:mock)的方式,我們可以讓 moveMoney() 返回任意我們想要的資料,完全在我們的控制範圍內,並且不需要真正進行網路通訊。
- 因為 WalletRpcService 是在 execute() 函式中通過 new 的方式建立的,我們無法動態地對其進行替換。也就是說,Transaction 類中的 execute() 方法的可測試性很差,需要通過重構(見下文:重構 Transaction 類)來讓其變得更容易測試。
- 重構後在單元測試中,使用依賴注入,非常容易地將 WalletRpcService 替換成 MockWalletRpcServiceOne 或 WalletRpcServiceTwo 傳入 setWalletRpcService() 中。
- RedisDistributedLock 的 mock 替換。
- RedisDistributedLock 是一個單例類。單例相當於一個全域性變數,我們無法 mock(無法繼承和重寫方法),也無法通過依賴注入的方式來替換。
- 如果 RedisDistributedLock 是我們自己維護的,可以自由修改、重構,那我們可以將其改為非單例的模式,或者定義一個介面,比如 IDistributedLock,讓 RedisDistributedLock 實現這個介面。接下來我們就可以用前面的方法 mock 替換。
- 但如果 RedisDistributedLock 不是我們維護的,我們無權去修改這部分程式碼,可以對 transaction 上鎖這部分邏輯重新封裝一下(見下文:重新封裝transaction 上鎖)。
- 這樣,我們就能在單元測試程式碼中隔離真正的 RedisDistributedLock 分散式鎖這部分邏輯了。可以呼叫 transaction.setTransactionLock()。
- 我們通過依賴注入和 mock,讓單元測試程式碼不依賴任何不可控的外部服務。完成測試用例1。
測試用例 1 程式碼實現:
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productI
boolean executedResult = transaction.execute();
assertTrue(executedResult);
}
mock
- 所謂的 mock 就是用一個“假”的服務替換真正的服務。mock 的服務完全在我們的控制之下,模擬輸出我們想要的資料。
- mock 的方式主要有兩種,手動 mock 和利用框架 mock。
- 利用框架 mock 僅僅是為了簡化程式碼編寫,每個框架的 mock 方式都不大一樣。
重構 Transaction 類
- 應用依賴注入,將 WalletRpcService 物件的建立反轉給上層邏輯,在外部建立好之後,再注入到 Transaction 類中。
- 重構後的 Transaction 類中,增加 setWalletRpcService() 方法,將 WalletRpcService 物件傳入。
- 在 execute() 方法中刪除 new WalletRpcService(),直接使用 this.walletRpcService。
重新封裝transaction 上鎖
- 增加 TransactionLock 類,裡面的方法lock() 和 unlock() 呼叫原來的分散式鎖 DistributedLock 單例類。
- 然後在 Transaction 中 setTransactionLock() ,依賴注入 TransactionLock 類。
transaction 上鎖程式碼實現:
public class TransactionLock {
public boolean lock(String id) {
return RedisDistributedLock.getSingletonIntance().lockTransction(id);
}
public void unlock() {
RedisDistributedLock.getSingletonIntance().unlockTransction(id);
}
}
public class Transaction {
//...
private TransactionLock lock;
public void setTransactionLock(TransactionLock lock) {
this.lock = lock;
}
public boolean execute() {
//...
try {
isLocked = lock.lock();
//...
} finally {
if (isLocked) {
lock.unlock();
}
}
//...
}
}
RedisDistributedLock 分散式鎖單元測試:
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
TransactionLock mockLock = new TransactionLock() {
public boolean lock(String id) {
return true;
}
public void unlock() {}
};
Transction transaction = new Transaction(null, buyerId, sellerId, productI
transaction.setWalletRpcService(new MockWalletRpcServiceOne());
transaction.setTransactionLock(mockLock);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}
實現測試用例3
- 將建立時間設定為14天前,來測試交易過期。transaction.setCreatedTimestamp(System.currentTimestamp() - 14days)。
- 但是,如果在 Transaction 類中,並沒有暴露修改 createdTimestamp 成員變數的 set 方法(也就是沒有定義 setCreatedTimestamp() 函式)呢?
- 你可能會說,如果沒有 createTimestamp 的 set 方法,我就重新新增一個唄!實際上,這違反了類的封裝特性。
- 在 Transaction 類的設計中,createTimestamp 是在交易生成時(也就是建構函式中)自動獲取的系統時間,本來就不應該人為地輕易修改。因為,我們無法控制使用者是否會呼叫 set 方法重設 createTimestamp,而重設 createTimestamp 並非我們的預期行為。
- 程式碼中包含跟“時間”有關的“未決行為”邏輯,我們一般的處理方式是將這種未決行為邏輯重新封裝(見下文:重新封裝未決行為邏輯)。
- 這樣我們在測試類中可以重寫 isExpired() 方法,隨意更改返回交易是否過期。
交易已過期(createTimestamp 超過 14 天)測試樣例:
public void testExecute_with_TransactionIsExpired() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}
重新封裝未決行為邏輯
- 在 Transaction 類中增加 isExpired() 方法,封裝交易是否過期邏輯。
- 在 execute() 方法中,可以直接呼叫 isExpired() 方法來判斷過期問題。
封裝未決行為程式碼實現:
public class Transaction {
protected boolean isExpired() {
long executionInvokedTimestamp = System.currentTimestamp();
return executionInvokedTimestamp - createdTimestamp > 14days;
}
public boolean execute() throws InvalidTransactionException {
//...
if (isExpired()) {
this.status = STATUS.EXPIRED;
return false;
}
//...
}
}
測試 Transaction 類的建構函式
- 造函式中並非只包含簡單賦值操作。
- 交易 id 的賦值邏輯稍微複雜,可以把 id 賦值這部分邏輯單獨抽象到一個方法中 fillTransactionId()。
重構之後的測試用例:
public void testExecute_with_TransactionIsExpired() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId)
protected boolean isExpired() {
return true;
}
};
boolean actualResult = transaction.execute();
assertFalse(actualResult);
assertEquals(STATUS.EXPIRED, transaction.getStatus());
}
最終建構函式的程式碼:
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
if (preAssignedId != null && !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith("t_")) {
this.id = "t_" + preAssignedId;
}
this.buyerId = buyerId;
this.sellerId = sellerId;
this.productId = productId;
this.orderId = orderId;
this.status = STATUS.TO_BE_EXECUTD;
this.createTimestamp = System.currentTimestamp();
}
實戰總結
- 重構之後的程式碼,不僅可測試性更好,而且從程式碼設計的角度來說,也遵從了經典的設計原則和思想。
- 程式碼的可測試性可以從側面上反應程式碼設計是否合理。
- 在平時的開發中,我們也要多思考一下,這樣編寫程式碼,是否容易編寫單元測試,這也有利於我們設計出好的程式碼。
其他常見的 Anti-Patterns
未決行為
- 所謂的未決行為邏輯就是,程式碼的輸出是隨機或者說不確定的,比如,跟時間、隨機數有關的程式碼。
public class Demo {
public long caculateDelayDays(Date dueTime) {
long currentTimestamp = System.currentTimeMillis();
if (dueTime.getTime() >= currentTimestamp) {
return 0;
}
long delayTime = currentTimestamp - dueTime.getTime();
long delayDays = delayTime / 86400;
return delayDays;
}
}
全域性變數
- 前面我們講過,全域性變數是一種面向過程的程式設計風格,有種種弊端。實際上,濫用全域性變數也讓編寫單元測試變得困難。
- 文中舉例:
- RangeLimiter 表示一個 [-5, 5] 的區間
- position 初始在 0 位置
- move() 函式負責移動 position
- position 是一個靜態全域性變數
- 為 RangeLimiterTest 類是為其設計的單元測試,有兩個測試用例:
- testMove_betweenRange() 中分別 move(1)、move(3)、move(-5)
- testMove_exceedRange() 中 move(6)
- 問題:
- 測試用例 testMove_betweenRange() 執行之後,position 的值變成了 -1
- 測試用例 testMove_exceedRange() 執行之後,position 的值變成了 5,move() 函式返回 true,assertFalse 語句判定失敗
- 所以第二個測試用例執行失敗
- 如果 RangeLimiter 類有暴露重設(reset)position 值的函式,我們可以在每次執行單元測試用例之前,把 position 重設為 0,這樣就能解決剛剛的問題。
- 不過,每個單元測試框架執行單元測試用例的方式可能是不同的。有的是順序執行,有的是併發執行。對於併發執行的情況,即便我們每次都把 position 重設為 0,也並不奏效。
public class RangeLimiter {
private static AtomicInteger position = new AtomicInteger(0);
public static final int MAX_LIMIT = 5;
public static final int MIN_LIMIT = -5;
public boolean move(int delta) {
int currentPos = position.addAndGet(delta);
boolean betweenRange = (currentPos <= MAX_LIMIT) && (currentPos >= MIN_LIMIT);
return betweenRange;
}
}
public class RangeLimiterTest {
public void testMove_betweenRange() {
RangeLimiter rangeLimiter = new RangeLimiter();
assertTrue(rangeLimiter.move(1));
assertTrue(rangeLimiter.move(3));
assertTrue(rangeLimiter.move(-5));
}
public void testMove_exceedRange() {
RangeLimiter rangeLimiter = new RangeLimiter();
assertFalse(rangeLimiter.move(6));
}
}
靜態方法
- 靜態方法跟全域性變數一樣,也是一種面向過程的程式設計思維。
- 在程式碼中呼叫靜態方法,有時候會導致程式碼不易測試。主要原因是靜態方法也很難 mock。
- 只有在這個靜態方法執行耗時太長、依賴外部資源、邏輯複雜、行為未決等情況下,我們才需要在單元測試中 mock 這個靜態方法。
複雜繼承
- 相比組合關係,繼承關係的程式碼結構更加耦合、不靈活,更加不易擴充套件、不易維護。實際上,繼承關係也更加難測試。
- 如果父類需要 mock 某個依賴物件才能進行單元測試,那所有的子類、子類的子類……在編寫單元測試的時候,都要 mock 這個依賴物件。
- 對於層次很深(在繼承關係類圖中表現為縱向深度)、結構複雜(在繼承關係類圖中表現為橫向廣度)的繼承關係,越底層的子類要 mock 的物件可能就會越多。
- 如果我們利用組合而非繼承來組織類之間的關係,類之間的結構層次比較扁平,在編寫單元測試的時候,只需要 mock 類所組合依賴的物件即可。
高耦合程式碼
- 如果一個類職責很重,需要依賴十幾個外部物件才能完成工作,程式碼高度耦合,那我們在編寫單元測試的時候,可能需要 mock 這十幾個依賴的物件。