單元測試,如何編寫可測試代碼及其重要性

已發表: 2022-03-11

單元測試是任何認真的軟件開發人員工具箱中必不可少的工具。 但是,有時很難為特定的代碼段編寫好的單元測試。 由於難以測試自己或他人的代碼,開發人員通常認為他們的困難是由於缺乏一些基本的測試知識或秘密的單元測試技術。

在本單元測試教程中,我打算證明單元測試非常簡單; 使單元測試複雜化並引入昂貴的複雜性的真正問題是設計不良、無法測試的代碼的結果。 我們將討論是什麼使代碼難以測試,我們應該避免哪些反模式和不良實踐以提高可測試性,以及通過編寫可測試代碼可以獲得哪些其他好處。 我們將看到編寫單元測試和生成可測試的代碼不僅僅是為了減少測試的麻煩,而是為了讓代碼本身更健壯,更容易維護。

單元測試教程:封面圖

什麼是單元測試?

本質上,單元測試是一種實例化我們應用程序的一小部分並獨立於其他部分驗證其行為的方法。 一個典型的單元測試包含 3 個階段:首先,它初始化它想要測試的應用程序的一小部分(也稱為被測系統,或 SUT),然後它對被測系統施加一些刺激(通常通過調用方法),最後,它觀察結果行為。 如果觀察到的行為與預期一致,則單元測試通過,否則,單元測試失敗,表明被測系統某處存在問題。 這三個單元測試階段也稱為 Arrange、Act 和 Assert,或簡稱為 AAA。

單元測試可以驗證被測系統的不同行為方面,但它很可能屬於以下兩類之一:基於狀態的或基於交互的。 驗證被測系統是否產生正確的結果,或者其結果狀態是否正確,稱為基於狀態的單元測試,而驗證它是否正確調用某些方法稱為基於交互的單元測試。

作為適當的軟件單元測試的隱喻,想像一個瘋狂的科學家想要構建一些超自然的嵌合體,有青蛙腿、章魚觸手、鳥翅膀和狗頭。 (這個比喻非常接近程序員在工作中的實際工作)。 那位科學家如何確保他挑選的每個零件(或單元)都真正起作用? 好吧,比方說,他可以拿一隻青蛙的腿,對其施加電刺激,然後檢查肌肉是否收縮。 他所做的基本上與單元測試的 Arrange-Act-Assert 步驟相同; 唯一的區別是,在這種情況下, unit指的是一個物理對象,而不是我們構建程序的抽像對象。

什麼是單元測試:插圖

我將在本文中的所有示例中使用 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 服務或數據庫等外部資源,那是不對的——本質上,我們正​​在使用大錘咬緊牙關。 這意味著糟糕的設計。

什麼是好的單元測試?

在深入本教程的主要部分和編寫單元測試之前,讓我們快速討論一個好的單元測試的屬性。 單元測試原則要求一個好的測試是:

  • 容易寫。 開發人員通常會編寫大量單元測試來涵蓋應用程序行為的不同情況和方面,因此應該很容易對所有這些測試例程進行編碼,而無需付出巨大的努力。

  • 可讀。 單元測試的意圖應該是明確的。 一個好的單元測試講述了我們應用程序的某些行為方面的故事,因此應該很容易理解正在測試哪個場景,並且如果測試失敗,很容易檢測到如何解決問題。 通過良好的單元測試,我們可以在不實際調試代碼的情況下修復錯誤!

  • 可靠的。 只有在被測系統中存在錯誤時,單元測試才會失敗。 這似乎很明顯,但是即使沒有引入錯誤,程序員也經常在測試失敗時遇到問題。 例如,測試可能在一個接一個地運行時通過,但在運行整個測試套件時失敗,或者在我們的開發機器上通過並在持續集成服務器上失敗。 這些情況表明存在設計缺陷。 好的單元測試應該是可重現的,並且獨立於外部因素,例如環境或運行順序。

  • 快速地。 開發人員編寫單元測試,以便他們可以重複運行它們並檢查是否沒有引入任何錯誤。 如果單元測試很慢,開發人員更有可能跳過在自己的機器上運行它們。 一個緩慢的測試不會產生重大影響; 再加一千,我們肯定會等一會兒。 緩慢的單元測試也可能表明被測系統或測試本身與外部系統交互,使其依賴於環境。

  • 真正的單元,而不是集成。 正如我們已經討論過的,單元測試和集成測試有不同的目的。 單元測試和被測系統都不應訪問網絡資源、數據庫、文件系統等,以排除外部因素的影響。

