最可落地執(zhí)行、最有效的保證重構(gòu)不出錯的手段應(yīng)該就是單元測試(Unit Testing)。
什么是單元測試?
單元測試由研發(fā)工程師自己來編寫,用來測試自己寫的代碼的正確性。我們常常將它跟集成測試放到一塊來對比。單元測試相對于集成測試(Integration Testing)來說,測試的粒度更小一些。集成測試的測試對象是整個系統(tǒng)或者某個功能模塊,比如測試用戶注冊、登錄功能是否正常,是一種端到端(end to end)的測試。而單元測試的測試對象是類或者函數(shù),用來測試一個類和函數(shù)是否都按照預(yù)期的邏輯執(zhí)行。這是代碼層級的測試。
單元測試除了能有效地為重構(gòu)保駕護航之外,也是保證代碼質(zhì)量最有效的兩個手段之一(另一個是 Code Review)。
寫單元測試就是針對代碼設(shè)計覆蓋各種輸入、異常、邊界條件的測試用例,并將這些測試用例翻譯成代碼的過程。
在把測試用例翻譯成代碼的時候,我們可以利用單元測試框架,來簡化測試代碼的編寫。比如,Java 中比較出名的單元測試框架有 Junit、TestNG、Spring Test 等。這些框架提供了通用的執(zhí)行流程(比如執(zhí)行測試用例的 TestCaseRunner)和工具類庫(比如各種 Assert 判斷函數(shù))等。借助它們,我們在編寫測試代碼的時候,只需要關(guān)注測試用例本身的編寫即可。
編寫可測試代碼案例實戰(zhàn)
其中,Transaction 是經(jīng)過我抽象簡化之后的一個電商系統(tǒng)的交易類,用來記錄每筆訂單交易的情況。Transaction 類中的 execute() 函數(shù)負責(zé)執(zhí)行轉(zhuǎn)賬操作,將錢從買家的錢包轉(zhuǎn)到賣家的錢包中。真正的轉(zhuǎn)賬操作是通過調(diào)用 WalletRpcService RPC 服務(wù)來完成的。除此之外,代碼中還涉及一個分布式鎖 DistributedLock 單例類,用來避免 Transaction 并發(fā)執(zhí)行,導(dǎo)致用戶的錢被重復(fù)轉(zhuǎn)出。
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(id);
if (!isLocked) {
return false; // 鎖定未成功,返回false,job兜底執(zhí)行
}
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, sellerId, amount);
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);
}
}
}
}
測試用例 1 的代碼實現(xiàn):
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
}
我們通過繼承 WalletRpcService 類,并且重寫其中的 moveMoney() 函數(shù)的方式來實現(xiàn) mock。具體的代碼實現(xiàn)如下所示。通過 mock 的方式,我們可以讓 moveMoney() 返回任意我們想要的數(shù)據(jù),完全在我們的控制范圍內(nèi),并且不需要真正進行網(wǎng)絡(luò)通信。
public class MockWalletRpcServiceOne extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return "123bac";
}
}
public class MockWalletRpcServiceTwo extends WalletRpcService {
public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
return null;
}
}
現(xiàn)在我們再來看,如何用 MockWalletRpcServiceOne、MockWalletRpcServiceTwo 來替換代碼中的真正的 WalletRpcService 呢?因為 WalletRpcService 是在 execute() 函數(shù)中通過 new 的方式創(chuàng)建的,我們無法動態(tài)地對其進行替換。也就是說,Transaction 類中的 execute() 方法的可測試性很差,需要通過重構(gòu)來讓其變得更容易測試。
該如何重構(gòu)這段代碼呢?在依賴反轉(zhuǎn)中,我們講到,依賴注入是實現(xiàn)代碼可測試性的最有效的手段。我們可以應(yīng)用依賴注入,將 WalletRpcService 對象的創(chuàng)建反轉(zhuǎn)給上層邏輯,在外部創(chuàng)建好之后,再注入到 Transaction 類中。
依賴注入是實現(xiàn)代碼可測試性的最有效的手段:通過DI實現(xiàn)反轉(zhuǎn),將對象的創(chuàng)建交給業(yè)務(wù)調(diào)用方,這樣就可以隨意控制輸出的結(jié)果,從而達到"mock"數(shù)據(jù)的目的,這樣的思路太贊了。。。(補充下:不存在外部依賴的類對象可以直接通過new來創(chuàng)建)
重構(gòu)之后的 Transaction 類的代碼如下所示:
public class Transaction {
//...
// 添加一個成員變量及其set方法
private WalletRpcService walletRpcService;
public void setWalletRpcService(WalletRpcService walletRpcService) {
this.walletRpcService = walletRpcService;
}
// ...
public boolean execute() {
// ...
// 刪除下面這一行代碼
// WalletRpcService walletRpcService = new WalletRpcService();
// ...
}
}
現(xiàn)在,我們就可以在單元測試中,非常容易地將 WalletRpcService 替換成 MockWalletRpcServiceOne 或 WalletRpcServiceTwo 了。重構(gòu)之后的代碼對應(yīng)的單元測試如下所示:
public void testExecute() {
Long buyerId = 123L;
Long sellerId = 234L;
Long productId = 345L;
Long orderId = 456L;
Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
// 使用mock對象來替代真正的RPC服務(wù)
transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}
WalletRpcService 的 mock 和替換問題解決了,我們再來看 RedisDistributedLock。它的 mock 和替換要復(fù)雜一些,主要是因為 RedisDistributedLock 是一個單例類。單例相當于一個全局變量,我們無法 mock(無法繼承和重寫方法),也無法通過依賴注入的方式來替換。
如果 RedisDistributedLock 是我們自己維護的,可以自由修改、重構(gòu),那我們可以將其改為非單例的模式,或者定義一個接口,比如 IDistributedLock,讓 RedisDistributedLock 實現(xiàn)這個接口。這樣我們就可以像前面 WalletRpcService 的替換方式那樣,替換 RedisDistributedLock 為 MockRedisDistributedLock 了。但如果 RedisDistributedLock 不是我們維護的,我們無權(quán)去修改這部分代碼,這個時候該怎么辦呢?
我們可以對 transaction 上鎖這部分邏輯重新封裝一下。具體代碼實現(xiàn)如下所示:
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, productId, orderId);
transaction.setWalletRpcService(new MockWalletRpcServiceOne());
transaction.setTransactionLock(mockLock);
boolean executedResult = transaction.execute();
assertTrue(executedResult);
assertEquals(STATUS.EXECUTED, transaction.getStatus());
}
現(xiàn)在,我們再來看測試用例 3:交易已過期(createTimestamp 超過 14 天),交易狀態(tài)設(shè)置為 EXPIRED,返回 false。
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 的創(chuàng)建時間 createdTimestamp 設(shè)置為 14 天前,也就是說,當單元測試代碼運行的時候,transaction 一定是處于過期狀態(tài)。但是,如果在 Transaction 類中,并沒有暴露修改 createdTimestamp 成員變量的 set 方法(也就是沒有定義 setCreatedTimestamp() 函數(shù))呢?
你可能會說,如果沒有 createTimestamp 的 set 方法,我就重新添加一個唄!實際上,這違反了類的封裝特性。在 Transaction 類的設(shè)計中,createTimestamp 是在交易生成時(也就是構(gòu)造函數(shù)中)自動獲取的系統(tǒng)時間,本來就不應(yīng)該人為地輕易修改,所以,暴露 createTimestamp 的 set 方法,雖然帶來了靈活性,但也帶來了不可控性。因為,我們無法控制使用者是否會調(diào)用 set 方法重設(shè)createTimestamp,而重設(shè) createTimestamp 并非我們的預(yù)期行為。
那如果沒有針對 createTimestamp 的 set 方法,那測試用例 3 又該如何實現(xiàn)呢?實際上,這是一類比較常見的問題,就是代碼中包含跟“時間”有關(guān)的“未決行為”邏輯。我們一般的處理方式是將這種未決行為邏輯重新封裝。針對 Transaction 類,我們只需要將交易是否過期的邏輯,封裝到 isExpired() 函數(shù)中即可,具體的代碼實現(xiàn)如下所示:
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;
}
//...
}
}
針對重構(gòu)之后的代碼,測試用例 3 的代碼實現(xiàn)如下所示:
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());
}
通過重構(gòu),Transaction 代碼的可測試性提高了。之前羅列的所有測試用例,現(xiàn)在我們都順利實現(xiàn)了。不過,Transaction 類的構(gòu)造函數(shù)的設(shè)計還有點不妥。
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();
}
我們發(fā)現(xiàn),構(gòu)造函數(shù)中并非只包含簡單賦值操作。交易 id 的賦值邏輯稍微復(fù)雜。我們最好也要測試一下,以保證這部分邏輯的正確性。為了方便測試,我們可以把 id 賦值這部分邏輯單獨抽象到一個函數(shù)中,具體的代碼實現(xiàn)如下所示:
public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
//...
fillTransactionId(preAssignId);
//...
}
protected void fillTransactionId(String preAssignedId) {
if (preAssignedId != null && !preAssignedId.isEmpty()) {
this.id = preAssignedId;
} else {
this.id = IdGenerator.generateTransactionId();
}
if (!this.id.startWith("t_")) {
this.id = "t_" + preAssignedId;
}
}
Transaction 類中 isExpired() 函數(shù)就不用測試了嗎?對于 isExpired() 函數(shù),邏輯非常簡單,肉眼就能判定是否有 bug,是可以不用寫單元測試的。
其他常見的 Anti-Patterns
上面通過一個實戰(zhàn)案例,講解了如何利用依賴注入來提高代碼的可測試性,以及編寫單元測試中最復(fù)雜的一部分內(nèi)容:如何通過 mock、二次封裝等方式解依賴外部服務(wù)?,F(xiàn)在,我們再來總結(jié)一下,有哪些典型的、常見的測試性不好的代碼,也就是我們常說的 Anti-Patterns。
1. 未決行為
代碼的輸出是隨機或者說不確定的,比如,跟時間、隨機數(shù)有關(guān)的代碼。
2. 全局變量
全局變量是一種面向過程的編程風(fēng)格,有種種弊端。實際上,濫用全局變量也讓編寫單元測試變得困難。
RangeLimiter 表示一個[-5, 5]的區(qū)間,position 初始在 0 位置,move() 函數(shù)負責(zé)移動 position。其中,position 是一個靜態(tài)全局變量。RangeLimiterTest 類是為其設(shè)計的單元測試。
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));
}
}
上面的單元測試有可能會運行失敗。假設(shè)單元測試框架順序依次執(zhí)行 testMove_betweenRange() 和 testMove_exceedRange() 兩個測試用例。在第一個測試用例執(zhí)行完成之后,position 的值變成了 -1;再執(zhí)行第二個測試用例的時候,position 變成了 5,move() 函數(shù)返回 true,assertFalse 語句判定失敗。所以,第二個測試用例運行失敗。
當然,如果 RangeLimiter 類有暴露重設(shè)(reset)position 值的函數(shù),我們可以在每次執(zhí)行單元測試用例之前,把 position 重設(shè)為 0,這樣就能解決剛剛的問題。文章來源:http://www.zghlxwxcb.cn/news/detail-530754.html
不過,每個單元測試框架執(zhí)行單元測試用例的方式可能是不同的。有的是順序執(zhí)行,有的是并發(fā)執(zhí)行。對于并發(fā)執(zhí)行的情況,即便我們每次都把 position 重設(shè)為 0,也并不奏效。如果兩個測試用例并發(fā)執(zhí)行,第 16、17、18、23 這四行代碼可能會交叉執(zhí)行,影響到 move() 函數(shù)的執(zhí)行結(jié)果。文章來源地址http://www.zghlxwxcb.cn/news/detail-530754.html
到了這里,關(guān)于設(shè)計模式之美——單元測試和代碼可測性的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!