新聞中心
作者 | 杜沁園(懸衡)

重構(gòu)代碼時,我們常常糾結(jié)于這樣的問題:
- 需要進(jìn)一步抽象嗎?會不會導(dǎo)致過度設(shè)計?
- 如果需要進(jìn)一步抽象的話,如何進(jìn)行抽象呢?有什么通用的步驟或者法則嗎?
單元測試是我們常用的驗(yàn)證代碼正確性的工具,但是如果只用來驗(yàn)證正確性的話,那就是真是 “大炮打蚊子”--大材小用,它還可以幫助我們評判代碼的抽象程度與設(shè)計水平。本文還會提出一個以“可測試性”為目標(biāo),不斷迭代重構(gòu)代碼的思路,利用這個思路,面對任何復(fù)雜的代碼,都能逐步推導(dǎo)出重構(gòu)思路。為了保證直觀,本文會以一個 “生產(chǎn)者消費(fèi)者” 的代碼重構(gòu)示例貫穿始終。最后還會以業(yè)務(wù)上常見的 Excel 導(dǎo)出系統(tǒng)為例簡單闡述一個業(yè)務(wù)上的重構(gòu)實(shí)例。閱讀本文需要具有基本的單元測試編寫經(jīng)驗(yàn)(最好是 Java),但是本文不會涉及任何具體的單元測試框架和技術(shù),因?yàn)樗鼈兌际遣恢匾?,學(xué)習(xí)了本文的思路,可以將它們用在任意的單測工具上。
不可測試的代碼
程序員們重構(gòu)一段代碼的動機(jī)是什么?可能眾說紛紜:
- 代碼不夠簡潔?
- 不好維護(hù)?
- 不符合個人習(xí)慣?
- 過度設(shè)計,不好理解?
這些都是比較主觀的因素,在一個老練程序員看來恰到好處的設(shè)計,一個新手程序員卻可能會覺得過于復(fù)雜,不好理解。但是讓他們同時坐下來為這段代碼添加單元測試時,他們往往能夠產(chǎn)生類似的感受,比如
- “單測很容易書寫,很容易就全覆蓋了”,那么這就是可測試的代碼;
- “雖然能寫得出來,但是費(fèi)了老大勁,使用了各種框架和技巧,才覆蓋完全”,那么這就是可測試性比較差的代碼;
- “完全不知道如何下手寫”,那么這就是不可測試的代碼;
一般而言,可測試的代碼一般都是同時是簡潔和可維護(hù)的,但是簡潔可維護(hù)的代碼卻不一定是可測試的,比如下面的“生產(chǎn)者消費(fèi)者”代碼就是不可測試的:
public void producerConsumer() {
BlockingQueue blockingQueue = new LinkedBlockingQueue<>();
Thread producerThread = new Thread(() -> {
for (int i = 0; i < 10; i++) {
blockingQueue.add(i + ThreadLocalRandom.current().nextInt(100));
}
});
Thread consumerThread = new Thread(() -> {
try {
while (true) {
Integer result = blockingQueue.take();
System.out.println(result);
}
} catch (InterruptedException ignore) {
}
});
producerThread.start();
consumerThread.start();
} 上面這段代碼做的事情非常簡單,啟動兩個線程:
- 生產(chǎn)者:將 0-9 的每個數(shù)字,分別加上 [0,100) 的隨機(jī)數(shù)后通過阻塞隊(duì)列傳遞給消費(fèi)者;
- 消費(fèi)者:從阻塞隊(duì)列中獲取數(shù)字并打??;
這段代碼看上去還是挺簡潔的,但是,算得上一段好代碼嗎?嘗試下給這段代碼加上單元測試。僅僅運(yùn)行一下這個代碼肯定是不夠的,因?yàn)槲覀儫o法確認(rèn)生產(chǎn)消費(fèi)邏輯是否正確執(zhí)行。我也只能發(fā)出“完全不知道如何下手”的感嘆,這不是因?yàn)槲覀兊膯卧獪y試編寫技巧不夠,而是因?yàn)榇a本身存在的問題:1、違背單一職責(zé)原則:這一個函數(shù)同時做了 數(shù)據(jù)傳遞,處理數(shù)據(jù),啟動線程三件事情。單元測試要兼顧這三個功能,就會很難寫。2、這個代碼本身是不可重復(fù)的,不利于單元測試,不可重復(fù)體現(xiàn)在
需要測試的邏輯位于異步線程中,對于它什么時候執(zhí)行?什么時候執(zhí)行完?都是不可控的;
邏輯中含有隨機(jī)數(shù);
消費(fèi)者直接將數(shù)據(jù)輸出到標(biāo)準(zhǔn)輸出中,在不同環(huán)境中無法確定這里的行為是什么,有可能是輸出到了屏幕上,也可能是被重定向到了文件中;
因?yàn)榈?2 點(diǎn)的原因,我們就不得不放棄單測了呢?其實(shí)只要通過合理的模塊職責(zé)劃分,依舊是可以單元測試。這種劃分不僅僅有助于單元測試,也會“順便”幫助我們抽象一套更加合理的代碼。
可測試意味著什么?
所有不可測試的代碼都可以通過合理的重構(gòu)與抽象,讓其核心邏輯變得可測試,這也重構(gòu)的意義所在。本章就會詳細(xì)說明這一點(diǎn)。
首先我們要了解可測試意味著什么,如果說一段代碼是可測試的,那么它一定符合下面的條件:
- 可以在本地設(shè)計完備的測試用例,稱之為 完全覆蓋的單元測試;
- 只要完全覆蓋的單元測試用例全部正確運(yùn)行,那么這一段邏輯肯定是沒有問題的;
第 1 點(diǎn)常會令人感到難以置信,但事實(shí)比想象的簡單,假設(shè)有這樣一個分段函數(shù):
f(x) 看起來有無限的定義域,我們永遠(yuǎn)無法窮舉所有可能的輸入。但是再仔細(xì)想想,我們并不需要窮舉,其實(shí)只要下面幾個用例可以通過,那么就可以確保這個函數(shù)是沒有問題的:
- <-50
f(-51) == -100
- [-50, 50]
f(-25) == -50
f(25) == 50
- >50
f(51) == 100
- 邊界情況
f(-50) == -100
f(50) == 100
日常工作中的代碼當(dāng)然比這個復(fù)雜很多,但是沒有本質(zhì)區(qū)別,也是按照如下思路進(jìn)行單元測試覆蓋的:
- 每一個分段其實(shí)就是代碼中的一個條件分支,用例的分支覆蓋率達(dá)到了 100%;
- 像 2x 這樣的邏輯運(yùn)算,通過幾個合適的采樣點(diǎn)就可以保證正確性;
- 邊界條件的覆蓋,就像是分段函數(shù)的轉(zhuǎn)折點(diǎn);
但是業(yè)務(wù)代碼依舊比 f(x) 要復(fù)雜很多,因?yàn)?nbsp;f(x) 還有其他好的性質(zhì)讓它可以被完全測試,這個性質(zhì)被稱作引用透明:
- 函數(shù)的返回值只和參數(shù)有關(guān),只要參數(shù)確定,返回值就是唯一確定的
現(xiàn)實(shí)中的代碼大多都不會有這么好的性質(zhì),反而具有很多“壞的性質(zhì)”,這些壞的性質(zhì)也常被稱為副作用:
- 代碼中含有遠(yuǎn)程調(diào)用,無法確定這次調(diào)用是否會成功;
- 含有隨機(jī)數(shù)生成邏輯,導(dǎo)致行為不確定;
- 執(zhí)行結(jié)果和當(dāng)前日期有關(guān),比如只有工作日的早上,鬧鐘才會響起;
- 好在我們可以用一些技巧將這些副作用從核心邏輯中抽離出來。
高階函數(shù)
“引用透明” 要求函數(shù)的出參由入?yún)⑽ㄒ淮_定,之前的例子容易讓人產(chǎn)生誤解,覺得出參和入?yún)⒁欢ㄒ菙?shù)據(jù),讓我們把視野再打開一點(diǎn),出入?yún)⒖梢允且粋€函數(shù),它也可以是引用透明的。
普通的函數(shù)又可以稱作一階函數(shù),而接收函數(shù)作為參數(shù),或者返回一個函數(shù)的函數(shù)稱為高階函數(shù),高階函數(shù)也可以是引用透明的。
對于函數(shù) f(x) 來說,x 是數(shù)據(jù)還是函數(shù),并沒有本質(zhì)的不同,如果 x 是函數(shù)的話,僅僅意味著 f(x) 擁有更加廣闊的定義域,以至于沒有辦法像之前一樣只用一個一維數(shù)軸表示。
對于高階函數(shù) f(g) (g 是一個函數(shù))來說,只要對于特定的函數(shù) g,返回邏輯也是固定,它就是引用透明的了, 而不用在乎參數(shù) g 或者返回的函數(shù)是否有副作用。利用這個特性,我們很容易將一個有副作用的函數(shù)轉(zhuǎn)換為一個引用透明的高階函數(shù)。
一個典型的擁有副作用的函數(shù)如下:
public int f() {
return ThreadLocalRandom.current().nextInt(100) + 1;
}它生成了隨機(jī)數(shù)并且加 1,因?yàn)檫@個隨機(jī)數(shù),導(dǎo)致它不可測試。但是我們將它轉(zhuǎn)換為一個可測試的高階函數(shù),只要將隨機(jī)數(shù)生成邏輯作為一個參數(shù)傳入,并且返回一個函數(shù)即可:
public Supplierg(Supplier integerSupplier) {
return () -> integerSupplier.get() + 1;
}
上面的 g 就是一個引用透明的函數(shù),只要給 g 傳遞一個數(shù)字生成器,返回值一定是一個 “用數(shù)字生成器生成一個數(shù)字并且加1” 的邏輯,并且不存在分支條件和邊界情況,只需要一個用例即可覆蓋:
public void testG() {
Supplier result = g(() -> 1);
assert result.get() == 2;
} 實(shí)際業(yè)務(wù)中可以稍微簡化一下高階函數(shù)的表達(dá), g 的返回的函數(shù)既然每次都會被立即執(zhí)行,那我們就不返回函數(shù)了,直接將邏輯寫在方法中,這樣也是可測試的:
public int g2(SupplierintegerSupplier) {
return integerSupplier.get() + 1;
}
這里我雖然使用了 Lambda 表達(dá)式簡化代碼,但是 “函數(shù)” 并不僅僅是指 Lambda 表達(dá)式,OOP 中的充血模型的對象,接口等等,只要其中含有邏輯,它們的傳遞和返回都可以看作 “函數(shù)”。
因?yàn)檫@個例子比較簡單,“可測試” 帶來的收益看起來沒有那么高,真實(shí)業(yè)務(wù)中的邏輯一般比 +1 要復(fù)雜多了,此時如果能構(gòu)建有效的測試將是非常有益的。
面向單測的重構(gòu)
第一輪重構(gòu)
我們本章回到開頭的生產(chǎn)者消費(fèi)者的例子,用上一章學(xué)習(xí)到的知識對它進(jìn)行重構(gòu)。那段代碼無法測試的第一個問題就是職責(zé)不清晰,它既做數(shù)據(jù)傳遞,又做數(shù)據(jù)處理。因此我們考慮將生產(chǎn)者消費(fèi)者數(shù)據(jù)傳遞的代碼單獨(dú)抽取出來:
publicvoid producerConsumerInner(Consumer > producer,
Consumer> consumer) {
BlockingQueueblockingQueue = new LinkedBlockingQueue<>();
new Thread(() -> producer.accept(blockingQueue::add)).start();
new Thread(() -> consumer.accept(() -> {
try {
return blockingQueue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
})).start();
}
這一段代碼的職責(zé)就很清晰了,我們給這個方法編寫單元測試的目標(biāo)也十分明確,即驗(yàn)證數(shù)據(jù)能夠正確地從生產(chǎn)者傳遞到消費(fèi)者。但是很快我們又遇到了之前提到的第二個問題,即異步線程不可控,會導(dǎo)致單測執(zhí)行的不穩(wěn)定,用上一章的方法,我們將執(zhí)行器作為一個入?yún)⒊殡x出去:
publicvoid producerConsumerInner(Executor executor,
Consumer> producer,
Consumer> consumer) {
BlockingQueueblockingQueue = new LinkedBlockingQueue<>();
executor.execute(() -> producer.accept(blockingQueue::add));
executor.execute(() -> consumer.accept(() -> {
try {
return blockingQueue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}));
}
這時我們就為它寫一個穩(wěn)定的單元測試了:
private void testProducerConsumerInner() {
producerConsumerInner(Runnable::run,
(Consumer>) producer -> {
producer.accept(1);
producer.accept(2);
},
consumer -> {
assert consumer.get() == 1;
assert consumer.get() == 2;
});
} 只要這個測試能夠通過,就能說明生產(chǎn)消費(fèi)在邏輯上是沒有問題的。一個看起來比之前的分段函數(shù)復(fù)雜很多的邏輯,本質(zhì)上卻只是它定義域上的一個恒等函數(shù)(因?yàn)橹灰粋€用例就能覆蓋全部情況),是不是很驚訝。如果不太喜歡上述的函數(shù)式編程風(fēng)格,可以很容易地將其改造成 OOP 風(fēng)格的抽象類,就像上一章提到的,傳遞對象和傳遞函數(shù)沒有本質(zhì)的區(qū)別:
public abstract class ProducerConsumer{
private final Executor executor;
private final BlockingQueueblockingQueue;
public ProducerConsumer(Executor executor) {
this.executor = executor;
this.blockingQueue = new LinkedBlockingQueue<>();
}
public void start() {
executor.execute(this::produce);
executor.execute(this::consume);
}
abstract void produce();
abstract void consume();
protected void produceInner(T item) {
blockingQueue.add(item);
}
protected T consumeInner() {
try {
return blockingQueue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
此時單元測試就會像是這個樣子:
private void testProducerConsumerAbCls() {
new ProducerConsumer(Runnable::run) {
@Override
void produce() {
produceInner(1);
produceInner(2);
}
@Override
void consume() {
assert consumeInner() == 1;
assert consumeInner() == 2;
}
}.start();
} 看到這些類,熟悉設(shè)計模式的讀者們一定會想到 “模板方法模式”,但是我們在上面的過程從來沒有刻意去用任何設(shè)計模式,正確的重構(gòu)就會讓你在無意間 “重新發(fā)現(xiàn)” 這些常用的設(shè)計模式,一般這種情況下,設(shè)計模式的使用都是正確的,因?yàn)槲覀円恢痹诎汛a往更加可測試的方向推薦,而這也是衡量設(shè)計模式是否使用正確的重要標(biāo)準(zhǔn),錯誤的設(shè)計模式使用則會讓代碼更加的割裂和不可測試,后文討論“過度設(shè)計”這個主題時會進(jìn)一步深入討論這一部分內(nèi)容。
很顯然這種測試無法驗(yàn)證多線程運(yùn)行的情況,但我故意這么做的,這部分單元測試的主要目的是驗(yàn)證邏輯的正確性,只有先驗(yàn)證邏輯上的正確性,再去測試并發(fā)才比較有意義,在邏輯存在問題的情況下就去測試并發(fā),只會讓問題隱藏得更深,難以排查。一般開源項(xiàng)目中會有專門的單元測試去測試并發(fā),但是因?yàn)槠渚帉懘鷥r比較大,運(yùn)行時間比較長,數(shù)量會遠(yuǎn)少于邏輯測試。
經(jīng)過第一輪重構(gòu),主函數(shù)變成了這個樣子(這里我最終采用了 OOP 風(fēng)格):
public void producerConsumer() {
new ProducerConsumer(Executors.newFixedThreadPool(2)) {
@Override
void produce() {
for (int i = 0; i < 10; i++) {
produceInner(i + ThreadLocalRandom.current().nextInt(100));
}
}
@Override
void consume() {
while (true) {
Integer result = consumeInner();
System.out.println(result);
}
}
}.start();
} 在第一輪重構(gòu)中,我們僅僅保障了數(shù)據(jù)傳遞邏輯是正確的,在第二輪重構(gòu)中,我們還將進(jìn)一步擴(kuò)大可測試的范圍。
第二輪重構(gòu)
代碼中影響我們進(jìn)一步擴(kuò)大測試范圍因素還有兩個:
- 隨機(jī)數(shù)生成邏輯
- 打印邏輯
只要將這兩個邏輯像之前一樣抽出來即可:
public class NumberProducerConsumer extends ProducerConsumer{
private final SuppliernumberGenerator;
private final ConsumernumberConsumer;
public NumberProducerConsumer(Executor executor,
SuppliernumberGenerator,
ConsumernumberConsumer) {
super(executor);
this.numberGenerator = numberGenerator;
this.numberConsumer = numberConsumer;
}
@Override
void produce() {
for (int i = 0; i < 10; i++) {
produceInner(i + numberGenerator.get());
}
}
@Override
void consume() {
while (true) {
Integer result = consumeInner();
numberConsumer.accept(result);
}
}
}
這次采用 OOP 和 函數(shù)式 混編的風(fēng)格,也可以考慮將 numberGenerator 和 numberConsumer 兩個方法參數(shù)改成抽象方法,這樣就是更加純粹的 OOP。它也只需要一個測試用例即可實(shí)現(xiàn)完全覆蓋:
private void testProducerConsumerInner2() {
AtomicInteger expectI = new AtomicInteger();
producerConsumerInner2(Runnable::run, () -> 0, i -> {
assert i == expectI.getAndIncrement();
});
assert expectI.get() == 10;
}此時主函數(shù)變成:
public void producerConsumer() {
new NumberProducerConsumer(Executors.newFixedThreadPool(2),
() -> ThreadLocalRandom.current().nextInt(100),
System.out::println).start();
}經(jīng)過兩輪重構(gòu),我們將一個很隨意的面條代碼重構(gòu)成了很優(yōu)雅的結(jié)構(gòu),除了更加可測試外,代碼也更加簡潔抽象,可復(fù)用,這些都是面向單測重構(gòu)所帶來的附加好處。
你可能會注意到,即使經(jīng)過了兩輪重構(gòu),我們依舊不會直接對主函數(shù) producerConsumer 進(jìn)行測試,而只是無限接近覆蓋里面的全部邏輯,因?yàn)槲艺J(rèn)為它不在“測試的邊界”內(nèi),我更傾向于用集成測試去測試它,集成測試則不在本篇文章討論的范圍內(nèi)。下一章則重點(diǎn)討論測試邊界的問題。
單元測試的邊界
邊界內(nèi)的代碼都是單元測試可以有效覆蓋到的代碼,而邊界外的代碼則是沒有單元測試保障的。
上一章所描述的重構(gòu)過程本質(zhì)上就是一個在探索中不斷擴(kuò)大測試邊界的過程。但是單元測試的邊界是不可能無限擴(kuò)大的,因?yàn)閷?shí)際的工程中必然有大量的不可測試部分,比如 RPC 調(diào)用,發(fā)消息,根據(jù)當(dāng)前時間做計算等等,它們必然得在某個地方傳入測試邊界,而這一部分就是不可測試的。
理想的測試邊界應(yīng)該是這樣的,系統(tǒng)中所有核心復(fù)雜的邏輯全部包含在了邊界內(nèi)部,然后邊界外都是不包含邏輯的,非常簡單的代碼,比如就是一行接口調(diào)用。這樣任何對于系統(tǒng)的改動都可以在單元測試中就得到快速且充分的驗(yàn)證,集成測試時只需要簡單測試下即可,如果出現(xiàn)問題,一定是對外部接口的理解有誤,而不是系統(tǒng)內(nèi)部改錯了。
清晰的單元測試邊界劃分有利于構(gòu)建更加穩(wěn)定的系統(tǒng)核心代碼,因?yàn)槲覀冊谕七M(jìn)測試邊界的過程中會不斷地將副作用從核心代碼中剝離出去,最終會得到一個完整且可測試的核心,就如同下圖的對比一樣:
重構(gòu)的工作流
好代碼從來都不是一蹴而就的,都是先寫一個大概,然后逐漸迭代和重構(gòu)的,從這個角度來說,重構(gòu)別人的代碼和寫新代碼沒有很大的區(qū)別。從上面的內(nèi)容中,我們可以總結(jié)出一個簡單的重構(gòu)工作流:
按照這個方法,就能夠逐步迭代出一套優(yōu)雅且可測試的代碼,即使因?yàn)闀r間問題沒有迭代到理想的測試邊界,也會擁有一套大部分可測試的代碼,后人可以在前人用例的基礎(chǔ)上,繼續(xù)擴(kuò)大測試邊界。
過度設(shè)計
最后再談一談過度設(shè)計的問題。按照本文的方法是不可能出現(xiàn)過度設(shè)計的問題,過度設(shè)計一般發(fā)生在為了設(shè)計而設(shè)計,生搬硬套設(shè)計模式的場合,但是本文的所有設(shè)計都有一個明確的目的--提升代碼的“可測試性”,所有的技巧都是在過程中無意使用的,不存在生硬的問題。而且過度設(shè)計會導(dǎo)致“可測試性”變差,過度設(shè)計的代碼常常是把自己的核心邏輯都給抽象掉了,導(dǎo)致單元測試無處可測。如果發(fā)現(xiàn)一段代碼“寫得很簡潔,很抽象,但是就是不好寫單元測試”,那么大概率是被過度設(shè)計了。另外一種過度設(shè)計是因?yàn)檫^度依賴框架而無意中導(dǎo)致的,Java 往往習(xí)慣于將自己的設(shè)計耦合進(jìn) Spring 框架中,比如將一段完整的邏輯拆分到幾個 Spring Bean 中,而不是使用普通的 Java 類,導(dǎo)致根本就無法在不啟動容器的情況下進(jìn)行完整的測試,最后只能寫一堆無效的測試提升“覆蓋率”。這也是很多人抱怨“單元測試沒有用”的原因。
和 TDD 的區(qū)別
本文到這里都還沒有提及到 TDD,但是上文中闡述的內(nèi)容肯定讓不少讀者想到了這個名詞,TDD 是 “測試驅(qū)動開發(fā)” 的簡寫,它強(qiáng)調(diào)在代碼編寫之前先寫用例,包括三個步驟:
- 紅燈:寫用例,運(yùn)行,無法通過用例
- 綠燈:用最快最臟的代碼讓測試通過
- 重構(gòu):將代碼重構(gòu)得更加優(yōu)雅
在開發(fā)過程中不斷地重復(fù)這三個步驟。但是會實(shí)踐中會發(fā)現(xiàn),在繁忙的業(yè)務(wù)開發(fā)中想要先寫測試用例是很困難的,可能會有以下原因:
代碼結(jié)構(gòu)尚未完全確定,出入口尚未明確,即使提前寫了單元測試,后面大概率也要修改
產(chǎn)品一句話需求,外加對系統(tǒng)不夠熟悉,用例很難在開發(fā)之前寫好
因此本文的工作流將順序做了一些調(diào)整,先寫代碼,然后再不斷地重構(gòu)代碼適配單元測試,擴(kuò)大系統(tǒng)的測試邊界。不過從更廣義的 TDD 思想上來說,這篇文章的總體思路和 TDD 是差不多的,或者標(biāo)題也可以改叫做 “TDD 實(shí)踐”。
業(yè)務(wù)實(shí)例 - 導(dǎo)出系統(tǒng)重構(gòu)
釘釘審批的導(dǎo)出系統(tǒng)是一個專門負(fù)責(zé)將審批單批量導(dǎo)出成 Excel 的系統(tǒng):
大概步驟如下:
- 啟動一個線程,在內(nèi)存中異步生成 Excel
- 上傳 Excel 到釘盤/oss
- 發(fā)消息給用戶
釘釘審批導(dǎo)出系統(tǒng)比常規(guī)導(dǎo)出系統(tǒng)要更加復(fù)雜一些,因?yàn)樗谋韱谓Y(jié)構(gòu)并不是固定的。而用戶可以通過設(shè)計器靈活配置:
從上面可以看出單個審批單還具有復(fù)雜的內(nèi)部結(jié)構(gòu),比如明細(xì),關(guān)聯(lián)表單等等,而且還能相互嵌套,因此邏輯很十分復(fù)雜。
我接手導(dǎo)出系統(tǒng)的時候,已經(jīng)維護(hù)兩年了,沒有任何測試用例,代碼中導(dǎo)出都是類似 patchXxx 的方法,可見在兩年的歲月中,被打了不少補(bǔ)丁。系統(tǒng)雖然總體能用,但是有很多小 bug,基本上遇到邊界情況就會出現(xiàn)一個 bug(邊界情況比如明細(xì)里只有一個控件,明細(xì)里有關(guān)聯(lián)表單,而關(guān)聯(lián)表單里又有明細(xì)等等)。代碼完全不可測試,完成的邏輯被 Spring Bean 隔離成一小塊,一小塊,就像下圖一樣:
我決定將這些代碼重構(gòu),不能讓它繼續(xù)荼毒后人,但是面對一團(tuán)亂麻的代碼完全不知道如何下手(以下貼圖僅僅是為了讓大家感受下當(dāng)時的心情,不用仔細(xì)看):
我決定用本文的工作流對代碼進(jìn)行重新梳理。
確定測試邊界
首先需要確定哪些部分是單元測試可以覆蓋到的,哪些部分是不需要覆蓋到的,靠集成測試保證的。經(jīng)過分析,我認(rèn)為導(dǎo)出系統(tǒng)的核心功能,就是根據(jù)表單配置和表單數(shù)據(jù)生成 excel 文件:
這部分也是最核心,邏輯也最復(fù)雜的部分,因此我將這一部分作為我的測試邊界,而其他部分,比如上傳,發(fā)工作通知消息等放在邊界之外:
圖中 “表單配置” 是一個數(shù)據(jù),而 “表單數(shù)據(jù)” 其實(shí)是一個函數(shù),因?yàn)閷?dǎo)出過程中會不斷批量分頁地去查詢數(shù)據(jù)。
不斷迭代,擴(kuò)大測試邊界到理想狀態(tài)
我迭代的過程如下:
- 異步執(zhí)行導(dǎo)致不可測試:抽出一個同步的函數(shù);
- 大量使用 Spring Bean 導(dǎo)致邏輯割裂:將邏輯放到普通的 Java 類或者靜態(tài)方法中;
- 表單數(shù)據(jù),流程與用戶的相關(guān)信息查詢是遠(yuǎn)程調(diào)用,含有副作用:通過高階函數(shù)將這些副作用抽出去;
- 導(dǎo)入狀態(tài)落入數(shù)據(jù)庫,也是一個副作用:同樣通過高階函數(shù)將其抽象出去;
最終導(dǎo)出的測試邊界大約是這個樣子:
public byte[] export(FormConfig config, DataService dataService, ExportStatusStore statusStore) {
//... 省略具體邏輯, 其中包括所有可測試的邏輯, 包括表單數(shù)據(jù)轉(zhuǎn)換,excel 生成
}
- config:數(shù)據(jù),表單配置信息,含有哪些控件,以及控件的配置
- dataService: 函數(shù),用于批量分頁查詢表單數(shù)據(jù)的副作用
- statusStore: 函數(shù),用于變更和持久化導(dǎo)出的狀態(tài)的副作用
public interface DataService {
PageList batchGet(String formId, Long cursor, int pageSize);
}
public interface ExportStatusStore {
/**
* 將狀態(tài)切換為 RUNNING
*/
void runningStatus();
/**
* 將狀態(tài)置為 finish
* @param fileId 文件 id
*/
void finishStatus(Long fileId);
/**
* 將狀態(tài)置為 error
* @param errMsg 錯誤信息
*/
void errorStatus(String errMsg);
}在本地即可驗(yàn)證生成的 Excel 文件是否正確(代碼經(jīng)過簡化):
public void testExport() {
// 這里的 export 就是剛剛展示的導(dǎo)出測試邊界
byte[] excelBytes = export(new FormConfig(), new LocalDataService(),
new LocalStatusStore());
assertExcelContent(excelBytes, Arrays.asList(
Arrays.asList("序號", "表格", "表格", "表格", "創(chuàng)建時間", "創(chuàng)建者"),
Arrays.asList("序號", "物品編號", "物品名稱", "xxx", "創(chuàng)建時間", "創(chuàng)建者"),
Arrays.asList("1", "22", "火車", "而非", "2020-10-11 00:00:00", "懸衡")
));
}其中 LocalDataService,LocalStatusStore 分別是內(nèi)存中的數(shù)據(jù)服務(wù),和狀態(tài)變更服務(wù)實(shí)現(xiàn),用于進(jìn)行單元測試。assertExcelContent 是我用 poi 寫的一個工具方法,用于測試內(nèi)存中的 excel 文件是否符合預(yù)期。所有邊界的用例都可以直接在本地測試和沉淀用例。
最終的代碼結(jié)構(gòu)大約如下(經(jīng)過簡化):
雖然到現(xiàn)在為止我的目的都是提升代碼的可測試性,但是實(shí)際上我一不小心也提升了代碼的拓展性,在完全沒有相關(guān)產(chǎn)品需求的情況下:
- 通過 DataService 的抽象,系統(tǒng)可以支持多種數(shù)據(jù)源導(dǎo)出,比如來自搜索,或者來自 db 的,只要傳入不同的 DataService 實(shí)現(xiàn)即可,完全不需要改動和性邏輯;
- ExportStatusStore 的抽象,讓系統(tǒng)有能力使用不同的狀態(tài)存儲,雖然目前使用的是 db,但是也可以在不改核心邏輯的情況下輕松切換成 tair 等其他中間件;
果然在我重構(gòu)后不久,就接到了類似的需求,比如要支持從不同的數(shù)據(jù)源導(dǎo)出。我們又新增了一個導(dǎo)出入口,這個導(dǎo)出狀態(tài)是存儲在不同的表中。每次我都暗自竊喜,其實(shí)這些我早就從架構(gòu)上準(zhǔn)備好了。
單元測試的局限性
雖然本文是一篇單元測試布道文章,前文也將單元測試說得“神通廣大”,但是也不得不承認(rèn)單元測試無法解決全部的問題。
單元測試僅僅能保證應(yīng)該的代碼邏輯是正確的,但是應(yīng)用開發(fā)中還有很多更加要緊的事情,比如架構(gòu)設(shè)計,中間件選型等等,很多系統(tǒng) bug 可能不是因?yàn)榇a邏輯,而是因?yàn)榧軜?gòu)設(shè)計導(dǎo)致的,此時單元測試就無法解決。因此要徹底保障系統(tǒng)的穩(wěn)健,還是需要從單元測試,架構(gòu)治理,技術(shù)選項(xiàng)等多個方面入手。
另外一點(diǎn)也不得不承認(rèn),單元測試是有一定成本的,一套工作流完成的話,可能會有數(shù)倍于原代碼量的單元測試,因此并不是所有代碼都需要這樣的重構(gòu),在時間有限的情況下,應(yīng)該優(yōu)先重構(gòu)系統(tǒng)中核心的穩(wěn)定的代碼,在權(quán)衡好成本與價值的情況下,再開始動手。
最后,單元測試也是對人有強(qiáng)依賴的技術(shù),側(cè)重于前期預(yù)防,沒有任何辦法量化一個人單元測試的質(zhì)量如何,效果如何,這一切都是出于工程自己內(nèi)心的“工匠精神” 以及對代碼的敬畏,相信讀到最后的你,也一定有著一顆工匠的心吧。
網(wǎng)站欄目:代碼重構(gòu):面向單元測試
分享網(wǎng)址:http://m.5511xx.com/article/cdphohi.html


咨詢
建站咨詢
