单元测试:如何编写可测试的代码及其重要性
原文來自互聯(lián)網(wǎng),由長沙DotNET技術(shù)社區(qū)編譯。如譯文侵犯您的署名權(quán)或版權(quán),請聯(lián)系小編,小編將在24小時內(nèi)刪除。限于譯者的能力有限,個別語句翻譯略顯生硬,還請見諒。
作者:謝爾蓋·科洛迪(SERGEY KOLODIY)
原文:https://www.toptal.com/qa/how-to-write-testable-code-and-why-it-matters
單元測試是任何專業(yè)軟件開發(fā)人員工具箱中必不可少的工具,但是,有時很難為特定的代碼編寫好的單元測試。開發(fā)人員在測試自己或他人的代碼時遇到困難,常常認為自己的努力是由于缺乏一些基本的測試知識或以為單元測試存在某些獨門絕技而引起的。
在本單元測試教程中,我打算證明單元測試非常簡單。真正的問題是復(fù)雜的單元測試,并引進昂貴的復(fù)雜性,以及設(shè)計拙劣的,不可測試的代碼。我們將討論什么使得代碼難以測試,應(yīng)避免哪些反模式和不良實踐以提高可測試性,以及通過編寫可測試的代碼可以帶來哪些其他好處。我們將看到編寫單元測試和生成可測試的代碼不僅是要減少測試的麻煩,還在于使代碼本身更健壯和易于維護。
圖片什么是單元測試?
本質(zhì)上,單元測試是一種我們應(yīng)用程序一小部分能夠獨立于其他部分來實例化并驗證其行為的方法。典型的單元測試包含三個階段:
首先,它初始化要測試的應(yīng)用程序的一小部分(也稱為被測系統(tǒng),即SUT),然后對被測系統(tǒng)施加一些刺激(通常通過調(diào)用方法),最后觀察結(jié)果。
如果觀察到的行為符合預(yù)期,則單元測試通過,否則,它將失敗,表明被測系統(tǒng)中的某處存在問題。這三個單元測試階段也稱為“安排”(Arrange),“行為”(Act)和“斷言”(Assest),或簡稱為AAA。
單元測試可以驗證被測系統(tǒng)的不同行為方面,但很可能會屬于以下兩類之一:
基于狀態(tài)
或
基于交互
。驗證被測系統(tǒng)產(chǎn)生正確的結(jié)果或結(jié)果狀態(tài)正確的方法稱為基于狀態(tài)的單元測試,而驗證其正確調(diào)用某些方法的方法稱為基于交互的單元測試。
作為適當(dāng)?shù)能浖卧獪y試的比喻,想象一下一個瘋狂的科學(xué)家,他想用青蛙腿,章魚觸角,鳥翅膀和狗的頭來構(gòu)建一些超自然的嵌合體[1]。(這個比喻與程序員在工作中實際所做的非常接近)。那位科學(xué)家將如何確保他挑選的每個零件(或單元)都能正常工作?好吧,比方說,他可以握住一只青蛙的腿,對其施加電刺激,并檢查肌肉是否適當(dāng)收縮。他所做的基本上與單元測試中的“Arrange-Act-Assert”步驟相同。唯一的區(qū)別是,在這種情況下,
單位
是指物理對象,而不是我們用來構(gòu)建程序的抽象對象。
圖片我將在本文的所有示例中使用C#,但是所描述的概念適用于所有面向?qū)ο蟮木幊陶Z言。
一個簡單的單元測試的示例可能如下所示:
[TestMethod]
public void IsPalindrome_ForPalindromeString_ReturnsTrue()
{
*// In the Arrange phase, we create and set up a system under test.**// A system under test could be a method, a single object, or a graph of connected objects.**// It is OK to have an empty Arrange phase, for example if we are testing a static method -**// in this case SUT already exists in a static form and we don't have to initialize anything explicitly.*PalindromeDetector detector = new PalindromeDetector(); *// The Act phase is where we poke the system under test, usually by invoking a method.**// If this method returns something back to us, we want to collect the result to ensure it was correct.**// Or, if method doesn't return anything, we want to check whether it produced the expected side effects.*bool isPalindrome = detector.IsPalindrome("kayak");*// The Assert phase makes our unit test pass or fail.**// Here we check that the method's behavior is consistent with expectations.*Assert.IsTrue(isPalindrome);}
單元測試與集成測試
要考慮的另一重要事項是單元測試和集成測試之間的區(qū)別。
軟件工程中的單元測試的目的是獨立于其他部分,驗證相對較小的軟件的行為。單元測試的范圍很窄,可以讓我們涵蓋所有情況,確保每個部分都能正常工作。
另一方面,集成測試表明,系統(tǒng)的不同部分可以在實際環(huán)境中協(xié)同工作。它們驗證復(fù)雜的場景(我們可以將集成測試視為用戶在系統(tǒng)中執(zhí)行某些高級操作),并且通常需要提供外部資源,例如數(shù)據(jù)庫或Web服務(wù)器。
讓我們回到我們瘋狂的科學(xué)家比喻,并假設(shè)他已經(jīng)成功地融合了嵌合體的所有部分。他想對所得生物進行集成測試,以確保它可以在不同類型的地形上行走。首先,科學(xué)家必須模擬生物行走的環(huán)境。然后,他將生物扔到那個環(huán)境中,然后用木棍戳戳它,觀察它是否按照設(shè)計行走和移動。完成測試后,這位瘋狂的科學(xué)家清理了所有分散在他可愛的實驗室中的灰塵,沙子和巖石。
圖片注意單元測試和集成測試之間的顯著區(qū)別:單元測試可驗證與環(huán)境和其他部分隔離的一小部分應(yīng)用程序的行為,并且非常易于實現(xiàn),而集成測試則涵蓋了應(yīng)用程序中不同組件之間的交互。集成測試需要接近真實的環(huán)境,需要更多的精力,包括額外的設(shè)置和拆卸階段。
單元測試和集成測試的合理組合可確保每個單元獨立于其他單元而正確運行,并且所有這些單元在集成時都能很好地發(fā)揮作用,從而使我們對整個系統(tǒng)按預(yù)期工作充滿信心。
但是,我們必須記住始終確定要執(zhí)行的測試類型:單元測試或集成測試。有時差異可能會欺騙您。如果我們認為我們正在編寫一個單元測試來驗證業(yè)務(wù)邏輯類中的一些微妙情況,并且意識到它需要存在諸如Web服務(wù)或數(shù)據(jù)庫之類的外部資源,那是不對的-
本質(zhì)上講,我們使用大錘來堅果。這意味著設(shè)計不好。
什么是好的單元測試?
在深入學(xué)習(xí)本教程的主要部分并編寫單元測試之前,讓我們快速討論一個好的單元測試的屬性。單元測試原則要求好的測試是:
?容易寫。開發(fā)人員通常編寫大量的單元測試以涵蓋應(yīng)用程序行為的不同情況和方面,因此應(yīng)該容易地編寫所有這些測試例程,而無需付出很大的努力。?可讀。單元測試的目的應(yīng)該明確。好的單元測試可以講述我們應(yīng)用程序某些行為方面的故事,因此,應(yīng)該很容易理解正在測試的場景,并且-如果測試失敗-則很容易檢測到如何解決問題。通過良好的單元測試,我們可以在不實際調(diào)試代碼的情況下修復(fù)錯誤!?可靠。僅當(dāng)被測系統(tǒng)中存在錯誤時,單元測試才會失敗。這似乎很明顯,但是即使沒有引入錯誤,程序員在測試失敗時也會遇到問題。例如,測試可能會在一次運行時通過,但在運行整個測試套件時會失敗,或者在我們的開發(fā)機器上通過而在連續(xù)集成服務(wù)器上會失敗。這些情況表明存在設(shè)計缺陷。好的單元測試應(yīng)具有可重復(fù)性,并且應(yīng)不受外界因素(例如環(huán)境或運行順序)的影響。?快速。開發(fā)人員編寫單元測試,以便他們可以重復(fù)運行它們并檢查是否未引入錯誤。如果單元測試很慢,則開發(fā)人員更有可能跳過在自己的計算機上運行它們的過程。一項緩慢的測試不會產(chǎn)生重大變化。再增加一千,我們肯定會等待一段時間。緩慢的單元測試還可能表明被測系統(tǒng)或測試本身與外部系統(tǒng)交互,從而使其與環(huán)境有關(guān)。?真正的單元,而不是集成。正如我們已經(jīng)討論過的,單元測試和集成測試具有不同的目的。單元測試和被測系統(tǒng)都不應(yīng)訪問網(wǎng)絡(luò)資源,數(shù)據(jù)庫,文件系統(tǒng)等,以消除外部因素的影響。
就是這樣,編寫單元測試沒有秘密。但是,有些技術(shù)可以使我們編寫可測試的代碼。
可測試和不可測試的代碼
某些代碼的編寫方式很難甚至不可能為它編寫好的單元測試。那么,什么使代碼難以測試?讓我們回顧一下在編寫可測試代碼時應(yīng)避免的一些反模式,代碼異味和不良做法。
使用非確定性因素“毒害”代碼庫
讓我們從一個簡單的例子開始。想象一下,我們正在編寫一個用于智能家居微控制器的程序,其中一項要求是,如果在晚上或晚上發(fā)現(xiàn)后院有運動,則自動打開后院的燈。我們從頭開始,通過實現(xiàn)一種方法來返回大約一天中的時間(“夜晚”,“早晨”,“下午”或“晚上”)的字符串表示:
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"; }本質(zhì)上,此方法讀取當(dāng)前系統(tǒng)時間,并根據(jù)該值返回結(jié)果。那么,這段代碼有什么問題呢?如果從單元測試的角度考慮它,我們將發(fā)現(xiàn)不可能為此方法編寫適當(dāng)?shù)幕跔顟B(tài)的單元測試。DateTime.Now本質(zhì)上是一個隱藏的輸入,在程序執(zhí)行期間或測試運行之間可能會更改。因此,隨后對其的調(diào)用將產(chǎn)生不同的結(jié)果。
這種不確定的行為使得無法在GetTimeOfDay()不實際更改系統(tǒng)日期和時間的情況下測試該方法的內(nèi)部邏輯。讓我們看一下如何執(zhí)行這種測試:
[TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() {try{// Setup: change system time to 6 AM...// Arrange phase is empty: testing static method, nothing to initialize// Actstring timeOfDay = GetTimeOfDay();// AssertAssert.AreEqual("Morning", timeOfDay);}finally{// Teardown: roll system time back...} }這樣的測試將違反前面討論的許多規(guī)則。編寫(由于設(shè)置和拆卸邏輯很簡單)昂貴,不可靠(例如,由于系統(tǒng)權(quán)限問題,即使被測系統(tǒng)中沒有錯誤,它也可能會失敗),并且不能保證小步快跑。最后,該測試實際上并不是單元測試,而是介于單元測試和集成測試之間的,因為它假裝測試簡單的邊緣情況,但需要以特定方式設(shè)置環(huán)境。結(jié)果不值得付出努力,是嗎?事實證明,所有這些可測試性問題都是由低質(zhì)量的GetTimeOfDay() 引起的。在當(dāng)前形式下,此方法存在幾個問題:
?它與具體的數(shù)據(jù)源緊密耦合。無法重用此方法來處理從其他來源檢索或作為參數(shù)傳遞的日期和時間。該方法僅適用于執(zhí)行代碼的特定計算機的日期和時間。緊密耦合是大多數(shù)可測試性問題的根源。?它違反了單一責(zé)任原則[2](SRP)。該方法有多個職責(zé);它消耗信息并對其進行處理。違反SRP的另一個指標(biāo)是,當(dāng)單個類或方法有多個更改原因時。從這個角度來看,GetTimeOfDay()由于內(nèi)部邏輯調(diào)整或日期和時間源應(yīng)更改,因此可以更改方法。?它取決于完成工作所需的信息。開發(fā)人員必須閱讀實際源代碼的每一行,以了解使用了哪些隱藏輸入以及它們來自何處。僅方法簽名不足以了解方法的行為。?很難預(yù)測和維護。僅僅通過讀取源代碼就無法預(yù)測依賴于可變?nèi)譅顟B(tài)的方法的行為。有必要考慮到它的當(dāng)前值,以及可能更早更改它的整個事件序列。在現(xiàn)實世界的應(yīng)用程序中,試圖解開所有這些東西變得很頭疼。
在檢查完API之后,讓我們最后對其進行修復(fù)!幸運的是,這比討論其所有缺陷要容易得多-我們只需要打破緊密相關(guān)的問題即可。
修復(fù)API:引入方法參數(shù)
修復(fù)API的最明顯,最簡單的方法是引入方法參數(shù):
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ù),而不是自己秘密地查找此信息。從單元測試的角度來看,這很棒。該方法現(xiàn)在具有確定性(即,其返回值完全取決于輸入),因此基于狀態(tài)的測試就像傳遞一些DateTime值并檢查結(jié)果一樣容易:
[TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() {// Arrange phase is empty: testing static method, nothing to initialize// Actstring timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00));// AssertAssert.AreEqual("Morning", timeOfDay); }注意,這個簡單的重構(gòu)還通過在應(yīng)該處理什么數(shù)據(jù)和應(yīng)該如何處理之間引入清晰的接縫,解決了前面討論的所有API問題(緊密耦合,違反SRP,不清楚和難以理解的API)。優(yōu)秀-該方法是可測試的,但是它的調(diào)用者呢?現(xiàn)在,調(diào)用者有責(zé)任為該GetTimeOfDay(DateTime dateTime)方法提供日期和時間,這意味著如果我們沒有引起足夠的重視,它們可能變得無法測試。讓我們看一下如何處理。
修復(fù)客戶端API:依賴注入
假設(shè)我們繼續(xù)研究智能家居系統(tǒng),并實現(xiàn)該GetTimeOfDay(DateTime dateTime)方法的以下客戶端-基于一天中的時間和運動檢測,負責(zé)打開或關(guān)閉燈的上述智能家居微控制器代碼:
public class SmartHomeController {public DateTime LastMotionTime { get; private set; }public void ActuateLights(bool motionDetected){DateTime time = DateTime.Now; // Ouch!// Update the time of last motion.if (motionDetected){LastMotionTime = time;}// If motion was detected in the evening or at night, turn the light on.string timeOfDay = GetTimeOfDay(time);if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")){BackyardLightSwitcher.Instance.TurnOn();}// If no motion is detected for one minute, or if it is morning or day, turn the light off.else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")){BackyardLightSwitcher.Instance.TurnOff();}} }我們有相同類型的隱藏DateTime.Now輸入問題-唯一的區(qū)別是它位于抽象級別的更高一點。為了解決這個問題,我們可以引入另一個參數(shù),再次委派為DateTime使用簽名的新方法的調(diào)用者提供值的責(zé)任ActuateLights(bool motionDetected, DateTime dateTime)。但是,讓我們不再采用將調(diào)用ActuateLights(bool motionDetected)方法和其客戶端保持可測試狀態(tài)的另一種方法,而不是將問題再次移到調(diào)用堆棧中的更高級別:Control Inversion[3]或IoC。控制反轉(zhuǎn)是一種用于將代碼解耦,尤其是用于單元測試的簡單但極為有用的技術(shù)。(畢竟,讓事情松耦合是能夠獨立地分析它們彼此是必不可少的。)IOC的重點是獨立決策的代碼(在做一些事情)的動作代碼(什么到有事時做)。這種技術(shù)增加了靈活性,使我們的代碼更具模塊化,并減少了組件之間的耦合。
控制反轉(zhuǎn)可以通過多種方式實現(xiàn)。讓我們看一個特定的示例- 使用構(gòu)造函數(shù)的依賴注入[4]?-以及它如何幫助構(gòu)建可測試的SmartHomeControllerAPI。
首先,讓我們創(chuàng)建一個IDateTimeProvider接口,其中包含用于獲取某些日期和時間的方法簽名:
public interface IDateTimeProvider {DateTime GetDateTime(); }然后,SmartHomeController引用一個IDateTimeProvider實現(xiàn),并將其委派給獲取日期和時間的責(zé)任:
public class SmartHomeController {private readonly IDateTimeProvider _dateTimeProvider; // Dependencypublic SmartHomeController(IDateTimeProvider dateTimeProvider){// Inject required dependency in the constructor._dateTimeProvider = dateTimeProvider;}public void ActuateLights(bool motionDetected){DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility// Remaining light control logic goes here...} }現(xiàn)在我們可以看到,為什么控制反轉(zhuǎn)是所謂的:將控制到什么機構(gòu)使用的讀取日期和時間倒轉(zhuǎn),現(xiàn)在是屬于客戶的SmartHomeController,而不是SmartHomeController自己。因此,ActuateLights(bool motionDetected)方法的執(zhí)行完全取決于可以從外部輕松管理的兩件事:motionDetected參數(shù)和IDateTimeProvider傳遞給SmartHomeController構(gòu)造函數(shù)的的具體實現(xiàn)。為什么這對于單元測試很重要?這意味著IDateTimeProvider可以在生產(chǎn)代碼和單元測試代碼中使用不同的實現(xiàn)。在生產(chǎn)環(huán)境中,將注入一些實際的實現(xiàn)(例如,讀取實際系統(tǒng)時間的實現(xiàn))。但是,在單元測試中,我們可以注入“假”實現(xiàn),該實現(xiàn)返回DateTime適合測試特定場景的恒定或預(yù)定義值。
偽造的實現(xiàn)IDateTimeProvider可能如下所示:
public class FakeDateTimeProvider : IDateTimeProvider {public DateTime ReturnValue { get; set; }public DateTime GetDateTime() { return ReturnValue; }public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }在此類的幫助下,有可能與SmartHomeController不確定因素隔離,并執(zhí)行基于狀態(tài)的單元測試。讓我們驗證一下,如果檢測到運動,則該運動的時間記錄在LastMotionTime屬性中:
[TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() {// Arrangevar controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59)));// Actcontroller.ActuateLights(true);// AssertAssert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }重構(gòu)之前無法進行這樣的測試。既然我們已經(jīng)消除了不確定性因素并驗證了基于狀態(tài)的方案,那么您認為這SmartHomeController是完全可測試的嗎?
使用副作用“毒害”代碼庫
盡管我們解決了由不確定性隱藏輸入引起的問題,并且能夠測試某些功能,但是代碼(或至少其中一些功能)仍然不可測試!
讓我們回顧一下ActuateLights(bool motionDetected)負責(zé)打開或關(guān)閉燈的方法的以下部分:
// If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) {BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) {BackyardLightSwitcher.Instance.TurnOff(); }如我們所見,SmartHomeController將打開或關(guān)閉燈光的責(zé)任委托給BackyardLightSwitcher實現(xiàn)了Singleton模式的對象。這種設(shè)計有什么問題?為了對ActuateLights(bool motionDetected)方法進行完全的單元測試,除了基于狀態(tài)的測試之外,我們還應(yīng)該執(zhí)行基于交互的測試;也就是說,我們應(yīng)該確保只有在滿足適當(dāng)條件的情況下,才能調(diào)用打開或關(guān)閉燈的方法。不幸的是,目前的設(shè)計不允許我們這樣做:在TurnOn()與TurnOff()方法BackyardLightSwitcher觸發(fā)一些狀態(tài)的變化在系統(tǒng)中,或者,換句話說,產(chǎn)生的副作用。驗證是否調(diào)用了這些方法的唯一方法是檢查它們相應(yīng)的副作用是否確實發(fā)生了,這可能會很痛苦。
確實,讓我們假設(shè)運動傳感器,后院燈籠和智能家居微控制器已連接到物聯(lián)網(wǎng)網(wǎng)絡(luò)并使用某種無線協(xié)議進行通信。在這種情況下,單元測試可以嘗試接收和分析該網(wǎng)絡(luò)流量。或者,如果硬件組件通過電線連接,則單元測試可以檢查是否已將電壓施加到適當(dāng)?shù)碾娐贰;蛘?#xff0c;畢竟,它可以使用附加的光傳感器來檢查燈光是否真正打開或關(guān)閉。
如我們所見,單元測試的副作用方法可能與不確定的單元測試一樣困難,甚至可能是不可能的。任何嘗試都會導(dǎo)致類似于我們已經(jīng)看到的問題。最終的測試將難以實施,不可靠,可能很慢并且不是真正的單元。而且,畢竟,每次我們運行測試套件時,閃光燈都會最終使我們發(fā)瘋!
同樣,所有這些可測試性問題都是由不良的API引起的,而不是由開發(fā)人員編寫單元測試的能力引起的。無論如何實現(xiàn)精確的燈光控制,SmartHomeControllerAPI都將面臨以下這些已熟悉的問題:
?它與具體實現(xiàn)緊耦合。該API依賴于的硬編碼具體實例BackyardLightSwitcher。ActuateLights(bool motionDetected)除了后院的燈以外,其他方法都無法重復(fù)使用。?它違反了單一責(zé)任原則。API發(fā)生更改的原因有兩個:首先,更改內(nèi)部邏輯(例如選擇僅在晚上而不是在晚上打開燈光);其次,如果將燈光開關(guān)機制替換為另一個,則進行更改。?它取決于其依賴性。除了深入研究源代碼之外,開發(fā)人員沒有辦法知道這SmartHomeController取決于硬編碼BackyardLightSwitcher組件。?很難理解和維護。如果在合適的條件下燈不亮怎么辦?我們可能會花很多時間嘗試解決這個問題SmartHomeController,但都沒有意識到,問題是由BackyardLightSwitcher(或什至更有趣的是,燒壞的燈泡!)中的錯誤引起的。
毫無疑問,可測試性和低質(zhì)量API問題的解決方案是使緊密耦合的組件彼此斷開。與前面的示例一樣,使用依賴注入將解決這些問題。只需將ILightSwitcher依賴項添加到SmartHomeController,將翻轉(zhuǎn)電燈開關(guān)的職責(zé)委托給它,然后傳遞一個假的,僅測試的ILightSwitcher實現(xiàn),該實現(xiàn)將記錄是否在正確的條件下調(diào)用了適當(dāng)?shù)姆椒ā5?#xff0c;讓我們回顧一下一種有趣的替代方法來分離責(zé)任,而不是再次使用“依賴注入”。
修復(fù)API:高階函數(shù)
在支持一流功能的任何面向?qū)ο蟮恼Z言中,此方法都是一種選擇。讓我們利用C#的功能特性,并使該ActuateLights(bool motionDetected)方法接受更多兩個參數(shù):一對Action委托,指向應(yīng)調(diào)用以打開和關(guān)閉燈的方法。此解決方案會將方法轉(zhuǎn)換為高階函數(shù):
public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) {DateTime time = _dateTimeProvider.GetDateTime();// Update the time of last motion.if (motionDetected){LastMotionTime = time;}// If motion was detected in the evening or at night, turn the light on.string timeOfDay = GetTimeOfDay(time);if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")){turnOn(); // Invoking a delegate: no tight coupling anymore}// If no motion is detected for one minute, or if it is morning or day, turn the light off.else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")){turnOff(); // Invoking a delegate: no tight coupling anymore} }與我們之前看到的經(jīng)典的面向?qū)ο蟮囊蕾囎⑷敕椒ㄏ啾?#xff0c;這是一種更具功能風(fēng)格的解決方案。但是,與依賴注入相比,它使我們可以用更少的代碼和更多的表現(xiàn)力來達到相同的結(jié)果。為了提供SmartHomeController所需的功能,不再需要實現(xiàn)符合接口的類。相反,我們可以傳遞一個函數(shù)定義。高階函數(shù)可以被視為實現(xiàn)控制反轉(zhuǎn)的另一種方式。現(xiàn)在,要對結(jié)果方法執(zhí)行基于交互的單元測試,我們可以將易于驗證的偽造操作傳遞給它:
[TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() {// Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off.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)));// Actcontroller.ActuateLights(true, turnOn, turnOff);// AssertAssert.IsTrue(turnedOn); }最后,我們已經(jīng)使該SmartHomeControllerAPI完全可測試,并且能夠?qū)ζ鋱?zhí)行基于狀態(tài)和基于交互的單元測試。同樣,請注意,除了提高可測試性之外,在決策和操作代碼之間引入接縫還有助于解決緊密耦合問題,并導(dǎo)致了更干凈,可重用的API。現(xiàn)在,為了實現(xiàn)完整的單元測試覆蓋范圍,我們可以簡單地實現(xiàn)一堆外觀相似的測試來驗證所有可能的情況,這沒什么大不了的,因為單元測試現(xiàn)在很容易實現(xiàn)。
雜質(zhì)和可測性
不受控制的不確定性和副作用在代碼庫上的破壞性作用相似。如果使用不當(dāng),它們會導(dǎo)致欺騙性,難以理解和維護的代碼,緊密耦合,不可重用和不可測試的代碼。
不受控制的不確定性和副作用在代碼庫上的破壞性作用相似。如果使用不當(dāng),它們會導(dǎo)致欺騙性,難以理解和維護的代碼,緊密耦合,不可重用和不可測試的代碼。
另一方面,確定性和無副作用的方法更容易測試,推理和重用以構(gòu)建更大的程序。就函數(shù)式編程而言,此類方法稱為純函數(shù)。我們很少會在單元測試純函數(shù)時遇到問題。我們要做的就是傳遞一些參數(shù)并檢查結(jié)果是否正確。真正使代碼不可測試的是硬編碼的,不純凈的因素,這些因素?zé)o法以其他方式替換,覆蓋或抽象化。
雜質(zhì)是有毒的:如果方法Foo()依賴于非確定性或副作用方法Bar(),則Foo()也會變?yōu)椴淮_定性或副作用。最終,我們可能最終使整個代碼庫中毒。將所有這些問題乘以一個復(fù)雜的實際應(yīng)用程序的大小,我們將發(fā)現(xiàn)自己難以維護代碼庫,其中充滿了氣味,反模式,秘密依賴性以及各種丑陋和令人不快的事情。
圖片但是,雜質(zhì)是不可避免的。任何現(xiàn)實生活中的應(yīng)用程序都必須在某個時候通過與環(huán)境,數(shù)據(jù)庫,配置文件,Web服務(wù)或其他外部系統(tǒng)進行交互來讀取和操縱狀態(tài)。因此,與其著眼于完全消除雜質(zhì),不如限制這些因素,避免讓它們污染您的代碼庫,并盡可能地破壞硬編碼的依賴關(guān)系,以便能夠獨立地分析和測試單元。
難以測試的常見警告標(biāo)志
編寫測試麻煩嗎?問題不在您的測試套件中。它在您的代碼中。
最后,讓我們回顧一些常見的警告標(biāo)志,這些警告標(biāo)志表明我們的代碼可能難以測試。
靜態(tài)屬性和字段
靜態(tài)屬性和字段,或者簡而言之,全局狀態(tài),可以通過隱藏方法來完成其工作所需的信息,引入不確定性或促進副作用的廣泛使用,從而使代碼的理解和可測試性復(fù)雜化。讀取或修改可變?nèi)譅顟B(tài)的函數(shù)本質(zhì)上是不純的。
例如,很難推理以下代碼,這取決于全局可訪問的屬性:
如果確定該HeatWater()方法沒有被調(diào)用該怎么辦?由于應(yīng)用程序的任何部分都可能更改了該CostSavingEnabled值,因此我們必須查找并分析所有修改該值的位置,以便找出問題所在。另外,正如我們已經(jīng)看到的那樣,無法出于測試目的設(shè)置一些靜態(tài)屬性(例如DateTime.Now,或Environment.MachineName;它們是只讀的,但仍不確定)。
另一方面,不變
且
確定的全局狀態(tài)完全可以。實際上,對此有一個更熟悉的名稱-常量。常量值Math.PI不會引入任何不確定性,并且由于不能更改其值,因此不會產(chǎn)生任何副作用:
double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!Singletons
本質(zhì)上,單例模式只是全局狀態(tài)的另一種形式。單例促進了模糊的API,這些API依賴于真實的依賴關(guān)系,并在組件之間引入了不必要的緊密耦合。他們還違反了“單一責(zé)任原則”,因為除了主要職責(zé)外,他們還控制著自己的初始化和生命周期。
單例可以輕松使單元測試與訂單相關(guān),因為它們會在整個應(yīng)用程序或單元測試套件的整個生命周期內(nèi)保持狀態(tài)不變。看下面的例子:
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; }在上面的示例中,如果首先運行針對緩存命中方案的測試,則它將向緩存中添加新用戶,因此對緩存未命中方案的后續(xù)測試可能會失敗,因為它假定緩存為空。為了克服這個問題,我們將不得不編寫額外的拆卸代碼,以UserCache在每次單元測試運行之后清理掉。在大多數(shù)情況下,可以(并且應(yīng)該)避免使用Singletons。但是,區(qū)分作為設(shè)計模式的Singleton和對象的單個實例非常重要。在后一種情況下,創(chuàng)建和維護單個實例的責(zé)任在于應(yīng)用程序本身。通常,這是通過工廠或依賴項注入容器處理的,該容器在應(yīng)用程序“頂部”附近(即,更靠近應(yīng)用程序入口點)的某個地方創(chuàng)建一個實例,然后將其傳遞給需要它的每個對象。從可測試性和API質(zhì)量的角度來看,這種方法是絕對正確的。
new 操作符
為了完成一些工作而更新對象的實例會帶來與Singleton反模式相同的問題:含隱藏依賴項,緊密耦合和可測試性差的API。
例如,為了測試返回404狀態(tài)代碼時以下循環(huán)是否停止,開發(fā)人員應(yīng)設(shè)置一個測試Web服務(wù)器:
using (var client = new HttpClient()) {HttpResponseMessage response;do{response = await client.GetAsync(uri);// Process the response and update the uri...} while (response.StatusCode != HttpStatusCode.NotFound); }但是,有時new并非有害:例如,可以創(chuàng)建簡單的實體對象:var person = new Person("John", "Doe", new DateTime(1970, 12, 31));
還可以創(chuàng)建一個不會產(chǎn)生任何副作用的小型臨時對象,除非修改其自身的狀態(tài),然后根據(jù)該狀態(tài)返回結(jié)果。在以下示例中,我們不在乎是否Stack調(diào)用了方法—我們只是檢查最終結(jié)果是否正確:
string ReverseString(string input) {// No need to do interaction-based testing and check that Stack methods were called or not;// The unit test just needs to ensure that the return value is correct (state-based testing).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)方法是不確定性或副作用行為的另一個潛在來源。它們可以輕松引入緊密耦合,并使我們的代碼不可測試。
例如,要驗證以下方法的行為,單元測試必須操縱環(huán)境變量并讀取控制臺輸出流以確保已打印適當(dāng)?shù)臄?shù)據(jù):
void CheckPathEnvironmentVariable() {if (Environment.GetEnvironmentVariable("PATH") != null){Console.WriteLine("PATH environment variable exists.");}else{Console.WriteLine("PATH environment variable is not defined.");} }但是,純靜態(tài)函數(shù)是可以的:它們的任何組合仍將是純函數(shù)。例如:
double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }單元測試的好處 顯然,編寫可測試的代碼需要一定的紀律,專心和額外的精力。但是無論如何,軟件開發(fā)都是一項復(fù)雜的思維活動,我們應(yīng)該始終謹慎,避免魯莽的把新代碼堆在舊代碼上面。
作為對軟件質(zhì)量保證措施的一個重要組成,我們最終將獲得干凈,易于維護,松耦合和可重用的API,這些API不會損害開發(fā)人員的理解能力。畢竟,可測試代碼的最終優(yōu)勢不僅在于可測試性本身,還在于創(chuàng)造易于理解的代碼,提供維護和擴展該代碼的能力。
References
[1]?嵌合體:?https://en.wikipedia.org/wiki/Chimera_(mythology)
[2]?單一責(zé)任原則:?https://en.wikipedia.org/wiki/Single_responsibility_principle
[3]?Control Inversion:?https://en.wikipedia.org/wiki/Inversion_of_control
[4]?依賴注入:?https://en.wikipedia.org/wiki/Dependency_injection
總結(jié)
以上是生活随笔為你收集整理的单元测试:如何编写可测试的代码及其重要性的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 一切都要从华为云 CloudIDE 酷似
- 下一篇: IdentityServer 部署踩坑记