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上提供的入门指南,可以看到许多用法。