作者:[美]Adam Freeman 來源:《精通ASP.NET MVC 4》 3.Visual Studio 的單元測試 有很多.NET單元測試包,其中很多是開源和免費的。本文打算使用 Visual Studio 附帶的內建單元測試支持,但其他一些.NET單元測試包也是可用的。 為了演
作者:[美]Adam Freeman 來源:《精通ASP.NET MVC 4》
3.Visual Studio 的單元測試
有很多.NET單元測試包,其中很多是開源和免費的。本文打算使用 Visual Studio 附帶的內建單元測試支持,但其他一些.NET單元測試包也是可用的。
為了演示Visual Studio的單元測試支持,本例打算對示例項目添加一個 IDiscountHelper 介面的新實現。 在 Models 文件夾下新建類文件 MinimumDiscountHelper.cs :
namespace EssentiaTools.Models { public class MinimumDiscountHelper:IDiscountHelper { public decimal ApplyDiscount(decimal totalParam) { throw new NotImplementedException(); } } }
此例的的目標是讓 MinimumDiscountHelper 演示以下行為:
· 總額大於 $100時,折扣為10%
· 總額介於(並包括)$10~$100之間時,折扣為$5
· 總額小於$10時,無折扣
· 總額為負值時,拋出 ArgumentOutOfRangeException
3.1 創建單元測試項目
承接 【MVC 4】3.MVC 基本工具(創建示例項目、使用 Ninject) 的項目“EssentiaTools”,右擊解決方案資源管理器中的頂級條目,從彈出的菜單中選擇“Add New Project(新建項目)”
在彈出的對話框中,添加“Unit Test Project(單元測試項目)”,將項目名設置為EssentiaTools.Tests
然後對這一測試項目添加一個引用,以便能夠對MVC 項目中的類執行測試。
3.2 創建單元測試
在 Essential.Tests 項目的 UnitTest1.cs 文件中添加單元測試:
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using EssentiaTools.Models; namespace EssentiaTools.Tests { [TestClass] public class UnitTest1 { private IDiscountHelper getTestObject() { return new MinimumDiscountHelper(); } [TestMethod] public void Discount_Above_100() { //準備 IDiscountHelper target = getTestObject(); decimal total = 200; //動作 var discountedTotal = target.ApplyDiscount(total); //斷言 Assert.AreEqual(total * 0.9M, discountedTotal); } } }
只添加了一個單元測試。含有測試的類是用 TestClass 註解屬性進行註釋的,其中的各個測試都是用 TestMethod 註解屬性進行註釋方法。並不是單元測試類中的所有方法都是單元測試。例如 getTestObject 方法因為該方法沒有 TestMethod 註解屬性,故 Visual Studio 不會把它當作一個單元測試。
可以看出,單元測試方法遵循了“準備/動作/斷言(A/A/A)”模式。
上述測試方法是通過調用 getTestObject 方法建立起來的,getTestObject 方法創建了一個待測試的實例 —— 本例為 MinimumDiscountHelper 類。另外還定義了要進行檢查的 total 值,這是單元測試的“準備(Arrange)” 部分。
對於測試的“動作(Act)”部分,調用 MinimumDiscountHelper.AppleDiscount 方法,並將結果賦給 discountedTotal 變數。最後,對於測試的“斷言(Assert)”部分使用了 Assert.AreEqual 方法,以檢查從 AppleDiscount 方法得到的值是最初總額的90% 。
Assert 類有一系列可以在測試中使用的靜態方法。這個類位於 Microsoft.VisualStudio.TestTools.UnitTesting 命名空間,該命名空間還包含了一些對建立和執行測試有用的其他類。有關該命名空間的類,可以參閱:https://msdn.microsoft.com/en-us/library/ms182530.aspx
Assert 類是用的最多的一個,其中重要的一些方法如下:
Assert 類中的每一個靜態方法都可以檢查單元測試的某個方面。如果斷言失敗,將拋出一個異常,這意味著整個單元測試失敗。由於每一個單元測試都是獨立進行處理的,因此其他單元測試將被繼續執行。
上述的每一個方法都有一個string 為參數的重載,該字元串作為斷言失敗時的消息元素。 AreEqual 和 AreNotEqual 方法有幾個重載,以滿足特定類型的比較。例如,有一個版本可以比較字元串, 而不需要考慮其他情況。
提示:Microsoft.VisualStudio.TestTools.UnitTesting 命名空間中一個值得註意的成員是 ExpectedException 屬性。這是一個斷言,只有當單元測試拋出 ExceptionType 參數指定類型的異常時,該斷言才是成功的。這是一種確保單元測試拋出異常的整潔方式,而不需要在單元測試中構造 try..catch 塊
為了驗證前述 MinimumDiscountHelper 的其他行為,修改文件 UnitTest1.cs 如下:
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using EssentiaTools.Models; namespace EssentiaTools.Tests { [TestClass] public class UnitTest1 { private IDiscountHelper getTestObject() { return new MinimumDiscountHelper(); } [TestMethod] public void Discount_Above_100() { //準備 IDiscountHelper target = getTestObject(); decimal total = 200; //動作 var discountedTotal = target.ApplyDiscount(total); //斷言 Assert.AreEqual(total * 0.9M, discountedTotal); } [TestMethod] public void Discount_Between_10_And_100() { //準備 IDiscountHelper target = getTestObject(); //動作 decimal TenDollarDiscount = target.ApplyDiscount(10); decimal HundredDollarDiscount = target.ApplyDiscount(100); decimal FiftyDollarDiscount = target.ApplyDiscount(50); //斷言 Assert.AreEqual(5, TenDollarDiscount, "$10 discount is wrong"); Assert.AreEqual(95, HundredDollarDiscount, "$100 discoutn is wrong"); Assert.AreEqual(45, FiftyDollarDiscount, "$50 discount is wrong"); } [TestMethod] public void Discount_Less_Than_10() { IDiscountHelper target = getTestObject(); decimal discount5 = target.ApplyDiscount(5); decimal discount0 = target.ApplyDiscount(0); Assert.AreEqual(5, discount5); Assert.AreEqual(0, discount0); } [TestMethod] [ExpectedException(typeof(ArgumentOutOfRangeException))] public void Discount_Negative_Total() { IDiscountHelper target = getTestObject(); target.ApplyDiscount(-1); } } }
3.3 運行單元測試(並失敗)
Visual Studio 2012 為管理和運行測試引入了一個更為有用的“Test Explorer(測試資源管理器)”視窗。從 Visual Studio 的“Test(測試)”菜單中選擇“Window(視窗)”—>"Test Explorer(測試資源管理器)",便可以看到這一新視窗,點擊左上角附近的“RunAll(全部運行)”按鈕,會看到下圖效果:
可以在該視窗的左側面板中看到所定義的測試列表。所有的測試都失敗了,這是當然的,因為所測試的這些方法還未實現。可以點其中任意測試,測試失敗的原因和細節會顯示在視窗的右側面板中。
3.4 實現特性
現在,到了實現特性的時候了。當編碼工作完成時,基本上可以確信代碼是能夠按預期工作的。有了之前的準備,MinimumDiscountHelper 類的實現相當簡單:
using System; using System.Collections.Generic; using System.Linq; using System.Web; namespace EssentiaTools.Models { public class MinimumDiscountHelper : IDiscountHelper { public decimal ApplyDiscount(decimal totalParam) { if (totalParam < 0) { throw new ArgumentOutOfRangeException(); } else if (totalParam > 100) { return totalParam * 0.9M; } else if (totalParam > 10 && totalParam <= 100) { return totalParam - 5; } else { return totalParam; } } } }
3.5 測試並修正代碼
為了演示如何利用 Visual Studio 進行單元測試迭代,上述代碼故意留下了一個錯誤。如果點擊“測試資源管理器”視窗中的“全部運行”按鈕,則可以看到該錯誤的效果。測試結果如下:
可以看到,三個單元測試得到了通過,但 Discount_Between_10_And_100 測試方法檢測到了一個問題。當點擊這一失敗的測試時,可以看到測試期望得到的是5,但實際得到的是10。
此刻,重新審視代碼便會發現,並未得到適當的實現——特別是總額是10或100的折扣,未做適當處理。問題出在 MinimumDiscountHelper 類的這句語句上:
... else if (totalParam > 10 && totalParam <= 100) ...
雖然目標是建立介於(包括)$10~$100 直接的行為,但實際卻排除了等於$10 的情況,修改成:
... else if (totalParam >= 10 && totalParam <= 100) ...
重新運行測試,所有測試代碼都已通過:
4. 使用 Moq
前面的單元測試如此簡單的原因之一是因為測試的是一個不依賴於其他類而起作用的單一的類。當然,實際項目中有這樣的類,但往往還需要測試一些不能孤立運行的對象。在這些情況下,需要將註意力於感興趣的類或方法上,才能不必對依賴類也進行隱式測試。
一個有用的辦法是使用模仿對象,它能夠以一種特殊而受控的的方式,來模擬項目中實際對象的功能。模仿對象能夠縮小測試的側重點,以使用戶只檢查感興趣的功能。
4.1 理解問題
在開始使用 Moq 之前,本例想演示一個試圖要修正的問題。下麵打算對 LinqValueCalculator 類進行單元測試,LinqValueCalculator 在前面出現過,具體代碼為:
using System; using System.Collections.Generic; using System.Linq; using System.Web; namespace EssentiaTools.Models { public class LinqValueCalculator : IValueCalculator { private IDiscountHelper discounter; public LinqValueCalculator(IDiscountHelper discountParam) { discounter = discountParam; } public decimal ValueProducts(IEnumerable<Product> products) { return discounter.ApplyDiscount(products.Sum(p => p.Price)); } } }
為了,測試這個類,在單元測試項目中新增單元測試文件 UnitTest2.cs :
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using EssentiaTools.Models; using System.Linq; namespace EssentiaTools.Tests { [TestClass] public class UnitTest2 { private Product[] products = { new Product{Name="Kayak",Catogory="Watersports",Price=275M}, new Product{Name="Lifejacket",Catogory="Watersports",Price=48.95M}, new Product{Name="Soccer ball",Catogory="Soccer",Price=19.50M}, new Product{Name="Corner flag",Catogory="Soccer",Price=34.95M} }; [TestMethod] public void Sum_Products_Correctly() { //準備 var discounter = new MinimumDiscountHelper(); var target = new LinqValueCalculator(discounter); var goalTotal = products.Sum(e => e.Price); //動作 var result = target.ValueProducts(products); //斷言 Assert.AreEqual(goalTotal, result); } } }
現在面臨的問題是,LinqValueCalculator 類依賴於 IDiscountHelper 介面的實現才能進行操作。此例使用了 MinimumDiscountHelper 類(這是 IDiscountHelper 介面的實現類),它表現了兩個不同的問題。
第一個問題是單元測試變得複雜和脆弱。為了創建一個能夠進行工作的單元測試,需要考慮 IDiscountHelper 實現中的折扣邏輯,以便判斷出 ValueProducts 方法的預期值。脆弱來自這樣一個事實:一旦該實現中的折扣邏輯發生變化,測試便會失敗。
第二個也是最令人擔憂的問題是已經延展了這一單元測試的範圍,使它的隱式的包含了 MinimumDiscountHelper 類。當單元測試失敗時,用戶不知道問題是出在 LinqValueCalculator 類中,還是在 MinimumDiscountHelper 類中。
當單元測試簡單且焦點集中時,會工作的很好,而當前的設置會讓這兩個特征都不能得到滿足。而在MVC項目中添加並運用 Moq ,能夠避免這些問題。
4.2 將 Moq 添加到VisualStudio 項目
和前面的 Ninject 一樣,在測試項目中 搜索並添加 NuGet 程式包 Moq 。
4.3 對單元測試添加模仿對象
對單元測試添加模仿對象,其目的是告訴 Moq,用戶想使用哪一種對象。對它的行為進行配置,然後將該對象運用於測試目的。
在單元測試中使用 Mock 對象,為 LinqValueCalculator 的單元測試添加模仿對象,修改 UnitTest2.cs 文件:
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using EssentiaTools.Models; using System.Linq; using Moq; namespace EssentiaTools.Tests { [TestClass] public class UnitTest2 { private Product[] products = { new Product{Name="Kayak",Catogory="Watersports",Price=275M}, new Product{Name="Lifejacket",Catogory="Watersports",Price=48.95M}, new Product{Name="Soccer ball",Catogory="Soccer",Price=19.50M}, new Product{Name="Corner flag",Catogory="Soccer",Price=34.95M} }; [TestMethod] public void Sum_Products_Correctly() { //準備 Mock<IDiscountHelper> mock = new Mock<IDiscountHelper>(); mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total); var target = new LinqValueCalculator(mock.Object); //動作 var result = target.ValueProducts(products); //斷言 Assert.AreEqual(products.Sum(e => e.Price), result); } } }
第一次使用 Moq 時,可能會覺得其語法有點奇怪,下麵將演示該過程的每個步驟。
(1) 創建模仿對象
第一步是要告訴 Moq,用戶想使用的是哪種模仿對象。 Moq 十分依賴於泛型的類型參數,從以下語句可以看到這種參數的使用方式,這是告訴 Moq,要模仿的對象時 IDiscountHelper 實現。
... Mock<IDiscountHelper> mock = new Mock<IDiscountHelper>(); ...
創建一個強類型的的 Mock<IDiscountHelper> 對象,目的是告訴 Moq 庫,它要處理的是哪種類型——當然,這便是用於該單元測試的 IDiscountHelper 介面。單為了改善單元測試的側重點,這可以是想要隔離出來的任何類型。
(2) 選擇方法
除了創建強類型的Mock對象外,還需要指定它的行為方式——這是模仿過程的核心,它可以建立模仿所需要的基準行為,用戶可以將這種行為用於對單元測試中目標對象的功能進行測試。以下是單元測試中的語句,它為模仿對象建立了用戶所希望的行為。
... mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total); ...
用 Setup 方法給模仿對象添加一個方法。 Moq 使用 LINQ 和 lambda 表達式進行工作。在調用 Setup 方法時,Moq 會傳遞要求它的介面。它巧妙地封裝了一些本書不打算細說的LINQ 魔力,這種魔力讓用戶可以選擇想要通過 lambda 表達式進行配置或檢查的方法。對於該單元測試,希望定義 AppleDiscount 方法的行為,它是 IDiscountHelper 介面的唯一方法,也是對 LinqValueCalculator 類進行測試所需要的方法。
必須告訴 Moq 用戶感興趣的參數值是什麼,這是要用 It 類要做的事情,如以下加粗部分所示。
... mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total); ...
這個It 類定義了許多以泛型類型參數進行使用的方法。此例用 decimal 作為泛型類型調用了 IsAny 方式。這是告訴 Moq ,當以任何十進位為參數來調用 ApplyDiscount 方法時,它應該運用我們定義的這一行為。
下麵給出了 It 類所提供的方法,所有的這些方法都是靜態的。
(3) 定義結果
Returns 方法讓用戶指定在調用模仿方法時要返回的結果。其類型參數用以指定結果的類型,而用 lambda 表達式來指定結果。如下:
...
mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total);
...
通過調用帶有 decimal 類型參數的 Returns 方法(即 Returns<decimal>),這是告訴 Moq 要返回一個十進位的值。對於 lambda 表達式,Moq 傳遞了一個在ApplyDiscount 方法中接收的類型值 —— 此例創建了一個穿透方法,該方法返回了傳遞給模仿的 ApplyDiscount 方法的值,並未對這個值執行任何操作。
上述過程的思想是:
為了對 LinqValueCalculator 進行單元測試,如果創建一個 IDiscountHelper 模仿對象,便可以在單元測試中排除 IDiscountHelper 介面的實現類 MinimumDiscountHelper ,從而使單元測試更為簡單容易。用 Moq 創建模仿對象的整個過程包括了以下幾個步驟:a. 用 Mock 創建模仿對象; b. 用Setup 方法建立模仿對象的行為; c. 用 It 類設置行為的參數; d. 用Return 方法指定行為的返回類型; e. 用 lambda 表達式在Return 方法中建立具體行為。
(4) 使用模仿對象
最後一個步驟是在單元測試中使用這個模仿對象,通過讀取 Mock<IDiscountHelper> 對象的Object 屬性值來實現
... var target = new LinqValueCalculator(mock.Object); ...
總結下,在上述示例中,Object 屬性返回 IDiscountHelper 介面的實現,該實現中的 ApplyDiscount 方法返回它傳遞的十進位參數的值。
這使單元測試很容易執行,因為用戶可以自行求取 Product 對象的價格總和,並檢查 LinqValueCalculator 對象得到了相同的值。
...
Assert.AreEqual(products.Sum(e => e.Price), result); ...
以這種方式使用 Moq 的好處是,單元測試只檢查 LinqValueCalculator 對象的行為,並不依賴任何 Models 文件夾中 IDiscountHelper 介面的真實實現。這意味著當測試失敗時,用戶便知道問題出在 LinqValueCalculator 實現中,或建立模仿對象的方式中。而解決源自這些方面的問題,比處理實際對象鏈及其相互交互,要更叫簡單而容易。
4.4 創建更複雜的模仿對象
前面展示了一個十分簡單的模仿對象,但 Moq 最漂亮的部分是快速建立複雜行為以便對不同情況進行測試的能力。在 UnitTest2.cs 中新建一個單元測試,模仿更加複雜的 IDiscountHelper 介面實現。
using System; using Microsoft.VisualStudio.TestTools.UnitTesting; using EssentiaTools.Models; using System.Linq; using Moq; namespace EssentiaTools.Tests { [TestClass] public class UnitTest2 { private Product[] products = { new Product{Name="Kayak",Catogory="Watersports",Price=275M}, new Product{Name="Lifejacket",Catogory="Watersports",Price=48.95M}, new Product{Name="Soccer ball",Catogory="Soccer",Price=19.50M}, new Product{Name="Corner flag",Catogory="Soccer",Price=34.95M} }; [TestMethod] public void Sum_Products_Correctly() { //準備 Mock<IDiscountHelper> mock = new Mock<IDiscountHelper>(); mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total); var target = new LinqValueCalculator(mock.Object); //動作 var result = target.ValueProducts(products); //斷言 Assert.AreEqual(products.Sum(e => e.Price), result); } private Product[] createProduct(decimal value) { return new[] { new Product { Price = value } }; } [TestMethod] [ExpectedException(typeof(System.ArgumentOutOfRangeException))] public void Pass_Through_Variable_Discounts() { Mock<IDiscountHelper> mock = new Mock<IDiscountHelper>(); mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total); mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v == 0))).Throws<System.ArgumentOutOfRangeException>(); mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v > 100))).Returns<decimal>(total => (total * 0.9M)); mock.Setup(m => m.ApplyDiscount(It.IsInRange<decimal>(10, 100, Range.Inclusive))).Returns<decimal>(total => total - 5); var target = new LinqValueCalculator(mock.Object); decimal FiveDollarDiscount = target.ValueProducts(createProduct(5)); decimal TenDollarDiscount = target.ValueProducts(createProduct(10)); decimal FiftyDollarDiscount = target.ValueProducts(createProduct(50)); decimal HundredDollarDiscount = target.ValueProducts(createProduct(100)); decimal FiveHundredDollarDiscount = target.ValueProducts(createProduct(500)); Assert.AreEqual(5, FiveDollarDiscount, "$5 Fail"); Assert.AreEqual(5, TenDollarDiscount, "$10 Fail"); Assert.AreEqual(45, FiftyDollarDiscount, "$50 Fail"); Assert.AreEqual(95, HundredDollarDiscount, "$100 Fail"); Assert.AreEqual(450, FiveHundredDollarDiscount, "$500 Fail"); target.ValueProducts(createProduct(0)); } } }
在單元測試期間,複製另一個模型類期望的行為似乎是在做一個奇怪的事情,但這能夠完美演示 Moq 的一些不同用法。
可以看出,根據所接收到的參數值,定義了 ApplyDiscount 方法的四個不同的行為。最簡單的行為是“全匹配”,它直接返回任意的decimal 值,如下:
mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total);
這是用於上一示例的同一行為,把它放在這是因為調用 Setup 方法的順序會影響模仿對象的行為。Moq 會以相反的順序評估所給定的行為,因此會考慮調用最後一個 Setup 方法。這意味著,用戶必須按從最一般到最特殊的順序,小心地創建模仿行為。 It.IsAny<decimal> 是此例所定義的最一般的條件,因而首先運用它。如果顛倒調用 Setup 的順序,該行為將能匹配對 ApplyDiscount 方法的所有調用,並生成錯誤的模仿結果。
(1) 模仿特定值(並拋出異常)
對於 Setup 方法第二個調用,使用了 It.Is 方法
mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v == 0))).Throws<System.ArgumentOutOfRangeException>();
若傳遞給 ApplyDiscount 方法的值是0,則 Is方法的謂詞便返回 true。這裡並未返回一個結果,而是使用了 Throws 方法,這會讓 Moq 拋出一個用類型參數指定的異常實例。
示例還用 Is 方法捕捉了大於100的值:
mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v > 100))).Returns<decimal>(total => (total * 0.9M));
Is.It 方法是為不同參數值建立指定行為最靈活的方式,因為用戶可以使用任意謂詞來返回 true 或 false 。在創建複雜模仿對象的,這是最常用的方法。
(2) 模仿值的範圍
It 對象最後是和 IsInRange 方法一起使用的,它讓用戶能夠捕捉參數值的範圍。
mock.Setup(m => m.ApplyDiscount(It.IsInRange<decimal>(10, 100, Range.Inclusive))).Returns<decimal>(total => total - 5);
這裡介紹這一方法是出於完整性,如果是在用戶自己的項目,可以使用 It 方法和一個謂詞來做同樣的事情,如下所示:
mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v=>v>=10&&v<=100))).Returns<decimal>(total => total - 5);
效果是相同的,但謂詞方法更為靈活。Moq 有一系列非常有用的特性,閱讀https://github.com/Moq/moq4/wiki/Quickstart上提供的入門指南,可以看到許多用法。
源碼地址:https://github.com/YeXiaoChao/EssentiaTools