单元测试:如何编写可测试的代码及其重要性
原文來自互聯網,由長沙DotNET技術社區編譯。如譯文侵犯您的署名權或版權,請聯系小編,小編將在24小時內刪除。限于譯者的能力有限,個別語句翻譯略顯生硬,還請見諒。
作者:謝爾蓋·科洛迪(SERGEY KOLODIY)
原文:https://www.toptal.com/qa/how-to-write-testable-code-and-why-it-matters
單元測試是任何專業軟件開發人員工具箱中必不可少的工具,但是,有時很難為特定的代碼編寫好的單元測試。開發人員在測試自己或他人的代碼時遇到困難,常常認為自己的努力是由于缺乏一些基本的測試知識或以為單元測試存在某些獨門絕技而引起的。
在本單元測試教程中,我打算證明單元測試非常簡單。真正的問題是復雜的單元測試,并引進昂貴的復雜性,以及設計拙劣的,不可測試的代碼。我們將討論什么使得代碼難以測試,應避免哪些反模式和不良實踐以提高可測試性,以及通過編寫可測試的代碼可以帶來哪些其他好處。我們將看到編寫單元測試和生成可測試的代碼不僅是要減少測試的麻煩,還在于使代碼本身更健壯和易于維護。
圖片什么是單元測試?
本質上,單元測試是一種我們應用程序一小部分能夠獨立于其他部分來實例化并驗證其行為的方法。典型的單元測試包含三個階段:
首先,它初始化要測試的應用程序的一小部分(也稱為被測系統,即SUT),然后對被測系統施加一些刺激(通常通過調用方法),最后觀察結果。
如果觀察到的行為符合預期,則單元測試通過,否則,它將失敗,表明被測系統中的某處存在問題。這三個單元測試階段也稱為“安排”(Arrange),“行為”(Act)和“斷言”(Assest),或簡稱為AAA。
單元測試可以驗證被測系統的不同行為方面,但很可能會屬于以下兩類之一:
基于狀態
或
基于交互
。驗證被測系統產生正確的結果或結果狀態正確的方法稱為基于狀態的單元測試,而驗證其正確調用某些方法的方法稱為基于交互的單元測試。
作為適當的軟件單元測試的比喻,想象一下一個瘋狂的科學家,他想用青蛙腿,章魚觸角,鳥翅膀和狗的頭來構建一些超自然的嵌合體[1]。(這個比喻與程序員在工作中實際所做的非常接近)。那位科學家將如何確保他挑選的每個零件(或單元)都能正常工作?好吧,比方說,他可以握住一只青蛙的腿,對其施加電刺激,并檢查肌肉是否適當收縮。他所做的基本上與單元測試中的“Arrange-Act-Assert”步驟相同。唯一的區別是,在這種情況下,
單位
是指物理對象,而不是我們用來構建程序的抽象對象。
圖片我將在本文的所有示例中使用C#,但是所描述的概念適用于所有面向對象的編程語言。
一個簡單的單元測試的示例可能如下所示:
[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);}
單元測試與集成測試
要考慮的另一重要事項是單元測試和集成測試之間的區別。
軟件工程中的單元測試的目的是獨立于其他部分,驗證相對較小的軟件的行為。單元測試的范圍很窄,可以讓我們涵蓋所有情況,確保每個部分都能正常工作。
另一方面,集成測試表明,系統的不同部分可以在實際環境中協同工作。它們驗證復雜的場景(我們可以將集成測試視為用戶在系統中執行某些高級操作),并且通常需要提供外部資源,例如數據庫或Web服務器。
讓我們回到我們瘋狂的科學家比喻,并假設他已經成功地融合了嵌合體的所有部分。他想對所得生物進行集成測試,以確保它可以在不同類型的地形上行走。首先,科學家必須模擬生物行走的環境。然后,他將生物扔到那個環境中,然后用木棍戳戳它,觀察它是否按照設計行走和移動。完成測試后,這位瘋狂的科學家清理了所有分散在他可愛的實驗室中的灰塵,沙子和巖石。
圖片注意單元測試和集成測試之間的顯著區別:單元測試可驗證與環境和其他部分隔離的一小部分應用程序的行為,并且非常易于實現,而集成測試則涵蓋了應用程序中不同組件之間的交互。集成測試需要接近真實的環境,需要更多的精力,包括額外的設置和拆卸階段。
單元測試和集成測試的合理組合可確保每個單元獨立于其他單元而正確運行,并且所有這些單元在集成時都能很好地發揮作用,從而使我們對整個系統按預期工作充滿信心。
但是,我們必須記住始終確定要執行的測試類型:單元測試或集成測試。有時差異可能會欺騙您。如果我們認為我們正在編寫一個單元測試來驗證業務邏輯類中的一些微妙情況,并且意識到它需要存在諸如Web服務或數據庫之類的外部資源,那是不對的-
本質上講,我們使用大錘來堅果。這意味著設計不好。
什么是好的單元測試?
在深入學習本教程的主要部分并編寫單元測試之前,讓我們快速討論一個好的單元測試的屬性。單元測試原則要求好的測試是:
?容易寫。開發人員通常編寫大量的單元測試以涵蓋應用程序行為的不同情況和方面,因此應該容易地編寫所有這些測試例程,而無需付出很大的努力。?可讀。單元測試的目的應該明確。好的單元測試可以講述我們應用程序某些行為方面的故事,因此,應該很容易理解正在測試的場景,并且-如果測試失敗-則很容易檢測到如何解決問題。通過良好的單元測試,我們可以在不實際調試代碼的情況下修復錯誤!?可靠。僅當被測系統中存在錯誤時,單元測試才會失敗。這似乎很明顯,但是即使沒有引入錯誤,程序員在測試失敗時也會遇到問題。例如,測試可能會在一次運行時通過,但在運行整個測試套件時會失敗,或者在我們的開發機器上通過而在連續集成服務器上會失敗。這些情況表明存在設計缺陷。好的單元測試應具有可重復性,并且應不受外界因素(例如環境或運行順序)的影響。?快速。開發人員編寫單元測試,以便他們可以重復運行它們并檢查是否未引入錯誤。如果單元測試很慢,則開發人員更有可能跳過在自己的計算機上運行它們的過程。一項緩慢的測試不會產生重大變化。再增加一千,我們肯定會等待一段時間。緩慢的單元測試還可能表明被測系統或測試本身與外部系統交互,從而使其與環境有關。?真正的單元,而不是集成。正如我們已經討論過的,單元測試和集成測試具有不同的目的。單元測試和被測系統都不應訪問網絡資源,數據庫,文件系統等,以消除外部因素的影響。
就是這樣,編寫單元測試沒有秘密。但是,有些技術可以使我們編寫可測試的代碼。
可測試和不可測試的代碼
某些代碼的編寫方式很難甚至不可能為它編寫好的單元測試。那么,什么使代碼難以測試?讓我們回顧一下在編寫可測試代碼時應避免的一些反模式,代碼異味和不良做法。
使用非確定性因素“毒害”代碼庫
讓我們從一個簡單的例子開始。想象一下,我們正在編寫一個用于智能家居微控制器的程序,其中一項要求是,如果在晚上或晚上發現后院有運動,則自動打開后院的燈。我們從頭開始,通過實現一種方法來返回大約一天中的時間(“夜晚”,“早晨”,“下午”或“晚上”)的字符串表示:
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"; }本質上,此方法讀取當前系統時間,并根據該值返回結果。那么,這段代碼有什么問題呢?如果從單元測試的角度考慮它,我們將發現不可能為此方法編寫適當的基于狀態的單元測試。DateTime.Now本質上是一個隱藏的輸入,在程序執行期間或測試運行之間可能會更改。因此,隨后對其的調用將產生不同的結果。
這種不確定的行為使得無法在GetTimeOfDay()不實際更改系統日期和時間的情況下測試該方法的內部邏輯。讓我們看一下如何執行這種測試:
[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...} }這樣的測試將違反前面討論的許多規則。編寫(由于設置和拆卸邏輯很簡單)昂貴,不可靠(例如,由于系統權限問題,即使被測系統中沒有錯誤,它也可能會失敗),并且不能保證小步快跑。最后,該測試實際上并不是單元測試,而是介于單元測試和集成測試之間的,因為它假裝測試簡單的邊緣情況,但需要以特定方式設置環境。結果不值得付出努力,是嗎?事實證明,所有這些可測試性問題都是由低質量的GetTimeOfDay() 引起的。在當前形式下,此方法存在幾個問題:
?它與具體的數據源緊密耦合。無法重用此方法來處理從其他來源檢索或作為參數傳遞的日期和時間。該方法僅適用于執行代碼的特定計算機的日期和時間。緊密耦合是大多數可測試性問題的根源。?它違反了單一責任原則[2](SRP)。該方法有多個職責;它消耗信息并對其進行處理。違反SRP的另一個指標是,當單個類或方法有多個更改原因時。從這個角度來看,GetTimeOfDay()由于內部邏輯調整或日期和時間源應更改,因此可以更改方法。?它取決于完成工作所需的信息。開發人員必須閱讀實際源代碼的每一行,以了解使用了哪些隱藏輸入以及它們來自何處。僅方法簽名不足以了解方法的行為。?很難預測和維護。僅僅通過讀取源代碼就無法預測依賴于可變全局狀態的方法的行為。有必要考慮到它的當前值,以及可能更早更改它的整個事件序列。在現實世界的應用程序中,試圖解開所有這些東西變得很頭疼。
在檢查完API之后,讓我們最后對其進行修復!幸運的是,這比討論其所有缺陷要容易得多-我們只需要打破緊密相關的問題即可。
修復API:引入方法參數
修復API的最明顯,最簡單的方法是引入方法參數:
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"; }現在,該方法要求調用者提供一個DateTime參數,而不是自己秘密地查找此信息。從單元測試的角度來看,這很棒。該方法現在具有確定性(即,其返回值完全取決于輸入),因此基于狀態的測試就像傳遞一些DateTime值并檢查結果一樣容易:
[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); }注意,這個簡單的重構還通過在應該處理什么數據和應該如何處理之間引入清晰的接縫,解決了前面討論的所有API問題(緊密耦合,違反SRP,不清楚和難以理解的API)。優秀-該方法是可測試的,但是它的調用者呢?現在,調用者有責任為該GetTimeOfDay(DateTime dateTime)方法提供日期和時間,這意味著如果我們沒有引起足夠的重視,它們可能變得無法測試。讓我們看一下如何處理。
修復客戶端API:依賴注入
假設我們繼續研究智能家居系統,并實現該GetTimeOfDay(DateTime dateTime)方法的以下客戶端-基于一天中的時間和運動檢測,負責打開或關閉燈的上述智能家居微控制器代碼:
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輸入問題-唯一的區別是它位于抽象級別的更高一點。為了解決這個問題,我們可以引入另一個參數,再次委派為DateTime使用簽名的新方法的調用者提供值的責任ActuateLights(bool motionDetected, DateTime dateTime)。但是,讓我們不再采用將調用ActuateLights(bool motionDetected)方法和其客戶端保持可測試狀態的另一種方法,而不是將問題再次移到調用堆棧中的更高級別:Control Inversion[3]或IoC。控制反轉是一種用于將代碼解耦,尤其是用于單元測試的簡單但極為有用的技術。(畢竟,讓事情松耦合是能夠獨立地分析它們彼此是必不可少的。)IOC的重點是獨立決策的代碼(在做一些事情)的動作代碼(什么到有事時做)。這種技術增加了靈活性,使我們的代碼更具模塊化,并減少了組件之間的耦合。
控制反轉可以通過多種方式實現。讓我們看一個特定的示例- 使用構造函數的依賴注入[4]?-以及它如何幫助構建可測試的SmartHomeControllerAPI。
首先,讓我們創建一個IDateTimeProvider接口,其中包含用于獲取某些日期和時間的方法簽名:
public interface IDateTimeProvider {DateTime GetDateTime(); }然后,SmartHomeController引用一個IDateTimeProvider實現,并將其委派給獲取日期和時間的責任:
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...} }現在我們可以看到,為什么控制反轉是所謂的:將控制到什么機構使用的讀取日期和時間倒轉,現在是屬于客戶的SmartHomeController,而不是SmartHomeController自己。因此,ActuateLights(bool motionDetected)方法的執行完全取決于可以從外部輕松管理的兩件事:motionDetected參數和IDateTimeProvider傳遞給SmartHomeController構造函數的的具體實現。為什么這對于單元測試很重要?這意味著IDateTimeProvider可以在生產代碼和單元測試代碼中使用不同的實現。在生產環境中,將注入一些實際的實現(例如,讀取實際系統時間的實現)。但是,在單元測試中,我們可以注入“假”實現,該實現返回DateTime適合測試特定場景的恒定或預定義值。
偽造的實現IDateTimeProvider可能如下所示:
public class FakeDateTimeProvider : IDateTimeProvider {public DateTime ReturnValue { get; set; }public DateTime GetDateTime() { return ReturnValue; }public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }在此類的幫助下,有可能與SmartHomeController不確定因素隔離,并執行基于狀態的單元測試。讓我們驗證一下,如果檢測到運動,則該運動的時間記錄在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); }重構之前無法進行這樣的測試。既然我們已經消除了不確定性因素并驗證了基于狀態的方案,那么您認為這SmartHomeController是完全可測試的嗎?
使用副作用“毒害”代碼庫
盡管我們解決了由不確定性隱藏輸入引起的問題,并且能夠測試某些功能,但是代碼(或至少其中一些功能)仍然不可測試!
讓我們回顧一下ActuateLights(bool motionDetected)負責打開或關閉燈的方法的以下部分:
// 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將打開或關閉燈光的責任委托給BackyardLightSwitcher實現了Singleton模式的對象。這種設計有什么問題?為了對ActuateLights(bool motionDetected)方法進行完全的單元測試,除了基于狀態的測試之外,我們還應該執行基于交互的測試;也就是說,我們應該確保只有在滿足適當條件的情況下,才能調用打開或關閉燈的方法。不幸的是,目前的設計不允許我們這樣做:在TurnOn()與TurnOff()方法BackyardLightSwitcher觸發一些狀態的變化在系統中,或者,換句話說,產生的副作用。驗證是否調用了這些方法的唯一方法是檢查它們相應的副作用是否確實發生了,這可能會很痛苦。
確實,讓我們假設運動傳感器,后院燈籠和智能家居微控制器已連接到物聯網網絡并使用某種無線協議進行通信。在這種情況下,單元測試可以嘗試接收和分析該網絡流量。或者,如果硬件組件通過電線連接,則單元測試可以檢查是否已將電壓施加到適當的電路。或者,畢竟,它可以使用附加的光傳感器來檢查燈光是否真正打開或關閉。
如我們所見,單元測試的副作用方法可能與不確定的單元測試一樣困難,甚至可能是不可能的。任何嘗試都會導致類似于我們已經看到的問題。最終的測試將難以實施,不可靠,可能很慢并且不是真正的單元。而且,畢竟,每次我們運行測試套件時,閃光燈都會最終使我們發瘋!
同樣,所有這些可測試性問題都是由不良的API引起的,而不是由開發人員編寫單元測試的能力引起的。無論如何實現精確的燈光控制,SmartHomeControllerAPI都將面臨以下這些已熟悉的問題:
?它與具體實現緊耦合。該API依賴于的硬編碼具體實例BackyardLightSwitcher。ActuateLights(bool motionDetected)除了后院的燈以外,其他方法都無法重復使用。?它違反了單一責任原則。API發生更改的原因有兩個:首先,更改內部邏輯(例如選擇僅在晚上而不是在晚上打開燈光);其次,如果將燈光開關機制替換為另一個,則進行更改。?它取決于其依賴性。除了深入研究源代碼之外,開發人員沒有辦法知道這SmartHomeController取決于硬編碼BackyardLightSwitcher組件。?很難理解和維護。如果在合適的條件下燈不亮怎么辦?我們可能會花很多時間嘗試解決這個問題SmartHomeController,但都沒有意識到,問題是由BackyardLightSwitcher(或什至更有趣的是,燒壞的燈泡!)中的錯誤引起的。
毫無疑問,可測試性和低質量API問題的解決方案是使緊密耦合的組件彼此斷開。與前面的示例一樣,使用依賴注入將解決這些問題。只需將ILightSwitcher依賴項添加到SmartHomeController,將翻轉電燈開關的職責委托給它,然后傳遞一個假的,僅測試的ILightSwitcher實現,該實現將記錄是否在正確的條件下調用了適當的方法。但是,讓我們回顧一下一種有趣的替代方法來分離責任,而不是再次使用“依賴注入”。
修復API:高階函數
在支持一流功能的任何面向對象的語言中,此方法都是一種選擇。讓我們利用C#的功能特性,并使該ActuateLights(bool motionDetected)方法接受更多兩個參數:一對Action委托,指向應調用以打開和關閉燈的方法。此解決方案會將方法轉換為高階函數:
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} }與我們之前看到的經典的面向對象的依賴注入方法相比,這是一種更具功能風格的解決方案。但是,與依賴注入相比,它使我們可以用更少的代碼和更多的表現力來達到相同的結果。為了提供SmartHomeController所需的功能,不再需要實現符合接口的類。相反,我們可以傳遞一個函數定義。高階函數可以被視為實現控制反轉的另一種方式。現在,要對結果方法執行基于交互的單元測試,我們可以將易于驗證的偽造操作傳遞給它:
[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); }最后,我們已經使該SmartHomeControllerAPI完全可測試,并且能夠對其執行基于狀態和基于交互的單元測試。同樣,請注意,除了提高可測試性之外,在決策和操作代碼之間引入接縫還有助于解決緊密耦合問題,并導致了更干凈,可重用的API。現在,為了實現完整的單元測試覆蓋范圍,我們可以簡單地實現一堆外觀相似的測試來驗證所有可能的情況,這沒什么大不了的,因為單元測試現在很容易實現。
雜質和可測性
不受控制的不確定性和副作用在代碼庫上的破壞性作用相似。如果使用不當,它們會導致欺騙性,難以理解和維護的代碼,緊密耦合,不可重用和不可測試的代碼。
不受控制的不確定性和副作用在代碼庫上的破壞性作用相似。如果使用不當,它們會導致欺騙性,難以理解和維護的代碼,緊密耦合,不可重用和不可測試的代碼。
另一方面,確定性和無副作用的方法更容易測試,推理和重用以構建更大的程序。就函數式編程而言,此類方法稱為純函數。我們很少會在單元測試純函數時遇到問題。我們要做的就是傳遞一些參數并檢查結果是否正確。真正使代碼不可測試的是硬編碼的,不純凈的因素,這些因素無法以其他方式替換,覆蓋或抽象化。
雜質是有毒的:如果方法Foo()依賴于非確定性或副作用方法Bar(),則Foo()也會變為不確定性或副作用。最終,我們可能最終使整個代碼庫中毒。將所有這些問題乘以一個復雜的實際應用程序的大小,我們將發現自己難以維護代碼庫,其中充滿了氣味,反模式,秘密依賴性以及各種丑陋和令人不快的事情。
圖片但是,雜質是不可避免的。任何現實生活中的應用程序都必須在某個時候通過與環境,數據庫,配置文件,Web服務或其他外部系統進行交互來讀取和操縱狀態。因此,與其著眼于完全消除雜質,不如限制這些因素,避免讓它們污染您的代碼庫,并盡可能地破壞硬編碼的依賴關系,以便能夠獨立地分析和測試單元。
難以測試的常見警告標志
編寫測試麻煩嗎?問題不在您的測試套件中。它在您的代碼中。
最后,讓我們回顧一些常見的警告標志,這些警告標志表明我們的代碼可能難以測試。
靜態屬性和字段
靜態屬性和字段,或者簡而言之,全局狀態,可以通過隱藏方法來完成其工作所需的信息,引入不確定性或促進副作用的廣泛使用,從而使代碼的理解和可測試性復雜化。讀取或修改可變全局狀態的函數本質上是不純的。
例如,很難推理以下代碼,這取決于全局可訪問的屬性:
如果確定該HeatWater()方法沒有被調用該怎么辦?由于應用程序的任何部分都可能更改了該CostSavingEnabled值,因此我們必須查找并分析所有修改該值的位置,以便找出問題所在。另外,正如我們已經看到的那樣,無法出于測試目的設置一些靜態屬性(例如DateTime.Now,或Environment.MachineName;它們是只讀的,但仍不確定)。
另一方面,不變
且
確定的全局狀態完全可以。實際上,對此有一個更熟悉的名稱-常量。常量值Math.PI不會引入任何不確定性,并且由于不能更改其值,因此不會產生任何副作用:
double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!Singletons
本質上,單例模式只是全局狀態的另一種形式。單例促進了模糊的API,這些API依賴于真實的依賴關系,并在組件之間引入了不必要的緊密耦合。他們還違反了“單一責任原則”,因為除了主要職責外,他們還控制著自己的初始化和生命周期。
單例可以輕松使單元測試與訂單相關,因為它們會在整個應用程序或單元測試套件的整個生命周期內保持狀態不變。看下面的例子:
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; }在上面的示例中,如果首先運行針對緩存命中方案的測試,則它將向緩存中添加新用戶,因此對緩存未命中方案的后續測試可能會失敗,因為它假定緩存為空。為了克服這個問題,我們將不得不編寫額外的拆卸代碼,以UserCache在每次單元測試運行之后清理掉。在大多數情況下,可以(并且應該)避免使用Singletons。但是,區分作為設計模式的Singleton和對象的單個實例非常重要。在后一種情況下,創建和維護單個實例的責任在于應用程序本身。通常,這是通過工廠或依賴項注入容器處理的,該容器在應用程序“頂部”附近(即,更靠近應用程序入口點)的某個地方創建一個實例,然后將其傳遞給需要它的每個對象。從可測試性和API質量的角度來看,這種方法是絕對正確的。
new 操作符
為了完成一些工作而更新對象的實例會帶來與Singleton反模式相同的問題:含隱藏依賴項,緊密耦合和可測試性差的API。
例如,為了測試返回404狀態代碼時以下循環是否停止,開發人員應設置一個測試Web服務器:
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并非有害:例如,可以創建簡單的實體對象:var person = new Person("John", "Doe", new DateTime(1970, 12, 31));
還可以創建一個不會產生任何副作用的小型臨時對象,除非修改其自身的狀態,然后根據該狀態返回結果。在以下示例中,我們不在乎是否Stack調用了方法—我們只是檢查最終結果是否正確:
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; }靜態方法
靜態方法是不確定性或副作用行為的另一個潛在來源。它們可以輕松引入緊密耦合,并使我們的代碼不可測試。
例如,要驗證以下方法的行為,單元測試必須操縱環境變量并讀取控制臺輸出流以確保已打印適當的數據:
void CheckPathEnvironmentVariable() {if (Environment.GetEnvironmentVariable("PATH") != null){Console.WriteLine("PATH environment variable exists.");}else{Console.WriteLine("PATH environment variable is not defined.");} }但是,純靜態函數是可以的:它們的任何組合仍將是純函數。例如:
double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }單元測試的好處 顯然,編寫可測試的代碼需要一定的紀律,專心和額外的精力。但是無論如何,軟件開發都是一項復雜的思維活動,我們應該始終謹慎,避免魯莽的把新代碼堆在舊代碼上面。
作為對軟件質量保證措施的一個重要組成,我們最終將獲得干凈,易于維護,松耦合和可重用的API,這些API不會損害開發人員的理解能力。畢竟,可測試代碼的最終優勢不僅在于可測試性本身,還在于創造易于理解的代碼,提供維護和擴展該代碼的能力。
References
[1]?嵌合體:?https://en.wikipedia.org/wiki/Chimera_(mythology)
[2]?單一責任原則:?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
總結
以上是生活随笔為你收集整理的单元测试:如何编写可测试的代码及其重要性的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 一切都要从华为云 CloudIDE 酷似
- 下一篇: IdentityServer 部署踩坑记