本文翻譯自Sergey Kolodiy 在 Toptal.com 的一篇文章,已獲得作者授權(quán)。感謝Sergey的分享。
This article is translated from Sergey Kolodiy's post on Toptal.com. Much appreciation to Sergey.
我對(duì)單元測(cè)試的興趣源自于工作中遇到的挫折。一開(kāi)始寫代碼的時(shí)候我雖然也遵循了命名規(guī)范、代碼風(fēng)格之類的基本原則,但是覺(jué)得寫測(cè)試比較耗費(fèi)時(shí)間,測(cè)試驅(qū)動(dòng)開(kāi)發(fā)更是只應(yīng)該存在于理想世界的東西。然而當(dāng)我編寫的程序規(guī)模越來(lái)越大,代碼越來(lái)越多時(shí),我漸漸感覺(jué)維護(hù)起來(lái)力不從心,每次對(duì)緣由代碼做修改,甚至重構(gòu)時(shí),都會(huì)提心吊膽,生怕改出什么大bug來(lái)。直到這時(shí)我才意識(shí)到,語(yǔ)與其話費(fèi)時(shí)間與不斷地調(diào)試和返工,不如一開(kāi)始老老實(shí)實(shí)地寫測(cè)試,保證代碼質(zhì)量。
但后來(lái)我為已有代碼補(bǔ)測(cè)試的時(shí)候,為新代碼覆蓋測(cè)試的時(shí)候,總感覺(jué)寫出來(lái)的測(cè)試很別扭,很不優(yōu)雅:我就測(cè)試一個(gè)小小的方法,居然要搭建數(shù)據(jù)庫(kù),然后又是setUp又是tearDown。寫單元測(cè)試都這么累的嗎?寫單元測(cè)試都要掌握一些高端技巧的嗎?后來(lái)我才了解到,原來(lái)我寫的代碼根本就untestable。所以我上網(wǎng)找怎樣寫可測(cè)試代碼的指南。這一份則是我讀過(guò)的最好的教程。當(dāng)然,最經(jīng)典的資源可能要數(shù)Google大神Misko Hevery主編的 Writing Testable Code,但相比于38頁(yè)詳盡的Guide來(lái)說(shuō),我個(gè)人認(rèn)為這一份教程更適合入門。感謝作者Sergey Kolodiy,你貢獻(xiàn)出來(lái)的知識(shí)和經(jīng)驗(yàn)讓我受益匪淺。
這么好的東西不能獨(dú)占啊,應(yīng)該分享出去才行。在征得作者的同意后,我把這份教程翻譯成了中文,希望對(duì)國(guó)內(nèi)還在掙扎的同學(xué)有用,讀完后早日脫離苦海。當(dāng)然,這么長(zhǎng)的文章我是沒(méi)時(shí)間字斟句酌地翻譯的,所以采用了機(jī)器翻譯+人工修飾的方法,同時(shí)出于自身能力的局限,未免有些地方翻譯得不通順。有能力的讀者直接閱讀原文就好,領(lǐng)略作者原汁原味的思想。話說(shuō)現(xiàn)在的開(kāi)發(fā)者不懂英文很難活得下去吧······
以下是正文部分。
單元測(cè)試是任何認(rèn)真的軟件開(kāi)發(fā)者必不可少的工具。 但是,有時(shí)候?yàn)樘囟ǖ拇a編寫好的單元測(cè)試是很困難的。 在感覺(jué)難以為自己或別人的代碼編寫單元測(cè)試時(shí),開(kāi)發(fā)者往往認(rèn)為這是由于自己缺乏一些基本的測(cè)試知識(shí)或神秘的單元測(cè)試技術(shù)引起的。
在這份單元測(cè)試教程中,我打算證明單元測(cè)試非常簡(jiǎn)單;使單元測(cè)試變得困難并引入復(fù)雜性的真正原因是設(shè)計(jì)不良,難以測(cè)試的代碼。 我們將討論:
- 使代碼難以測(cè)試的原因,
- 應(yīng)該避免反模式(anti-patterns)和不良實(shí)踐(bad practices)來(lái)提高可測(cè)試性,
- 以及通過(guò)編寫可測(cè)試代碼可以獲得的其他好處。
我們將意識(shí)到編寫單元測(cè)試和可測(cè)試代碼不僅僅是為了減少測(cè)試問(wèn)題,而且能使代碼本身更健壯,更易于維護(hù)。
[圖片上傳失敗...(image-29ae4e-1555172741807)]
什么是單元測(cè)試?
從本質(zhì)上講,單元測(cè)試是把我們的程序中一小部分代碼單獨(dú)拎出來(lái),獨(dú)立于程序的其他部分,然后驗(yàn)證其表現(xiàn)和行為的測(cè)試方法。典型的單元測(cè)試包含3個(gè)階段:
- 首先,它將想要測(cè)試的一小部分程序代碼拎出來(lái),做一些初始化工作,比如實(shí)例化對(duì)象(這部分代碼也稱為被測(cè)系統(tǒng)/ system under test,或SUT)。
- 然后對(duì)被測(cè)系統(tǒng)做一些測(cè)試(通常是調(diào)用它的方法)。
- 最后,觀察被測(cè)系統(tǒng)的行為。 如果觀察到的行為與預(yù)期一致,則單元測(cè)試通過(guò),否則失敗,表明被測(cè)系統(tǒng)中某處存在問(wèn)題。
這三個(gè)單元測(cè)試階段也稱為準(zhǔn)備(Arrange),執(zhí)行 (Act)和斷言(Assert),或簡(jiǎn)稱為AAA。
單元測(cè)試可以驗(yàn)證被測(cè)系統(tǒng)不同方面的行為,但主要來(lái)說(shuō)可以分為下面兩大類:基于狀態(tài)的測(cè)試或基于交互的測(cè)試。 驗(yàn)證被測(cè)系統(tǒng)產(chǎn)生的結(jié)果是否正確,或者結(jié)果狀態(tài)是否正確,稱為基于狀態(tài)的單元測(cè)試;而驗(yàn)證它是否正確調(diào)用某些方法稱為基于交互的單元測(cè)試。
打個(gè)比方,想象一個(gè)瘋狂的科學(xué)家想要?jiǎng)?chuàng)造一些超自然的生物,有青蛙腿,章魚(yú)觸手,鳥(niǎo)翅和狗的頭部(這個(gè)比喻與程序員在工作中實(shí)際做的非常接近)。那位科學(xué)家將如何確保他所選擇的每個(gè)部分(或單元)有效、可用? 好吧,他可以先拿一只青蛙的腿,對(duì)它施加電刺激,并檢查肌肉收縮是否合適。 他所做的基本上與單元測(cè)試的準(zhǔn)備- 執(zhí)行 - 斷言步驟相同;唯一的區(qū)別是,在這種情況下,單元(unit)指的是物理對(duì)象,而不是我們構(gòu)建程序的抽象對(duì)象。
我將在本文中使用C#來(lái)編寫示例,但文中所描述的概念適用于所有面向?qū)ο蟮木幊陶Z(yǔ)言。
[TestMethod]
public void IsPalindrome_ForPalindromeString_ReturnsTrue()
{
// 在準(zhǔn)備(Arrange)階段, 我們構(gòu)造了一個(gè)被測(cè)系統(tǒng)(SUT)
// 一個(gè)被測(cè)系統(tǒng)可以是一個(gè)方法,一個(gè)單獨(dú)的對(duì)象或者是一組相互關(guān)聯(lián)的對(duì)象
// 當(dāng)然,如果沒(méi)什么要準(zhǔn)備的話也可以跳過(guò)準(zhǔn)備階段,比如測(cè)試一個(gè)靜態(tài)方法
PalindromeDetector detector = new PalindromeDetector();
// 在執(zhí)行階段我們要對(duì)被測(cè)系統(tǒng)施加這樣那樣的刺激,通常是調(diào)用用一個(gè)對(duì)象的方法
// 如果被調(diào)用的方法有返回值,我們通常會(huì)看看返回值是否正確
// 如果調(diào)用的方法沒(méi)有返回值,那我們會(huì)看看這個(gè)方法是否產(chǎn)生了正確的副作用(做了正確的操作)
bool isPalindrome = detector.IsPalindrome("kayak");
// 斷言階段決定我們的單元測(cè)試是否通過(guò)
// 這里我們檢查被測(cè)系統(tǒng)的行為是否符合預(yù)期
Assert.IsTrue(isPalindrome);
}
單元測(cè)試 VS 集成測(cè)試
有一點(diǎn)特別需要注意的是單元測(cè)試和集成測(cè)試之間的區(qū)別。
軟件工程中單元測(cè)試的目的是不受其他部分影響,獨(dú)立于其他部分,只驗(yàn)證某一小部分代碼的行為。 單元測(cè)試的范圍很窄,但允許我們覆蓋所有業(yè)務(wù)情況,確保每個(gè)部件都能正常工作。
另一方面,集成測(cè)試確保系統(tǒng)的不同部分在現(xiàn)實(shí)環(huán)境中協(xié)同工作。 它們驗(yàn)證了復(fù)雜的場(chǎng)景(我們可以將集成測(cè)試看成一個(gè)用戶在我們的系統(tǒng)中執(zhí)行某些高層次的綜合操作,而不是僅僅使用某一個(gè)部件的某一個(gè)功能),并且通常需要使用外部資源,如數(shù)據(jù)庫(kù)或Web服務(wù)器。
讓我們回到我們瘋狂的科學(xué)家比喻。假設(shè)他已成功地組裝好超自然生物體的所有部分,他希望對(duì)創(chuàng)造出來(lái)的生物進(jìn)行整體測(cè)試,確保它可以在不同類型的地形上行走。 首先,科學(xué)家必須模擬出一個(gè)生物體行走的環(huán)境。 然后,他將生物扔進(jìn)那個(gè)環(huán)境并用棍子戳它,觀察它是否按照設(shè)計(jì)行走和移動(dòng)。在完成測(cè)試后,這位瘋狂的科學(xué)家還需清理散落在他那可愛(ài)的實(shí)驗(yàn)室里的所有泥土,沙子和巖石。

