重構(gòu)是很有價值的工具月洛,但只有重構(gòu)還不行。要正確地進(jìn)行重構(gòu)捌议,前提是得有一套穩(wěn)固的測試集合哼拔,以幫我發(fā)現(xiàn)難以避免的疏漏。編寫優(yōu)良的測試程序瓣颅,可以極大提高我的編程速度倦逐,也許這會違反許多程序員的直覺。
1 自測試代碼的價值
如果你認(rèn)真觀察自己如何分配時間宫补,就會發(fā)現(xiàn)檬姥,編寫代碼的時間僅占有所有時間很少的一部分,有些時間花在設(shè)計(jì)上粉怕,有些時間用來決定下一步干什么健民,但是花費(fèi)在調(diào)試的時間是最多的。修復(fù)bug通常是比較快的贫贝,但找出bug所在卻是一場噩夢秉犹。
我需要做的就是把我所期望的輸出放到測試代碼中脸侥,然后做一個對比就行了讨越。由于我頻繁地運(yùn)行測試,每次測試都在不久之前爵憎,因此我知道bug地源頭就是我剛剛寫下的代碼客燕。因?yàn)榇a量很少鸳劳,我對它也記憶尤新,所以就能輕松找出bug也搓。
注意到這一點(diǎn)后赏廓,我對測試的積極性提高了涵紊。我不再等待每次迭代結(jié)尾時再增加測試,而是只要寫好一個功能點(diǎn)幔摸,就立即添加她們栖袋。
說服別人也這么做并不容易,編寫測試程序抚太,意味著要寫很多額外的代碼。除非你確實(shí)體會到這種方法是如何提升編程速度的昔案,否則自測試似乎就沒什么意義尿贫。
事實(shí)上,撰寫測試代碼的最好時機(jī)是在開始動手編碼之前踏揣。編寫測試代碼其實(shí)就是在問自己:為了添加這個功能庆亡,我需要實(shí)現(xiàn)些什么,編寫測試代碼還能幫我把注意力集中于接口而非實(shí)現(xiàn)捞稿。
先編寫一個測試又谋,編寫代碼使測試通過,然后進(jìn)行重構(gòu)保證代碼整潔娱局。這個“測試彰亥、編碼、重構(gòu)”的循環(huán)應(yīng)該在每個小時內(nèi)都完成很多次衰齐。這種良好的節(jié)奏感可使編程工作更加高效任斋、有條不紊的方式開展。
有時我需要重構(gòu)一些沒有測試的代碼耻涛,在重構(gòu)之前废酷,我得先改造這些代碼,使其能夠自測試才行抹缕。
2 如何編寫單元測試
寫單元測試就是針對代碼設(shè)計(jì)各種測試用例澈蟆,以覆蓋各種輸入、異常卓研、邊界情況趴俘,并將其翻譯成代碼。我們可以利用一些測試框架來簡化單元測試的編寫鉴分。除此之外哮幢,對于單元測試,我們需要建立以下正確的認(rèn)知:
- 編寫單元測試盡管繁瑣志珍,但并不是太耗時橙垢;
- 我們可以稍微放低對單元測試代碼質(zhì)量的要求;
- 覆蓋率作為衡量單元測試質(zhì)量的唯一標(biāo)準(zhǔn)是不合理的伦糯,實(shí)際上柜某,更重要的是要看測試用例是否覆蓋了所有可能的情況嗽元,特別是一些 corner case;
- 單元測試不要依賴被測代碼的具體實(shí)現(xiàn)邏輯喂击,只需要關(guān)注代碼實(shí)現(xiàn)的功能即可剂癌;
- 單元測試框架無法測試,多半是因?yàn)榇a的可測試性不好翰绊。
3 單元測試為何難以落地
很多人往往會覺得寫單元測試比較繁瑣佩谷,并且沒有太多挑戰(zhàn),而不愿意去做监嗜。有很多團(tuán)隊(duì)和項(xiàng)目在剛開始推行單元測試的時候谐檀,還比較認(rèn)真,執(zhí)行得比較好裁奇。但當(dāng)開發(fā)任務(wù)緊了之后桐猬,就開始放低對單元測試的要求,一旦出現(xiàn)破窗效應(yīng)刽肠,慢慢的溃肪,大家就都不寫了,這種情況很常見音五。
由于歷史遺留問題惫撰,原來的代碼都沒有寫單元測試,代碼已經(jīng)堆砌了十幾萬行了躺涝,不可能再一個一個去補(bǔ)單元測試润绎。這種情況下,我們首先要保證新寫的代碼都要有單元測試诞挨,其次莉撇,每次在改動到某個類時,如果沒有單元測試就順便補(bǔ)上惶傻,不過這要求工程師們有足夠強(qiáng)的主人翁意識(ownership)棍郎,畢竟光靠 leader 督促,很多事情是很難執(zhí)行到位的银室。
寫好代碼直接提交涂佃,然后丟給黑盒測試狠命去測,測出問題就反饋給開發(fā)團(tuán)隊(duì)再修改蜈敢,測不出的問題就留在線上出了問題再修復(fù)辜荠。在這樣的開發(fā)模式下,團(tuán)隊(duì)往往覺得沒有必要寫單元測試抓狭,但如果我們把單元測試寫好伯病、做好 Code Review,重視起代碼質(zhì)量否过,其實(shí)可以很大程度上減少黑盒測試的投入午笛。
4 案例實(shí)戰(zhàn)
實(shí)戰(zhàn)內(nèi)容來自《極客時間》專欄《設(shè)計(jì)模式之美》惭蟋,有興趣的可以看下原文。
Transaction 是經(jīng)過我抽象簡化之后的一個電商系統(tǒng)的交易類药磺,用來記錄每筆訂單交易的情況告组。Transaction 類中的 execute() 函數(shù)負(fù)責(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);
}
}
}
}
在 Transaction 類中卿叽,主要邏輯集中在 execute() 函數(shù)中桥胞,所以它是我們測試的重點(diǎn)對象。為了盡可能全面覆蓋各種正常和異常情況考婴,針對這個函數(shù)贩虾,我設(shè)計(jì)了下面 6 個測試用例。
- 正常情況下沥阱,交易執(zhí)行成功缎罢,回填用于對賬(交易與錢包的交易流水)用的 walletTransactionId,交易狀態(tài)設(shè)置為 EXECUTED考杉,函數(shù)返回 true策精。
- buyerId、sellerId 為 null崇棠、amount 小于 0咽袜,返回 InvalidTransactionException。
- 交易已過期(createTimestamp 超過 14 天)枕稀,交易狀態(tài)設(shè)置為 EXPIRED询刹,返回 false。
- 交易已經(jīng)執(zhí)行了(status==EXECUTED)萎坷,不再重復(fù)執(zhí)行轉(zhuǎn)錢邏輯凹联,返回 true。
- 錢包(WalletRpcService)轉(zhuǎn)錢失敗哆档,交易狀態(tài)設(shè)置為 FAILED蔽挠,函數(shù)返回 false。
- 交易正在執(zhí)行著瓜浸,不會被重復(fù)執(zhí)行象泵,函數(shù)直接返回 false寞秃。
對于上面的測試用例,第 2 個實(shí)現(xiàn)起來非常簡單偶惠,我就不做介紹了春寿。我們重點(diǎn)來看其中的 1 和 3。
現(xiàn)在忽孽,我們就來看測試用例 1 的代碼實(shí)現(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);
}
execute() 函數(shù)的執(zhí)行依賴兩個外部的服務(wù),一個是 RedisDistributedLock兄一,一個 WalletRpcService厘线。這就導(dǎo)致上面的單元測試代碼存在下面幾個問題。
- 如果要讓這個單元測試能夠運(yùn)行出革,我們需要搭建 Redis 服務(wù)和 Wallet RPC 服務(wù)造壮。搭建和維護(hù)的成本比較高。
- 我們還需要保證將偽造的 transaction 數(shù)據(jù)發(fā)送給 Wallet RPC 服務(wù)之后骂束,能夠正確返回我們期望的結(jié)果耳璧,然而 Wallet RPC 服務(wù)有可能是第三方(另一個團(tuán)隊(duì)開發(fā)維護(hù)的)的服務(wù),并不是我們可控的展箱。換句話說旨枯,并不是我們想讓它返回什么數(shù)據(jù)就返回什么。
- Transaction 的執(zhí)行跟 Redis混驰、RPC 服務(wù)通信攀隔,需要走網(wǎng)絡(luò),耗時可能會比較長栖榨,對單元測試本身的執(zhí)行性能也會有影響昆汹。
- 網(wǎng)絡(luò)的中斷、超時婴栽、Redis筹煮、RPC 服務(wù)的不可用,都會影響單元測試的執(zhí)行居夹。
如果代碼中依賴了外部系統(tǒng)或者不可控組件败潦,比如,需要依賴數(shù)據(jù)庫准脂、網(wǎng)絡(luò)通信劫扒、文件系統(tǒng)等,那我們就需要將被測代碼與外部系統(tǒng)解依賴狸膏,而這種解依賴的方法就叫作“mock”沟饥。所謂的 mock 就是用一個“假”的服務(wù)替換真正的服務(wù)。mock 的服務(wù)完全在我們的控制之下,模擬輸出我們想要的數(shù)據(jù)贤旷。
我們通過繼承 WalletRpcService 類广料,并且重寫其中的 moveMoney() 函數(shù)的方式來實(shí)現(xiàn) mock。具體的代碼實(shí)現(xiàn)如下所示幼驶。通過 mock 的方式艾杏,我們可以讓 moveMoney() 返回任意我們想要的數(shù)據(jù),完全在我們的控制范圍內(nèi)盅藻,并且不需要真正進(jìn)行網(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;
}
}
因?yàn)?WalletRpcService 是在 execute() 函數(shù)中通過 new 的方式創(chuàng)建的,我們無法動態(tài)地對其進(jìn)行替換氏淑。也就是說勃蜘,Transaction 類中的 execute() 方法的可測試性很差,需要通過重構(gòu)來讓其變得更容易測試假残。該如何重構(gòu)這段代碼呢缭贡?
我們可以應(yīng)用依賴注入,將 WalletRpcService 對象的創(chuàng)建反轉(zhuǎn)給上層邏輯辉懒,在外部創(chuàng)建好之后阳惹,再注入到 Transaction 類中。重構(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。RedisDistributedLock 是一個單例類攒巍。單例相當(dāng)于一個全局變量嗽仪,我們無法 mock(無法繼承和重寫方法),也無法通過依賴注入的方式來替換柒莉。
如果 RedisDistributedLock 是我們自己維護(hù)的闻坚,可以自由修改、重構(gòu)兢孝,那我們可以將其改為非單例的模式窿凤,或者定義一個接口,比如 IDistributedLock跨蟹,讓 RedisDistributedLock 實(shí)現(xiàn)這個接口雳殊。這樣我們就可以像前面 WalletRpcService 的替換方式那樣,替換 RedisDistributedLock 為 MockRedisDistributedLock 了窗轩。但如果 RedisDistributedLock 不是我們維護(hù)的夯秃,我們無權(quán)去修改這部分代碼,這個時候該怎么辦呢?
我們可以對 transaction 上鎖這部分邏輯重新封裝一下仓洼。具體代碼實(shí)現(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();
}
}
//...
}
}
針對重構(gòu)過的代碼介陶,我們的單元測試代碼修改為下面這個樣子。這樣色建,我們就能在單元測試代碼中隔離真正的 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());
}
至此,測試用例 1 就算寫好了镀岛。我們通過依賴注入和 mock弦牡,讓單元測試代碼不依賴任何不可控的外部服務(wù)。你可以照著這個思路漂羊,自己寫一下測試用例 4驾锰、5、6走越。
現(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());
}
那如果沒有針對 createTimestamp 的 set 方法,那測試用例 3 又該如何實(shí)現(xiàn)呢搬素?實(shí)際上呵晨,這是一類比較常見的問題,就是代碼中包含跟“時間”有關(guān)的“未決行為”邏輯熬尺。我們一般的處理方式是將這種未決行為邏輯重新封裝摸屠。針對 Transaction 類,我們只需要將交易是否過期的邏輯粱哼,封裝到 isExpired() 函數(shù)中即可季二,具體的代碼實(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 的代碼實(shí)現(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());
}
實(shí)際上揭措,可測試性差的代碼胯舷,本身代碼設(shè)計(jì)得也不夠好,很多地方都沒有遵守我們之前講到的設(shè)計(jì)原則和思想绊含,比如“基于接口而非實(shí)現(xiàn)編程”思想需纳、依賴反轉(zhuǎn)原則等。重構(gòu)之后的代碼艺挪,不僅可測試性更好不翩,而且從代碼設(shè)計(jì)的角度來說兵扬,也遵從了經(jīng)典的設(shè)計(jì)原則和思想。這也印證了我們之前說過的口蝠,代碼的可測試性可以從側(cè)面上反應(yīng)代碼設(shè)計(jì)是否合理器钟。除此之外,在平時的開發(fā)中妙蔗,我們也要多思考一下傲霸,這樣編寫代碼,是否容易編寫單元測試眉反,這也有利于我們設(shè)計(jì)出好的代碼昙啄。
常見的測試不友好的代碼有下面這 5 種:
- 代碼中包含未決行為邏輯。所謂的未決行為邏輯就是寸五,代碼的輸出是隨機(jī)或者說不確定的梳凛,比如,跟時間梳杏、隨機(jī)數(shù)有關(guān)的代碼韧拒。
- 濫用可變?nèi)肿兞俊H肿兞渴且环N面向過程的編程風(fēng)格十性,有種種弊端叛溢。實(shí)際上,濫用全局變量也讓編寫單元測試變得困難劲适。
- 濫用靜態(tài)方法楷掉。靜態(tài)方法跟全局變量一樣,也是一種面向過程的編程思維霞势。在代碼中調(diào)用靜態(tài)方法烹植,有時候會導(dǎo)致代碼不易測試。主要原因是靜態(tài)方法也很難 mock支示。
- 使用復(fù)雜的繼承關(guān)系刊橘。如果父類需要 mock 某個依賴對象才能進(jìn)行單元測試鄙才,那所有的子類颂鸿、子類的子類……在編寫單元測試的時候,都要 mock 這個依賴對象攒庵。
- 高度耦合的代碼嘴纺。如果一個類職責(zé)很重,需要依賴十幾個外部對象才能完成工作浓冒,代碼高度耦合栽渴,那我們在編寫單元測試的時候,可能需要 mock 這十幾個依賴的對象稳懒。