.NET 單元測試:先花後省
已發表: 2022-03-11在與利益相關者和客戶討論單元測試時,經常會出現很多混淆和疑問。 單元測試有時聽起來就像使用牙線對孩子所做的那樣, “我已經刷牙了,為什麼還要這樣做?”
對於那些認為他們的測試方法和用戶驗收測試足夠強大的人來說,建議單元測試通常聽起來像是不必要的費用。
但是單元測試是一個非常強大的工具,而且比你想像的要簡單。 在本文中,我們將了解單元測試以及 DotNet 中可用的工具,例如Microsoft.VisualStudio.TestTools和Moq 。
我們將嘗試構建一個簡單的類庫來計算斐波那契數列中的第 n 項。 為此,我們將要創建一個用於計算斐波那契數列的類,該類依賴於將數字相加的自定義數學類。 然後,我們可以使用 .NET 測試框架來確保我們的程序按預期運行。
什麼是單元測試?
單元測試將程序分解成最小的代碼位,通常是函數級的,並確保函數返回預期的值。 通過使用單元測試框架,單元測試成為一個單獨的實體,然後可以在構建程序時對程序運行自動化測試。
[TestClass] public class FibonacciTests { [TestMethod] //Check the first value we calculate public void Fibonacci_GetNthTerm_Input2_AssertResult1() { //Arrange int n = 2; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert Assert.AreEqual(result, 1); } }
使用 Arrange, Act, Assert 方法測試我們的數學庫可以正確添加 2 + 2 的簡單單元測試。
設置單元測試後,如果對代碼進行了更改,以說明程序最初開發時未知的附加條件,例如,單元測試將顯示所有案例是否都符合預期值由函數輸出。
單元測試不是集成測試。 這不是端到端測試。 雖然這兩種方法都是強大的方法,但它們應該與單元測試結合使用——而不是作為替代品。
單元測試的好處和目的
單元測試最難理解但最重要的好處是能夠即時重新測試更改的代碼。 它之所以如此難以理解,是因為很多開發人員對自己說: “我再也不會碰那個功能了”,或者“我完成後會重新測試它”。 利益相關者會思考, “如果那篇文章已經寫好了,我為什麼需要重新測試它?”
作為一個在開發領域的兩邊都有的人,我已經說過這兩件事了。 我內心的開發者知道為什麼我們必須重新測試它。
我們每天所做的改變會產生巨大的影響。 例如:
- 您的開關是否正確考慮了您輸入的新值?
- 你知道你用了多少次那個開關嗎?
- 您是否正確考慮了不區分大小寫的字符串比較?
- 您是否適當地檢查空值?
- 是否按預期處理了拋出異常?
單元測試接受這些問題並將它們記錄在代碼和過程中,以確保始終回答這些問題。 可以在構建之前運行單元測試,以確保您沒有引入新的錯誤。 因為單元測試被設計成原子的,所以它們運行得非常快,通常每次測試不到 10 毫秒。 即使在一個非常大的應用程序中,一個完整的測試套件也可以在一個小時內完成。 您的 UAT 流程可以匹配嗎?
Fibonacci_GetNthTerm_Input2_AssertResult1
之外,所有單元測試都在 5 毫秒內運行。 我在這裡的命名約定是為了在我要測試的類中輕鬆搜索類或方法
不過,作為開發人員,也許這對您來說聽起來像是更多的工作。 是的,您可以放心,您發布的代碼是好的。 但是單元測試也讓你有機會看到你的設計在哪裡薄弱。 您是否正在為兩段代碼編寫相同的單元測試? 他們應該在一段代碼上嗎?
讓您的代碼本身可進行單元測試是您改進設計的一種方式。 對於大多數從未進行單元測試的開發人員,或者在編碼之前沒有花太多時間考慮設計的開發人員,您可以通過為單元測試做好準備來意識到您的設計改進了多少。
您的代碼單元可測試嗎?
除了 DRY,我們還有其他考慮。
您的方法或功能是否試圖做太多事情?
如果您需要編寫運行時間比您預期更長的過於復雜的單元測試,您的方法可能過於復雜並且更適合作為多個方法。
你是否正確地利用了依賴注入?
如果您的測試方法需要另一個類或函數,我們將其稱為依賴項。 在單元測試中,我們不關心依賴項在幕後做了什麼。 就被測方法而言,它是一個黑匣子。 依賴項有自己的一組單元測試,這些單元測試將確定其行為是否正常工作。
作為測試人員,您希望模擬這種依賴關係並告訴它在特定實例中返回什麼值。 這將使您更好地控制測試用例。 為此,您需要注入該依賴項的虛擬(或我們稍後將看到的模擬)版本。
您的組件是否以您期望的方式相互交互?
一旦你解決了你的依賴和依賴注入,你可能會發現你已經在你的代碼中引入了循環依賴。 如果 A 類依賴於 B 類,而 B 類又依賴於 A 類,那麼您應該重新考慮您的設計。
依賴注入之美
讓我們考慮一下我們的斐波那契示例。 你的老闆告訴你他們有一個新類,它比 C# 中當前可用的 add 運算符更高效、更準確。
雖然這個特定示例在現實世界中不太可能出現,但我們確實在其他組件中看到了類似的示例,例如身份驗證、對象映射以及幾乎任何算法過程。 出於本文的目的,讓我們假設您的客戶的新添加功能是計算機發明以來最新最好的。
因此,您的老闆給您一個黑盒庫,其中包含一個類Math
,並且在該類中,有一個函數Add
。 你實現斐波那契計算器的工作可能看起來像這樣:
public int GetNthTerm(int n) { Math math = new Math(); int nMinusTwoTerm = 1; int nMinusOneTerm = 1; int newTerm = 0; for (int i = 2; i < n; i++) { newTerm = math.Add(nMinusOneTerm, nMinusTwoTerm); nMinusTwoTerm = nMinusOneTerm; nMinusOneTerm = newTerm; } return newTerm; }
這並不可怕。 您實例化一個新的Math
類並使用它來添加前兩個術語以獲得下一個。 您通過正常的一系列測試運行此方法,計算出 100 個術語,計算第 1000 個術語,第 10,000 個術語,等等,直到您對您的方法正常工作感到滿意為止。 然後在未來的某個時候,用戶抱怨第 501 項沒有按預期工作。 您花了一個晚上查看您的代碼,並試圖找出為什麼這個極端案例不起作用。 你開始懷疑最新最好的Math
課並沒有你老闆想像的那麼好。 但這是一個黑匣子,你無法真正證明這一點——你在內部陷入了僵局。
這裡的問題是依賴Math
沒有註入到您的斐波那契計算器中。 因此,在您的測試中,您總是依賴Math
中現有的、未經測試的和未知的結果來測試斐波那契。 如果Math
有問題,那麼 Fibonacci 將永遠是錯誤的(沒有為第 501 項編碼特殊情況)。
糾正這個問題的想法是將Math
類註入到您的 Fibonacci 計算器中。 但更好的是,為Math
類創建一個定義公共方法(在我們的例子中為Add
)的接口,並在我們的Math
類上實現該接口。
public interface IMath { int Add(int x, int y); } public class Math : IMath { public int Add(int x, int y) { //super secret implementation here } } }
我們可以將IMath
接口注入到 Fibonacci 中,而不是將Math
類註入到 Fibonacci 中。 這裡的好處是我們可以定義我們自己的OurMath
類,我們知道它是準確的,並針對它測試我們的計算器。 更好的是,使用 Moq 我們可以簡單地定義Math.Add
返回的內容。 我們可以定義一些總和,或者我們可以告訴Math.Add
返回 x + y。
private IMath _math; public Fibonacci(IMath math) { _math = math; }
將 IMath 接口注入到 Fibonacci 類中
//setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y);
使用 Moq 定義Math.Add
返回的內容。

現在我們有了一個經過驗證的方法(好吧,如果 + 運算符在 C# 中是錯誤的,我們會有更大的問題)添加兩個數字的方法。 使用我們新的 Mocked IMath
,我們可以為我們的第 501 個學期編寫一個單元測試,看看我們是否搞砸了我們的實現,或者自定義的Math
類是否需要更多的工作。
不要讓方法嘗試做太多事情
這個例子也指向了一個方法做得太多的想法。 當然,加法是一個相當簡單的操作,不需要從我們的GetNthTerm
方法中抽像出它的功能。 但是如果操作稍微複雜一點呢? 可能不是添加,而是模型驗證,調用工廠來獲取要操作的對象,或者從存儲庫中收集額外的所需數據。
大多數開發人員會嘗試堅持一種方法有一個目的的想法。 在單元測試中,我們試圖堅持單元測試應該應用於原子方法的原則,並且通過在方法中引入過多的操作,我們使其無法測試。 我們經常會創建一個問題,我們必須編寫如此多的測試才能正確測試我們的功能。
我們添加到方法中的每個參數都會根據參數的複雜性以指數方式增加我們必須編寫的測試數量。 如果您在邏輯中添加布爾值,則需要將要編寫的測試數量加倍,因為您現在需要檢查真假案例以及當前測試。 在模型驗證的情況下,我們的單元測試的複雜性會迅速增加。
我們都對在方法中添加一些額外內容感到內疚。 但是這些更大、更複雜的方法需要太多的單元測試。 當您編寫單元測試時,很快就會發現該方法試圖做的太多了。 如果您覺得您正在嘗試從輸入參數測試太多可能的結果,請考慮您的方法需要分解為一系列較小的事實這一事實。
不要重複自己
我們最喜歡的編程租戶之一。 這應該是相當直截了當的。 如果您發現自己不止一次編寫相同的測試,那麼您已經不止一次地引入了代碼。 將工作重構為一個公共類,您嘗試使用它的兩個實例都可以訪問它,這可能會使您受益。
有哪些單元測試工具可用?
DotNet 為我們提供了一個非常強大的開箱即用的單元測試平台。 使用它,您可以實現所謂的 Arrange、Act、Assert 方法。 你安排好你最初的考慮,用你的測試方法對這些條件採取行動,然後斷言發生了什麼事。 你可以斷言任何東西,使這個工具更加強大。 您可以斷言某個方法被調用了特定次數,該方法返回了特定值,引發了特定類型的異常,或者您能想到的任何其他內容。 對於那些尋求更高級框架的人來說,NUnit 及其 Java 對應的 JUnit 是可行的選擇。
[TestMethod] //Test To Verify Add Never Called on the First Term public void Fibonacci_GetNthTerm_Input0_AssertAddNeverCalled() { //Arrange int n = 0; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Never); }
測試我們的斐波那契方法是否通過拋出異常來處理負數。 單元測試可以驗證是否引發了異常。
為了處理依賴注入,DotNet 平台上同時存在 Ninject 和 Unity。 兩者之間幾乎沒有區別,問題在於您是想使用 Fluent Syntax 還是 XML Configuration 來管理配置。
為了模擬依賴關係,我推薦 Moq。 起訂量可能很難讓您動手,但要點是您創建依賴項的模擬版本。 然後,您告訴依賴項在特定條件下返回什麼。 例如,如果您有一個名為Square(int x)
的方法,它對整數進行平方運算,您可以告訴它當 x = 2 時返回 4。您還可以告訴它對於任何整數都返回 x^2。 或者您可以告訴它在 x = 2 時返回 5。為什麼要執行最後一種情況? 如果測試角色下的方法是驗證依賴項的答案,您可能希望強制返回無效答案以確保正確捕獲錯誤。
[TestMethod] //Test To Verify Add Called Three times on the fifth Term public void Fibonacci_GetNthTerm_Input4_AssertAddCalledThreeTimes() { //Arrange int n = 4; //setup Mock<UnitTests.IMath> mockMath = new Mock<UnitTests.IMath>(); mockMath .Setup(r => r.Add(It.IsAny<int>(), It.IsAny<int>())) .Returns((int x, int y) => x + y); UnitTests.Fibonacci fibonacci = new UnitTests.Fibonacci(mockMath.Object); //Act int result = fibonacci.GetNthTerm(n); //Assert mockMath.Verify(r => r.Add(It.IsAny<int>(), It.IsAny<int>()), Times.Exactly(3)); }
使用 Moq 告訴模擬的IMath
接口如何處理被測Add
。 您可以使用It.Is
或使用It.IsInRange
設置範圍。
DotNet 的單元測試框架
微軟單元測試框架
Microsoft 單元測試框架是 Microsoft 開箱即用的單元測試解決方案,包含在 Visual Studio 中。 因為它帶有VS,所以它很好地集成了它。 當您開始一個項目時,Visual Studio 會詢問您是否要在應用程序旁邊創建一個單元測試庫。
Microsoft 單元測試框架還附帶了許多工具來幫助您更好地分析您的測試過程。 此外,由於它由 Microsoft 擁有和編寫,因此它的存在具有一定的穩定性。
但是,在使用 Microsoft 工具時,您會得到它們給您的東西。 Microsoft 單元測試框架的集成可能很麻煩。
單元
使用 NUnit 對我來說最大的好處是參數化測試。 在我們上面的斐波那契示例中,我們可以輸入許多測試用例並確保這些結果是正確的。 並且在我們的第 501 個問題的情況下,我們總是可以添加一個新的參數集來確保測試始終運行而無需新的測試方法。
NUnit 的主要缺點是將其集成到 Visual Studio 中。 它缺少 Microsoft 版本附帶的花里胡哨,這意味著您需要下載自己的工具集。
xUnit.Net
xUnit 在 C# 中非常流行,因為它與現有的 .NET 生態系統完美集成。 Nuget 有許多可用的 xUnit 擴展。 它還與 Team Foundation Server 很好地集成,儘管我不確定有多少 .NET 開發人員仍然在各種 Git 實現上使用 TFS。
不利的一面是,許多用戶抱怨 xUnit 的文檔有點缺乏。 對於單元測試的新用戶來說,這可能會引起巨大的頭痛。 此外,xUnit 的可擴展性和適應性也使學習曲線比 NUnit 或 Microsoft 的單元測試框架更陡峭。
測試驅動設計/開發
測試驅動設計/開發 (TDD) 是一個更高級的主題,值得單獨發表。 但是,我想提供一個介紹。
這個想法是從你的單元測試開始,告訴你的單元測試什麼是正確的。 然後,您可以圍繞這些測試編寫代碼。 理論上,這個概念聽起來很簡單,但在實踐中,很難訓練你的大腦向後思考應用程序。 但是這種方法有一個內置的好處,即不需要在事後編寫單元測試。 這會減少重構、重寫和類混淆。
近年來,TDD 一直是一個流行詞,但採用速度很慢。 它的概念性質使利益相關者感到困惑,因此難以獲得批准。 但作為開發人員,我鼓勵您使用 TDD 方法編寫一個小型應用程序以適應該過程。
為什麼你不能有太多的單元測試
單元測試是開發人員可以使用的最強大的測試工具之一。 它不足以對您的應用程序進行全面測試,但它在回歸測試、代碼設計和目的文檔方面的優勢是無與倫比的。
沒有寫太多單元測試這樣的事情。 每個邊緣案例都可能在您的軟件中提出大問題。 將發現的錯誤作為單元測試進行紀念可以確保這些錯誤在以後的代碼更改期間不會找到方法重新進入您的軟件。 雖然您可以在項目的前期預算中增加 10-20%,但您可以節省的遠不止培訓、錯誤修復和文檔方面的費用。
您可以在此處找到本文中使用的 Bitbucket 存儲庫。