請(qǐng)注意單元測(cè)試和集成測(cè)試之間的顯著差異:?jiǎn)卧獪y(cè)試驗(yàn)證應(yīng)用程序的一小部分的行為,與環(huán)境和其他部分隔離,并且非常容易實(shí)現(xiàn)!而集成測(cè)試則涵蓋不同組件之間的交互, 貼近現(xiàn)實(shí)生活的環(huán)境,需要更大的工作量,包括額外的前期準(zhǔn)備和后期清理階段。
單元和集成測(cè)試的合理組合可確保每個(gè)單元正常工作,獨(dú)立于其他單元,并且所有這些單元在集成起來(lái)后都能很好地發(fā)揮作用,使我們對(duì)整個(gè)系統(tǒng)按預(yù)期工作充滿信心。
但是,我們必須明確我們正在做的是哪一項(xiàng)測(cè)試:是單元測(cè)試還是集成測(cè)試。 這兩者的差異有時(shí)可能難以區(qū)分。 如果我們認(rèn)為我們正在編寫一個(gè)單元測(cè)試來(lái)驗(yàn)證業(yè)務(wù)邏輯類中的一些微妙邊緣情況,但卻意識(shí)到它需要使用到Web服務(wù)或數(shù)據(jù)庫(kù)等外部資源,那就意味著出問(wèn)題了。實(shí)際上,我們正在使用大錘來(lái) 破解堅(jiān)果(Essentially, we are using a sledgehammer to crack a nut.)。 這意味著糟糕的設(shè)計(jì)。
一個(gè)好的單元測(cè)試有哪些特質(zhì)
在我們進(jìn)入到本教程的主要部分并實(shí)操編寫單元測(cè)試前,然我們先快速看一下好的單元測(cè)試有哪些特質(zhì)。單元測(cè)試原則要求一個(gè)好的單元測(cè)試:
- 易寫。開(kāi)發(fā)者通常會(huì)編寫大量單元測(cè)試來(lái)覆蓋應(yīng)用程序不同情況、不同方面下的行為。因此所有這些測(cè)試?yán)虘?yīng)該很容易編寫,而無(wú)需付出巨大努力。
- 可讀。單元測(cè)試的意圖應(yīng)該是明確的。一個(gè)好的單元測(cè)試反映了程序的一些功能和行為,包括如何使用、有哪些典型的使用場(chǎng)景。因此應(yīng)該很容易能理解正在測(cè)試哪個(gè)場(chǎng)景 - 如果測(cè)試失敗 - 很容易檢測(cè)和定位到問(wèn)題。有了良好的單元測(cè)試,我們可以在調(diào)試代碼之前就發(fā)現(xiàn)并修復(fù)錯(cuò)誤!
- 可靠。只有在被測(cè)系統(tǒng)中存在錯(cuò)誤時(shí),單元測(cè)試才會(huì)失敗。這似乎很理所當(dāng)然,但程序員經(jīng)常遇到一個(gè)問(wèn)題——即使沒(méi)有引入錯(cuò)誤,他們的測(cè)試也會(huì)失敗。例如,測(cè)試可以在逐個(gè)運(yùn)行時(shí)通過(guò),但在運(yùn)行整個(gè)測(cè)試套件時(shí)失敗,或者在我們的開(kāi)發(fā)機(jī)上通過(guò)了卻在持續(xù)集成服務(wù)器上失敗。這些情況表明存在設(shè)計(jì)缺陷。良好的單元測(cè)試應(yīng)該是可復(fù)現(xiàn)的,并且不受外部因素的影響,例如環(huán)境或運(yùn)行順序。
- 快速。開(kāi)發(fā)者編寫單元測(cè)試的目的,是重復(fù)運(yùn)行它們并檢查是否有引入錯(cuò)誤。如果單元測(cè)試運(yùn)行地很慢,開(kāi)發(fā)人員很可能跳過(guò)在自己的機(jī)器上運(yùn)行測(cè)試。一個(gè)測(cè)試很緩慢不會(huì)有什么顯著影響;然而當(dāng)單元測(cè)試的規(guī)模變大,比如增加一千個(gè)測(cè)試,我們肯定回浪費(fèi)一段時(shí)間去等待測(cè)試結(jié)束。慢速的單元測(cè)試還可能表明被測(cè)系統(tǒng)或單元測(cè)試本身與用到了外部資源,使測(cè)試依賴于環(huán)境。
- 真正的單元測(cè)試,而不是集成測(cè)試。正如我們已經(jīng)討論過(guò)的,單元測(cè)試和集成測(cè)試有不同的用途。單元測(cè)試和被測(cè)系統(tǒng)都不應(yīng)訪問(wèn)網(wǎng)絡(luò)資源,數(shù)據(jù)庫(kù),文件系統(tǒng)等,以消除外部因素的影響。
就是這樣:編寫單元測(cè)試并沒(méi)有什么神秘的技巧。 不過(guò),倒是有一些技術(shù)讓我們編寫出可測(cè)試的代碼。
可測(cè)試代碼 VS 不可測(cè)試代碼
有些代碼在編寫時(shí)開(kāi)發(fā)者沒(méi)有遵守合理的代碼規(guī)范,以至于很難、甚至不可能為這些代碼寫出好的單元測(cè)試。 那么,是什么讓代碼難以測(cè)試? 讓我們回顧一下在編寫可測(cè)試代碼時(shí)應(yīng)該避免的一些反模式( anti-patterns),代碼異味(code smells)和不良實(shí)踐(bad practices)。
存在非確定因素的有毒代碼
讓我們從一個(gè)簡(jiǎn)單的例子開(kāi)始。 想象一下,我們正在編程實(shí)現(xiàn)一個(gè)智能家用微控制器,其中一個(gè)需求是如果在晚上(evening)或深夜(night)檢測(cè)到有動(dòng)作、有聲響時(shí),自動(dòng)打開(kāi)后院的燈光。 我們已經(jīng)自下而上開(kāi)始實(shí)現(xiàn)一個(gè)方法,返回大致時(shí)間(“Night”,“Morning”,“Afternoon” 或“Evening”)的字符串表示:
public static string GetTimeOfDay()
{
DateTime time = DateTime.Now;
if (time.Hour >= 0 && time.Hour < 6)
{
return "Night";
}
if (time.Hour >= 6 && time.Hour < 12)
{
return "Morning";
}
if (time.Hour >= 12 && time.Hour < 18)
{
return "Afternoon";
}
return "Evening";
}
此方法讀取當(dāng)前系統(tǒng)時(shí)間并根據(jù)該值返回結(jié)果。 那么,這段代碼有什么問(wèn)題?
如果我們從單元測(cè)試的角度考慮它,我們就會(huì)發(fā)現(xiàn),不可能為此方法編寫出合理的基于狀態(tài)的單元測(cè)試。 DateTime.Now本質(zhì)上是該方法的一個(gè)隱藏的輸入,且它的值會(huì)在程序執(zhí)行期間或測(cè)試運(yùn)行期間發(fā)生變化。 因此,隨后對(duì)它的調(diào)用將產(chǎn)生不同的結(jié)果。
這種非確定(non-deterministic)的行為使得必須先更改系統(tǒng)日期和時(shí)間然后才能測(cè)試GetTimeOfDay()方法的內(nèi)部邏輯。 我們來(lái)看看如何實(shí)現(xiàn)這樣的測(cè)試:
[TestMethod]
public void GetTimeOfDay_At6AM_ReturnsMorning()
{
try
{
// 前期工作:把系統(tǒng)時(shí)間改成6AM
...
// 準(zhǔn)備階段可以跳過(guò):測(cè)試的是靜態(tài)方法,無(wú)需實(shí)例化
// 執(zhí)行
string timeOfDay = GetTimeOfDay();
// 斷言
Assert.AreEqual("Morning", timeOfDay);
}
finally
{
// 善后工作: 把系統(tǒng)時(shí)間調(diào)回去
...
}
}
像這樣的測(cè)試會(huì)違反前面討論的許多規(guī)則。這樣的測(cè)試不容易編寫(設(shè)置系統(tǒng)時(shí)間然后又調(diào)回去可不是非凡的工作),也不可靠(例如,由于系統(tǒng)權(quán)限問(wèn)題,即使被測(cè)系統(tǒng)中沒(méi)有錯(cuò)誤,測(cè)試也可能會(huì)失敗),并且不能保證快速執(zhí)行。 而且,最后,這個(gè)測(cè)試實(shí)際上不是單元測(cè)試:它將是單元和集成測(cè)試之間的東西,因?yàn)樗此茰y(cè)試一個(gè)簡(jiǎn)單的邊緣情況,但需要以特定方式設(shè)置環(huán)境。
所有這些可測(cè)試性問(wèn)題都是由低質(zhì)量的GetTimeOfDay()API引起的??疾煲幌履壳暗脑O(shè)計(jì)和實(shí)現(xiàn),這個(gè)方法有幾個(gè)問(wèn)題:
- 它與具體數(shù)據(jù)源緊密耦合。不能重用此方法來(lái)處理從其他數(shù)據(jù)源獲取的日期和時(shí)間;該方法僅適用于執(zhí)行該程序的特定計(jì)算機(jī)。緊耦合是大多數(shù)可測(cè)性問(wèn)題的主要根源。
- 它違反了單一責(zé)任原則(SRP)。該方法有多個(gè)責(zé)任:它消耗信息并處理信息。 SRP違規(guī)的一個(gè)判斷標(biāo)準(zhǔn)是單個(gè)類或方法有多個(gè)原因會(huì)引起修改。從這個(gè)角度來(lái)看,修改
GetTimeOfDay()方法的原因有兩個(gè):可以是內(nèi)部邏輯調(diào)整,也可以是修改日期和時(shí)間的數(shù)據(jù)源。因此這個(gè)方法違反了SRP。 - 它隱藏了完成工作所需要的信息(It lies about the information required to get its job done.)。開(kāi)發(fā)者必須閱讀實(shí)際源代碼的每一行,才能了解方法使用到了哪些隱藏輸入以及它們來(lái)自何處。僅僅是方法簽名不足以理解方法的行為。
- 它很難預(yù)測(cè)和維護(hù)。僅通過(guò)閱讀源代碼無(wú)法預(yù)測(cè)依賴于可變?nèi)譅顟B(tài)(在這里是系統(tǒng)時(shí)間)的方法會(huì)有怎樣的行為:必須考慮這個(gè)可變?nèi)譅顟B(tài)的當(dāng)前值,以及可能會(huì)改變它的所有事件。在真實(shí)世界的應(yīng)用程序中,試圖考慮所有這些東西是很讓人頭痛的。
在考察了這個(gè)API之后,讓我們來(lái)修正它吧!幸運(yùn)的是,這比討論它的各種設(shè)計(jì)缺陷要容易得多——我們只需要打破緊耦合問(wèn)題,分離關(guān)注點(diǎn)。
改造API:引入一個(gè)方法參數(shù)
改造這個(gè)API,最明顯也最容易的方式是引入一個(gè)方法參數(shù)(introducing a method argument)。
public static string GetTimeOfDay(DateTime dateTime)
{
if (dateTime.Hour >= 0 && dateTime.Hour < 6)
{
return "Night";
}
if (dateTime.Hour >= 6 && dateTime.Hour < 12)
{
return "Morning";
}
if (dateTime.Hour >= 12 && dateTime.Hour < 18)
{
return "Noon";
}
return "Evening";
}
現(xiàn)在,該方法要求調(diào)用者提供DateTime參數(shù),而不是自己私下去查找此信息,這就符合了優(yōu)秀單元測(cè)的特征:該方法現(xiàn)在是確定的(即,它的返回值完全取決于輸入),因此這個(gè)基于狀態(tài)的測(cè)試現(xiàn)在就很簡(jiǎn)單了,只需傳遞一些DateTime值并檢查返回結(jié)果:
[TestMethod]
public void GetTimeOfDay_For6AM_ReturnsMorning()
{
// 準(zhǔn)備階段可以跳過(guò):測(cè)試的是靜態(tài)方法,無(wú)需實(shí)例化
// 執(zhí)行
string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00));
// 斷言
Assert.AreEqual("Morning", timeOfDay);
}
請(qǐng)注意,通過(guò)分離關(guān)注點(diǎn):要處理哪些數(shù)據(jù)和應(yīng)該如何處理數(shù)據(jù),這個(gè)簡(jiǎn)單的重構(gòu)也解決了前面討論的所有API問(wèn)題(緊耦合,違反SRP,不可預(yù)測(cè)和難以理解的API)。
好極了!現(xiàn)在這個(gè)方法可以測(cè)試,但調(diào)用該方法的代碼又該怎么辦? 現(xiàn)在,調(diào)用者有責(zé)任為GetTimeOfDay(DateTime dateTime)方法提供日期和時(shí)間,如果我們沒(méi)有考慮到調(diào)用者,它們也可能變得不可測(cè)試。 我們來(lái)看看如何處理這個(gè)問(wèn)題。
改造調(diào)用程序:依賴注入
讓我們繼續(xù)研究上述智能家居系統(tǒng),并編寫一個(gè)調(diào)用GetTimeOfDay(DateTime dateTime)的程序——這段程序負(fù)責(zé)根據(jù)日期時(shí)間和檢測(cè)到動(dòng)作來(lái)打開(kāi)或關(guān)閉后院里的燈:
public class SmartHomeController
{
public DateTime LastMotionTime { get; private set; }
public void ActuateLights(bool motionDetected)
{
DateTime time = DateTime.Now; // 這里出問(wèn)題了!
// 更新檢測(cè)到動(dòng)作的時(shí)間
if (motionDetected)
{
LastMotionTime = time;
}
// 如果檢測(cè)到有動(dòng)作時(shí)處于晚上或深夜
string timeOfDay = GetTimeOfDay(time);
if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night"))
{
BackyardLightSwitcher.Instance.TurnOn();
}
// 若持續(xù)一分鐘沒(méi)有檢測(cè)到動(dòng)作或聲響, 或者正處于早上或中午, 則把燈關(guān)掉
else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon"))
{
BackyardLightSwitcher.Instance.TurnOff();
}
}
}
麻煩了!這里出現(xiàn)了和先前類似的DateTime.Now隱藏輸入問(wèn)題,唯一的區(qū)別是它在調(diào)用者一方,所處的抽象級(jí)別的稍高一點(diǎn)。為了解決這個(gè)問(wèn)題,我們可以再次引入一個(gè)參數(shù)ActuateLights(bool motionDetected,DateTime dateTime)把提供DateTime的責(zé)任拋給再上一層調(diào)用該方法的程序。不過(guò),我么可以采用另一種技巧,而非再將問(wèn)題提高到調(diào)用堆棧中的更高級(jí)別,來(lái)讓ActuateLights(bool motionDetected)API不用改變,同時(shí)也可測(cè)試,這種技巧就是控制反轉(zhuǎn)(Inverse of Control,IoC)。
控制反轉(zhuǎn)是一種簡(jiǎn)單卻又非常有用的解耦代碼技巧,特別適合單元測(cè)試。畢竟,保持松散耦合對(duì)于能夠彼此獨(dú)立地分析各個(gè)單元至關(guān)重要。IoC的關(guān)鍵點(diǎn)是將控制代碼(when to do something)與執(zhí)行代碼分開(kāi)(what to do when things happen)。該技術(shù)提高了程序的靈活性,使我們的代碼更加模塊化,并減少了組件之間的耦合。
控制反轉(zhuǎn)可以通過(guò)多種方式實(shí)現(xiàn),讓我們看一個(gè)例子:構(gòu)造函數(shù)依賴注入,以及它怎樣協(xié)助構(gòu)建可測(cè)試的SmartHomeControllerAPI。
首先,讓我們創(chuàng)建一個(gè)IDateTimeProvider接口,其中包含用于獲取某些日期和時(shí)間的方法簽名:
public interface IDateTimeProvider
{
DateTime GetDateTime();
}
然后,讓SmartHomeController使用IDateTimeProvider的一個(gè)實(shí)現(xiàn)去獲取日期和時(shí)間:
public class SmartHomeController
{
private readonly IDateTimeProvider _dateTimeProvider; // 一來(lái)
public SmartHomeController(IDateTimeProvider dateTimeProvider)
{
// 將依賴注入到構(gòu)造函數(shù)
_dateTimeProvider = dateTimeProvider;
}
public void ActuateLights(bool motionDetected)
{
DateTime time = _dateTimeProvider.GetDateTime(); // 分離關(guān)注點(diǎn)
// 剩余的開(kāi)關(guān)燈控制邏輯...
}
}
現(xiàn)在我們可以看到為什么這種技巧被稱為控制反轉(zhuǎn):本來(lái)SmartHomeController的調(diào)用者應(yīng)該依賴SmartHomeController去讀取日期和時(shí)間和做其他事,但現(xiàn)在SmartHomeController卻依賴于調(diào)用者提供讀取日期時(shí)間的機(jī)制。 如今,ActuateLights(bool motionDetected)方法的執(zhí)行取決于兩件可以從外部輕松管理的東西:motionDetected參數(shù)和傳遞給SmartHomeController構(gòu)造函數(shù)IDateTimeProvider的具體實(shí)現(xiàn)。
為什么這對(duì)單元測(cè)試很重要? 這意味著可以在生產(chǎn)代碼和單元測(cè)試代碼中使用不同的IDateTimeProvider實(shí)現(xiàn)。 在生產(chǎn)環(huán)境中,將注入一些實(shí)際實(shí)現(xiàn)(例如,讀取實(shí)際系統(tǒng)時(shí)間的實(shí)現(xiàn))。 但是,在單元測(cè)試中,我們可以注入一個(gè)“假”實(shí)現(xiàn),它返回一個(gè)適合于測(cè)試特定場(chǎng)景的常量或預(yù)定義DateTime值。
下面是IDateTimeProvider 的一個(gè)“假”實(shí)現(xiàn):
public class FakeDateTimeProvider : IDateTimeProvider
{
public DateTime ReturnValue { get; set; }
public DateTime GetDateTime() { return ReturnValue; }
public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; }
}
在這個(gè)類的幫助下,我們可以將SmartHomeController與非確定性因素隔離開(kāi)來(lái),并執(zhí)行基于狀態(tài)的單元測(cè)試。 下面的測(cè)試驗(yàn)證,如果檢測(cè)到有動(dòng)作,則該動(dòng)作的時(shí)間記錄在LastMotionTime屬性中:
[TestMethod]
void ActuateLights_MotionDetected_SavesTimeOfMotion()
{
// Arrange
var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59)));
// Act
controller.ActuateLights(true);
// Assert
Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime);
}
好極了! 在重構(gòu)之前我們不可能寫出這樣的測(cè)試?,F(xiàn)在我們已經(jīng)消除了非確定性因素并驗(yàn)證了基于狀態(tài)的場(chǎng)景,你認(rèn)為SmartHomeController是完全可測(cè)試的代碼了嗎?
副作用會(huì)污染代碼
盡管我們解決了由非確定性隱藏輸入引起的問(wèn)題,并且我們能夠測(cè)試某些功能,但代碼(或者至少其中一些代碼)仍然是不可測(cè)試的!
讓我們回顧一下負(fù)責(zé)打開(kāi)或關(guān)閉燈光的ActuateLights(bool motionDetected)方法的以下部分:
// 如果檢測(cè)到有動(dòng)作時(shí)處于晚上或深夜, 把燈打開(kāi)
if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night"))
{
BackyardLightSwitcher.Instance.TurnOn();
}
// 若持續(xù)一分鐘沒(méi)有檢測(cè)到動(dòng)作或聲響, 或者正處于早上或中午, 則把燈關(guān)掉
else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon"))
{
BackyardLightSwitcher.Instance.TurnOff();
}
正如我們所看到的,SmartHomeController將開(kāi)啟或關(guān)閉燈光的責(zé)任委托給BackyardLightSwitcher對(duì)象,后者實(shí)現(xiàn)了單例模式。 這個(gè)設(shè)計(jì)有什么問(wèn)題?
像要徹底測(cè)試ActuateLights(bool motionDetected)方法,除了基于狀態(tài)的測(cè)試之外,我們還應(yīng)該執(zhí)行基于交互的測(cè)試:也就是說(shuō),當(dāng)且僅當(dāng)滿足適當(dāng)?shù)臈l件時(shí),我們應(yīng)該確負(fù)責(zé)保打開(kāi)或關(guān)閉燈的方法能被調(diào)用。 不幸的是,當(dāng)前的設(shè)計(jì)不允許我們這樣做:BackyardLightSwitcher的TurnOn()和TurnOff()方法不返回任何東西,而是引發(fā)了系統(tǒng)中的一些狀態(tài)變化,換句話說(shuō),產(chǎn)生副作用。 驗(yàn)證這些方法是否被調(diào)用的唯一方式是檢查它們相應(yīng)的副作用是否實(shí)際發(fā)生,這可能也是相當(dāng)痛苦的事情。
讓我們假設(shè)動(dòng)作檢測(cè)器,后院燈和智能家用微控制器三者連接到了物聯(lián)網(wǎng),并使用一些無(wú)線協(xié)議進(jìn)行通信。 在這種情況下,單元測(cè)試可以嘗試接收和分析網(wǎng)絡(luò)流量?;蛘撸绻@些組件通過(guò)導(dǎo)線連接,則單元測(cè)試可以檢查電壓是否施加到適當(dāng)?shù)碾娐贰?或者說(shuō),我們還可以使用額外的光傳感器來(lái)檢查燈光確實(shí)是打開(kāi)或關(guān)閉了。
正如我們所看到的,測(cè)試產(chǎn)生副作用的方法可能會(huì)與測(cè)試包含非確定性因素的方法一樣難,甚至不可能做到。任何嘗試編寫的測(cè)試都可能有我們之前討論到的問(wèn)題:很難實(shí)現(xiàn),不可靠,可能很慢,而且不是真正的單元。而且,每次我們運(yùn)行測(cè)試套件時(shí)都會(huì)不斷閃爍的燈光最終都會(huì)讓我們發(fā)瘋!
同樣,所有這些可測(cè)試性問(wèn)題都應(yīng)該歸咎于錯(cuò)誤的API設(shè)計(jì),而不是開(kāi)發(fā)者編寫單元測(cè)試的能力。 無(wú)論如何實(shí)現(xiàn)輕量級(jí)控制,SmartHomeController API都會(huì)遇到這些已經(jīng)熟悉的問(wèn)題:
- 它與具體實(shí)現(xiàn)緊耦合。 API依賴于
BackyardLightSwitcher的被寫死的具體實(shí)例,因此也就不可能重用ActuateLights(bool motionDetected)方法來(lái)控制除后院之外的其他任何燈光。 - 它違反了單一責(zé)任原則。 API會(huì)在兩種情況下需要修改:改變內(nèi)部邏輯(例如選擇僅在夜間開(kāi)燈,而不是在晚上開(kāi)燈);或者改變燈光開(kāi)閉的具體機(jī)制。
- 它隱藏了依賴。除了深入研究源代碼之外,開(kāi)發(fā)者無(wú)法知道
SmartHomeController其實(shí)依賴于被硬編碼的BackyardLightSwitcher組件。 - 它很難理解和維護(hù)。如果在條件吻合的情況下燈光卻沒(méi)有開(kāi)啟怎么辦?我們可能會(huì)花費(fèi)大量時(shí)間來(lái)嘗試修復(fù)
SmartHomeController卻無(wú)濟(jì)于事,最后才意識(shí)到問(wèn)題是由BackyardLightSwitcher中的一個(gè)錯(cuò)誤造成的(或者,甚至更有趣,是燈泡燒壞了?。?。
毫無(wú)疑問(wèn),可測(cè)試性和低質(zhì)量API問(wèn)題的解決方案都是將緊密耦合的組件彼此分開(kāi)。與前面的示例一樣,使用依賴注入可以解決這些問(wèn)題:只需將一個(gè)ILightSwitcher依賴項(xiàng)添加到SmartHomeController,讓它負(fù)責(zé)控制燈的開(kāi)關(guān),并在測(cè)試時(shí)傳遞一個(gè)假的,僅測(cè)試用的ILightSwitcher實(shí)現(xiàn),記錄是否在適當(dāng)?shù)臈l件下調(diào)用了適當(dāng)?shù)姆椒ā?但是我們可以使用一種有趣的方式來(lái)解決這個(gè)問(wèn)題。
改造API:高階函數(shù)
這種方案可以用在任何一種支持一階函數(shù)的面向?qū)ο笳Z(yǔ)言。我們將利用C#的功能特性,讓ActuateLights(bool motionDetected)方法接受另外兩個(gè)參數(shù):一對(duì)Actiondelegates函數(shù),來(lái)打開(kāi)和關(guān)閉燈。 這個(gè)解決方案將該方法轉(zhuǎn)變?yōu)橐粋€(gè)高階函數(shù):
public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff)
{
DateTime time = _dateTimeProvider.GetDateTime();
// 更新檢測(cè)到動(dòng)作的時(shí)間
if (motionDetected)
{
LastMotionTime = time;
}
// 如果檢測(cè)到有動(dòng)作時(shí)處于晚上或深夜
string timeOfDay = GetTimeOfDay(time);
if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night"))
{
turnOn(); // 調(diào)用一個(gè)負(fù)責(zé)開(kāi)燈的函數(shù),解耦代碼
}
// 若持續(xù)一分鐘沒(méi)有檢測(cè)到動(dòng)作或聲響, 或者正處于早上或中午, 則把燈關(guān)掉
else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon"))
{
turnOff(); // 調(diào)用一個(gè)負(fù)責(zé)關(guān)燈的函數(shù),解耦代碼
}
}
與我們之前看到的經(jīng)典的面向?qū)ο笫揭蕾囎⑷敕椒ㄏ啾?,這是一個(gè)更具函數(shù)式編程風(fēng)格的解決方案。但是,與依賴注入相比,它可以讓我們以更少的代碼和更強(qiáng)的表現(xiàn)力獲得相同的效果。使用這種方案,我們不再需要設(shè)計(jì)一個(gè)借口,然后又實(shí)現(xiàn)一個(gè)符合接口規(guī)范的類來(lái)為SmartHomeController提供所需的功能。我們需要做的只有傳遞一個(gè)函數(shù)。 高階函數(shù)可以被認(rèn)為是實(shí)現(xiàn)控制反轉(zhuǎn)的一種方式(其實(shí)本質(zhì)上也是依賴注入)。
現(xiàn)在,要對(duì)這個(gè)方法編寫基于交互的單元測(cè)試,我們可以將容易驗(yàn)證的“假”Action函數(shù)傳遞到其中:
[TestMethod]
public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight()
{
// 準(zhǔn)備階段:構(gòu)造兩個(gè)僅僅是改變某個(gè)變量的布爾值的Action函數(shù)
bool turnedOn = false;
Action turnOn = () => turnedOn = true;
Action turnOff = () => turnedOn = false;
var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59)));
// 執(zhí)行
controller.ActuateLights(true, turnOn, turnOff);
// 斷言
Assert.IsTrue(turnedOn);
}
最后,經(jīng)過(guò)努力我們終于把SmartHomeControllerAPI改造得徹底可測(cè)試了,無(wú)論是基于狀態(tài)愛(ài)是基于交互的單元測(cè)試都可以編寫了。再次注意,除了提高可測(cè)試性之外,分離決策和執(zhí)行有助于解決緊耦合問(wèn)題,并且使得API更清晰,可重用。
雜質(zhì)(Impurity)與可測(cè)試性(Testability)
不受控的非確定性因素和副作用兩者對(duì)代碼的破壞性影響是相似的。如果使用不當(dāng),就會(huì)導(dǎo)致具有欺騙性,難以理解和維護(hù),緊耦合,不可重用和不可測(cè)試的代碼。
另一方面,確定性和無(wú)副作用的方法更容易測(cè)試,預(yù)測(cè)和重用以構(gòu)建更大的程序。在函數(shù)式編程里,這些方法(指對(duì)象里的方法)稱為純函數(shù)。我們?cè)跒榧兒瘮?shù)編寫單元測(cè)試的時(shí)候很少有問(wèn)題。我們所要做的僅僅是傳遞一些參數(shù)并檢查結(jié)果是否正確。真正使代碼不可測(cè)試的是哪些被硬編碼的,不可替代的因素,不能以任何其他方式替換,覆蓋或抽象。
雜質(zhì)是有毒的:如果方法Foo()依賴于非確定性或副作用方法Bar(),那么Foo()也包含了非確定性或副作用。最終,我們最終可能會(huì)毒害整個(gè)代碼庫(kù)。想象一下所有這些問(wèn)題乘,以及現(xiàn)實(shí)中應(yīng)用程序的規(guī)模,我們將發(fā)現(xiàn)自己陷入了難以維護(hù)的代碼庫(kù),充滿了異味,反模式,秘密依賴以及各種丑陋和讓人不快的東西( We’ll find ourselves encumbered with a hard to maintain codebase full of smells, anti-patterns, secret dependencies, and all sorts of ugly and unpleasant things.)。