就是這樣——編寫單元測試沒有什麼秘密。 但是,有一些技術可以讓我們編寫可測試的代碼

可測試和不可測試的代碼

有些代碼的編寫方式很難,甚至不可能為它編寫一個好的單元測試。 那麼,是什麼讓代碼難以測試呢? 讓我們回顧一下在編寫可測試代碼時應該避免的一些反模式、代碼異味和不良做法。

用非確定性因素毒害代碼庫

讓我們從一個簡單的例子開始。 想像一下,我們正在為智能家居微控制器編寫程序,其中一個要求是如果在晚上或晚上檢測到後院有一些動作,則自動打開後院的燈。 我們從自下而上開始,實現了一個方法,該方法返回一個表示一天中大致時間(“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"; }

本質上,此方法讀取當前系統時間並根據該值返回結果。 那麼,這段代碼有什麼問題呢?

如果我們從單元測試的角度考慮它,我們會發現不可能為這種方法編寫適當的基於狀態的單元測試。 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 // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }

像這樣的測試會違反前面討論的許多規則。 編寫起來會很昂貴(由於非平凡的設置和拆卸邏輯),不可靠(例如,由於系統權限問題,即使被測系統中沒有錯誤,它也可能失敗),並且不能保證快跑。 最後,這個測試實際上不會是單元測試——它會介於單元測試和集成測試之間,因為它假裝測試一個簡單的邊緣案例,但需要以特定方式設置環境。 結果不值得努力,對吧?

事實證明,所有這些可測試性問題都是由低質量的GetTimeOfDay() API 引起的。 在目前的形式中,這種方法存在幾個問題:

  • 它與具體的數據源緊密耦合。 無法重用此方法來處理從其他來源檢索或作為參數傳遞的日期和時間; 該方法僅適用於執行代碼的特定機器的日期和時間。 緊耦合是大多數可測試性問題的根源。

  • 它違反了單一職責原則(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 // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.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)方法及其客戶端可測試:控制反轉,或 IoC。

控制反轉是一種簡單但非常有用的技術,用於解耦代碼,尤其是用於單元測試。 (畢竟,保持鬆散耦合對於能夠相互獨立地進行分析至關重要。)IoC 的關鍵是將決策代碼(何時做某事)與行動代碼(當某事發生時做什麼)分開)。 這種技術增加了靈活性,使我們的代碼更加模塊化,並減少了組件之間的耦合。

控制反轉可以通過多種方式實現; 讓我們看一個特定的例子——使用構造函數的依賴注入——以及它如何幫助構建可測試的SmartHomeController API。

首先,讓我們創建一個IDateTimeProvider接口,其中包含用於獲取某些日期和時間的方法簽名:

 public interface IDateTimeProvider { DateTime GetDateTime(); }

然後,讓SmartHomeController引用一個IDateTimeProvider實現,並委託它獲取日期和時間:

 public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public 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... } }

現在我們可以看到為什麼叫 Inversion of Control 了:使用什麼機制讀取日期和時間的控制權反轉了,現在屬於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() { // 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); }

偉大的! 在重構之前不可能進行這樣的測試。 現在我們已經消除了非確定性因素並驗證了基於狀態的場景,您認為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對象,該對象實現了單例模式。 這個設計有什麼問題?

要對ActuateLights(bool motionDetected)方法進行完整的單元測試,除了基於狀態的測試外,我們還應該執行基於交互的測試; 也就是說,我們應該確保當且僅當滿足適當的條件時才調用打開或關閉燈的方法。 不幸的是,當前的設計不允許我們這樣做: BackyardLightSwitcherTurnOn()TurnOff()方法會觸發系統中的某些狀態更改,或者換句話說,會產生副作用。 驗證這些方法是否被調用的唯一方法是檢查它們相應的副作用是否確實發生了,這可能會很痛苦。

事實上,讓我們假設運動傳感器、後院燈和智能家居微控制器連接到物聯網網絡並使用某種無線協議進行通信。 在這種情況下,單元測試可以嘗試接收和分析該網絡流量。 或者,如果硬件組件用電線連接,單元測試可以檢查電壓是否施加到適當的電路上。 或者,畢竟,它可以使用額外的光傳感器檢查燈是否實際打開或關閉。

正如我們所看到的,單元測試副作用方法可能與單元測試非確定性方法一樣困難,甚至可能是不可能的。 任何嘗試都會導致類似於我們已經看到的問題。 由此產生的測試將難以實施、不可靠、可能很慢並且不是真正的單元。 而且,畢竟,每次我們運行測試套件時閃爍的燈光最終會讓我們發瘋!

同樣,所有這些可測試性問題都是由糟糕的 API 引起的,而不是開發人員編寫單元測試的能力。 無論燈光控制是如何實現的, SmartHomeController API 都會遇到這些已經很熟悉的問題:

  • 它與具體實現緊密耦合。 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))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }

最後,我們使SmartHomeController API 完全可測試,並且我們能夠為它執行基於狀態和基於交互的單元測試。 再次注意,除了提高可測試性之外,在決策和操作代碼之間引入接縫有助於解決緊密耦合問題,並產生更清潔、可重用的 API。

現在,為了實現完整的單元測試覆蓋,我們可以簡單地實現一堆看起來相似的測試來驗證所有可能的情況——這沒什麼大不了的,因為單元測試現在很容易實現。

雜質和可測試性

不受控制的不確定性和副作用在對代碼庫的破壞性影響方面是相似的。 如果使用不慎,它們會導致具有欺騙性、難以理解和維護、緊密耦合、不可重用和不可測試的代碼。

另一方面,確定性無副作用的方法更容易測試、推理和重用以構建更大的程序。 在函數式編程方面,這種方法稱為純函數。 我們很少會在單元測試純函數時遇到問題; 我們所要做的就是傳遞一些參數並檢查結果的正確性。 真正使代碼不可測試的是硬編碼的、不純的因素,這些因素不能被替換、覆蓋或以其他方式抽象掉。

雜質是有毒的:如果方法Foo()依賴於非確定性或副作用方法Bar() ,那麼Foo()也變得非確定性或副作用。 最終,我們可能最終會毒害整個代碼庫。 將所有這些問題乘以一個複雜的現實應用程序的大小,我們會發現自己被一個難以維護的代碼庫所困擾,其中充滿了異味、反模式、秘密依賴以及各種醜陋和令人不快的事情。

單元測試示例:插圖

然而,雜質是不可避免的; 任何現實生活中的應用程序都必須在某些時候通過與環境、數據庫、配置文件、Web 服務或其他外部系統交互來讀取和操作狀態。 因此,與其完全消除雜質,不如限制這些因素,避免它們毒害您的代碼庫,並儘可能打破硬編碼的依賴關係,以便能夠獨立地分析和單元測試事物。

難以測試代碼的常見警告信號

編寫測試有問題? 問題不在您的測試套件中。 它在你的代碼中。
鳴叫

最後,讓我們回顧一些常見的警告標誌,這些標誌表明我們的代碼可能難以測試。

靜態屬性和字段

靜態屬性和字段,或者簡單地說,全局狀態,可以通過隱藏方法完成工作所需的信息、引入非確定性或促進副作用的廣泛使用,使代碼理解和可測試性複雜化。 讀取或修改可變全局狀態的函數本質上是不純的。

例如,很難推斷以下代碼,它依賴於全局可訪問的屬性:

 if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }

如果在我們確定應該調用HeatWater()方法時沒有調用它怎麼辦? 由於應用程序的任何部分都可能更改了CostSavingEnabled值,因此我們必須找到並分析所有修改該值的位置,以找出問題所在。 此外,正如我們已經看到的那樣,不可能為測試目的設置一些靜態屬性(例如DateTime.NowEnvironment.MachineName ;它們是只讀的,但仍然是非確定性的)。

另一方面,不可變確定性的全局狀態是完全可以的。 事實上,有一個更熟悉的名稱——常數。 像Math.PI這樣的常量值不會引入任何不確定性,並且由於它們的值無法更改,因此不允許任何副作用:

 double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!

單身人士

本質上,單例模式只是全局狀態的另一種形式。 單例促進了隱藏在真正依賴關係上的 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; }

In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache after each unit test run.

Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.

The new Operator

Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.

For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:

 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); }

However, sometimes new is absolutely harmless: for example, it is OK to create simple entity objects:

 var person = new Person("John", "Doe", new DateTime(1970, 12, 31));

It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack methods were called or not — we just check if the end result is correct:

 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; }

靜態方法

Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.

For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:

 void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }

However, pure static functions are OK: any combination of them will still be a pure function. 例如:

 double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }

Benefits of Unit Testing

Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.

As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.