然而,雜質(zhì)是不可避免的。任何現(xiàn)實(shí)中的應(yīng)用程序在某些時(shí)候都必須與環(huán)境,數(shù)據(jù)庫(kù),配置文件,Web服務(wù)或其他外部系統(tǒng)交互。 因此,我們的目標(biāo)不應(yīng)該是完全消除雜質(zhì),而是限制這些因素,避免讓它們毒害整個(gè)代碼庫(kù),并盡可能地消滅被硬編碼的依賴關(guān)系,以便能夠獨(dú)立地分析各部件和進(jìn)行單元測(cè)試。
一些常見(jiàn)警告標(biāo)志
感覺(jué)寫測(cè)試的時(shí)候舉步維艱?問(wèn)題不在你的測(cè)試套件中,而是在你的代碼中。
最后,讓我們回顧一些常見(jiàn)的,表明我們的代碼可能難以測(cè)試警告標(biāo)志。
靜態(tài)屬性
靜態(tài)屬性和字段,或簡(jiǎn)單地說(shuō),全局狀態(tài),會(huì)隱藏方法執(zhí)行時(shí)用到信息,引入非確定性,或引入使用副作用,使得代碼理解和測(cè)試變得復(fù)雜。 讀取或修改可變?nèi)譅顟B(tài)的函數(shù)是不純的。
例如,我們很難預(yù)測(cè)以下代碼的行為,因?yàn)檫@取決于可修改的全局屬性CostSavingEnabled:
if (!SmartHomeSettings.CostSavingEnabled)
{
_swimmingPoolController.HeatWater();
}
如果HeatWater()方法在它應(yīng)該被調(diào)用時(shí)沒(méi)有被調(diào)用怎么辦? 由于應(yīng)用程序的任何部分都可能已更改CostSavingEnabled值,因此我們必須分析所有可能改動(dòng)這個(gè)變量的代碼,才能找出錯(cuò)誤。 此外,正如我們已經(jīng)看到的,不能出于測(cè)試的目的而添加一些靜態(tài)屬性(例如,DateTime.Now或Environment.MachineName。雖然它們是只讀的,但仍然是非確定性的)。
另一方面,不可變和確定性的全局狀態(tài)則是完全可以的,事實(shí)上它就是一個(gè)常數(shù)。 像Math.PI這樣的常量值不會(huì)引入任何非確定性,并且由于它們的值無(wú)法更改,因此不產(chǎn)生任何副作用:
// 這依然是一個(gè)純函數(shù)
double Circumference(double radius) { return 2 * Math.PI * radius; }
單例
從本質(zhì)上講,單例模式就是一種全局狀態(tài)。 單例會(huì)使得API變得具有欺騙性,隱藏起真正的依賴關(guān)系,并在組件之間引入不必要的緊耦合。 單例還違反了單一責(zé)任原則,因?yàn)槌酥饕氊?zé)外,它們還控制著自己的初始化和生命周期。
單例很容易會(huì)導(dǎo)致使單元測(cè)試變得依賴執(zhí)行順序,因?yàn)閱卫谡麄€(gè)應(yīng)用程序或單元測(cè)試套件的生命周期中都帶有狀態(tài)??纯聪旅娴睦樱?/p>
User GetUser(int userId)
{
User user;
if (UserCache.Instance.ContainsKey(userId))
{
user = UserCache.Instance[userId];
}
else
{
user = _userService.LoadUser(userId);
UserCache.Instance[userId] = user;
}
return user;
}
在上面的示例中,如果首先執(zhí)行了針對(duì)緩存命中的測(cè)試,就需要先向緩存添加用戶,那么緩存有了數(shù)據(jù)。若后續(xù)執(zhí)行假定緩存為空的,針對(duì)緩存未命中的測(cè)試可能會(huì)失敗。 為了解決這個(gè)問(wèn)題,我們將不得不編寫額外的清理代碼來(lái)清理每個(gè)單元測(cè)試運(yùn)行后的UserCache。
在大多數(shù)情況下,使用單例模式是一種不好的做法,可以(而且應(yīng)該)避免使用。但是,應(yīng)該區(qū)分單例模式和某個(gè)類的單個(gè)實(shí)例。 在后一種情況下,創(chuàng)建和維護(hù)單個(gè)實(shí)例的責(zé)任在于整個(gè)應(yīng)用程序本身。 通常,這是由一個(gè)工廠或依賴注入容器提供的,該容器在應(yīng)用程序的“頂部”附近(即,靠近應(yīng)用程序入口)創(chuàng)建單個(gè)實(shí)例,然后將其傳遞給需要它的每個(gè)對(duì)象。 從可測(cè)試性和API質(zhì)量的角度來(lái)看,這種方法都是絕對(duì)正確的。
(譯者注:我覺(jué)得這里作者想要強(qiáng)調(diào)的是區(qū)分可變數(shù)據(jù)和不可變數(shù)據(jù)或者工具實(shí)例。如果某個(gè)實(shí)例承擔(dān)數(shù)據(jù)容器的職責(zé),那本質(zhì)上就是可變的全局變量,應(yīng)該避免。然而某單個(gè)實(shí)例雖然和單例模式造出來(lái)的東西很像,但其實(shí)只是一個(gè)工具,比如像上面提到的DateTime工具或者Action函數(shù),那么只要不寫死在代碼里,就可以放心大膽地使用。這兩段寫得有點(diǎn)含糊,看不明白的讀者建議直接讀原文。)
new 操作符
為了完成某些工作而實(shí)例化出一個(gè)對(duì)象,會(huì)引入與反單例模式相同的問(wèn)題:隱藏的依賴,緊耦合、可測(cè)試性差以及不清晰的API。
例如,為了測(cè)試當(dāng)返回404狀態(tài)碼時(shí)以下循環(huán)是否停止,開(kāi)發(fā)者就需要搭建一個(gè)測(cè)試用的Web服務(wù)器,這顯然是一件令人很頭痛的事情:
using (var client = new HttpClient())
{
HttpResponseMessage response;
do
{
response = await client.GetAsync(uri);
// 處理響應(yīng)并更新uri...
} while (response.StatusCode != HttpStatusCode.NotFound); // 404時(shí)停止
}
但是,有時(shí)候new操作符是無(wú)害的,可以放心使用,那就是在構(gòu)造簡(jiǎn)單的實(shí)體對(duì)象時(shí):
var person = new Person("John", "Doe", new DateTime(1970, 12, 31));
創(chuàng)建一個(gè)不會(huì)產(chǎn)生任何副作用的小型臨時(shí)對(duì)象是沒(méi)問(wèn)題的。同樣,如果只修改自己的狀態(tài),然后根據(jù)該狀態(tài)返回結(jié)果,那也是沒(méi)問(wèn)題的。 在下面的示例中,我們不關(guān)心stack的方法有沒(méi)有調(diào)用,我們只關(guān)心最終的返回值是否正確:
string ReverseString(string input)
{
// 沒(méi)必要做測(cè)試stack的方法有沒(méi)有被調(diào)用(基于交互的測(cè)試)
// 編寫單元測(cè)試時(shí)只需檢查返回值正確(基于狀態(tài)的測(cè)試)
var stack = new Stack<char>();
foreach(var s in input)
{
stack.Push(s);
}
string result = string.Empty;
while(stack.Count != 0)
{
result += stack.Pop();
}
return result;
}
靜態(tài)方法
靜態(tài)方法是非確定性或副作用的另一個(gè)潛在來(lái)源。 它們很容易引入緊耦合并使我們的代碼變得不可測(cè)試。
例如,要檢驗(yàn)以下方法的行為,單元測(cè)試的代碼就必須操作環(huán)境變量并讀取控制臺(tái)輸出流來(lái)檢驗(yàn)是否打印出了正確的數(shù)據(jù):
void CheckPathEnvironmentVariable()
{
if (Environment.GetEnvironmentVariable("PATH") != null)
{
Console.WriteLine("PATH environment variable exists.");
}
else
{
Console.WriteLine("PATH environment variable is not defined.");
}
}
不過(guò),靜態(tài)的純函數(shù)是允許的,任意純函數(shù)的組合函數(shù)也是可以的。比如
double Hypotenuse(double side1, double side2)
{
return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2));
}
單元測(cè)試的好處
顯然,編寫可測(cè)試代碼需要遵循一定的原則,付出額外的精力。 但無(wú)論如何,軟件開(kāi)發(fā)是一項(xiàng)復(fù)雜的智力活動(dòng),我們應(yīng)該始終小心謹(jǐn)慎,并且避免輕率地從頭腦中拋出新的代碼。
作為對(duì)這種軟件質(zhì)量保證行為(亦即單元測(cè)試)的獎(jiǎng)勵(lì),我們最終會(huì)得到干凈的,易于維護(hù)的,松散耦合的和可重復(fù)使用的API,在嘗試?yán)斫膺@些API時(shí)開(kāi)發(fā)者的大腦不會(huì)受到損傷。 畢竟,可測(cè)試代碼的最終優(yōu)勢(shì)不僅在于可測(cè)試性本身,還在于能夠輕松理解,維護(hù)和擴(kuò)展代碼。
本作品首發(fā)于簡(jiǎn)書(shū) ,采用知識(shí)共享署名 4.0 國(guó)際許可協(xié)議進(jìn)行授權(quán)。
原文鏈接:https://www.toptal.com/qa/how-to-write-testable-code-and-why-it-matters