ASP.NET + MVC5 入门完整教程七 -—-- MVC基本工具(下)
Visual Stdio 的单元测试
示例准备
在本例中,使用 Visual studio 附带的内置单元测试支持,但其他一些 NET 单元测试包也是可用的。最流行的可能是 Nunit,所有测试包的功能大体相同。本例选择采用 Visual studio内置支持的理由是,喜欢它与 IDE 其余部分的集成。为了演示 Visual studio的单元测试支持,打算对示例项目添加一个 IDiscountHelper接口的新实现。在 Models文件夹中创建一个名为 MinimumDiscountHelper.cs的新文件,确保其内容与下吻合:
(EssentialTools 整个项目参见:ASP.NET + MVC5 入门完整教程七 -—-- MVC基本工具(上) )
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
namespace EssentialTools.Models
{
public class MinimumDiscountHelper : IDiscountHelper
{
public decimal ApplyDiscount(decimal totalParam)
{
throw new NotImplementedException();
}
}
}
注意:直接 :IDiscountHelper 即可,IDiscountHelper 实现接口就能自动完成代码
此例目标是让 MinimumDiscountHelper 演示一下行为:
- 总额大于¥100,折扣为10%
- 总额介于(包括)¥10到¥100之间,折扣为 5%
- 总额小于¥10 ,无折扣
- 总额为负值,抛出一个 ArgumentOutOfRangeException 异常
MinimumDiscountHelper 尚未实现这些功能,记下来讲解具体过程。
创建单元测试项目
右键解决方案(EssentialTools)添加新项目:
将项目名称设置为 Essentialtools.Tests并单击OK按钮,便可创建这一新的项目, Visual studio 会将它添加到当前 MVC 应用程序项目的解决方案中,这里需要给该测试项目添加一个对应用程序项目的引用,以使测试项目能够访问应用程序中的类,并对这些类进行测试。在解决方案资源管理器中右击 Essentialtools.Tests 项目的“ References(引用)”条目,并从弹出的菜单中选择“ Add Reference(添加引用)”,单击左侧面板的“ Solution(解决方案)”,并选中 Essentialtools 条目旁边的复选框,如下图所示。这一做法的目的很简单,就是让单元测试项目 Essentialtools.Tests引用 MVC 项目 Essentialtools,以便能够针对MVC 项目中的类编写有关的单元测试。
添加单元测试
在 EssentialTools.Tests 项目的 UnitTest1.cs 文件添加单元测试。如下所示:
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using EssentialTools.Models;
namespace EssentialTools.Tests
{
[TestClass]
public class UnitTest1
{
private IDiscountHelper getTestObject()
{
return new MinimumDiscountHelper();
}
[TestMethod]
public void Discount_About_100()
{
//准备
IDiscountHelper target = getTestObject();
decimal total = 200;
//动作
var discountedTotal = target.ApplyDiscount(total);
//断言
Assert.AreEqual(total * 0.9M, discountedTotal);
}
public void TestMethod1()
{
}
}
}
这里只添加了一个单元测试。含有测试的类是用 TestClass 注解属性进行注释的,其中的各个测试都是用 TestMethod 注解属性进行注释的方法。并不是一个单元测试类中的所有方法都是单测试。为了说明这一点,这里定义了一个用于准备测试的 getTestObject 方法。因为该方法没有 TestMethod 注解属性,故 Visual studio 不会把它当做一个单元测试。
可以看出,在单元测试方法中遵循了“准备/动作/断言(AAA)”模式。如何命名单元测试有无数约定,但个人建议是使用的名称要简单,让它清楚地表示该测试检查的是什么。本例的单元测试方法名为 Discount_About_100,这对我们来说是明确而有意义的。但是,真正重要的是你及你的团队要理解你所定的命名模式。因此,如果你不喜欢这种风格,完全可以采取不同的命名方案。
上述测试方法是通过调用 getTestObject 方法建立起来的, getTestObject 方法创建了一个待测试对象的实例:本例为 MinimumDiscountHelper 类。另外,还定义了要进行检查的 total 值这是单元测试的“准备( Arrange)”部分对于测试的“动作(Act)”部分,调用 MinimumDiscountHelper.ApplyDiscount方法,并将结果赋给 discountedTotal 变量。最后,对于测试的“断言( Assert)”部分,使用了Assert.AreEqual 方法,以检查从 ApplyDiscount 方法得到的值是最初总额的90%。Assert类有一系列可以在测试中使用的静态方法。这个类位于 Microsoft. Visualstudio Testtools,Unittesting命名空间,该命名空间还包含了一些对建立和执行测试有用的其他类。 Assert类是用得最多的一个,下表摘录了其中最重要的一些方法:
Assert 类中的每一个静态方法都可以检查单元测试的某个功能,并且在检查失败时,这些方法会抛出一个异常。要通过单元测试,所有断言都必须成功。
上述表格中的每一个方法都有一个以 string 为参数的重载,该字符串为断言失败时的异常信息元素。Assert 和 AreNotEqual 方法有几个重载,以满足特定类型比较。
以上已经展示了如何组装单元测试,接下来将对测试项目增加一些测试,以验证前述 MinimumDiscountHelper 的其他行为。UnitTest1.cs 定义其他测试行为。如下所示:
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using EssentialTools.Models;
namespace EssentialTools.Tests
{
[TestClass]
public class UnitTest1
{
private IDiscountHelper getTestObject()
{
return new MinimumDiscountHelper();
}
[TestMethod]
public void Discount_About_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 discount is wrong");
Assert.AreEqual(45, FiftyDollarDiscount, "$50 discount is wrong");
}
[TestMethod]
public void Discount_Less_Then_100()
{
//准备
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);
}
}
}
运行测试单元(未通过)
Visual Studio 提供了“Test Explorer(测试资源管理器)”窗口,用于管理和运行测试。从“Test(测试)”菜单中选择“ Windows(窗口)”→“ Test Explorer(测试资源管理器)”,便可以看到该窗口,单击左上角附近的“RunAll(全部运行)”按钮,会看到类似下图所示的结果。
在“ Test Exp1orer(测试资源管理器)”窗口的左侧面板中可以看到定义的测试列表。所有的测试都失败了,这是当然的,因为所测试的这些方法还尚未实现。单击其中任一测试,测试失败的原因细节便会显示在窗口的右侧面板中。“ Test Explorer(测试资源管理器)”窗口提供一系列不同的方式,可以选择和过滤单元测试,以及选择要运行的测试。不过,对于这一简单的示例项目而言,只需单击“RunA11(全部运行)”按钮来运行所有测试。
实现特性
现在到了实现的时候了,当编码工作完成时,基本上确信代码能按照预期工作。现在实现 MinimumDiscountHelper ,如下所示:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
namespace EssentialTools.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;
}
}
}
}
测试并修改代码
为了演示如何用 Visual studio进行单元测试迭,上述代码故意留下一个错误。如果单击“Test Explorer(测试资源管理器)”窗口中的“RunA11(全部运行)”按钮,可以看到该错误的效果测试结果如下所示。
Visual studio总是试图把最有用的信息放到“ Test Explorer(测试资源管理器)”窗口的顶部。在这种情况下,这意味着失败的测试将显示在通过的测试之前你可以看到,3个单元测试得到了通过,但 Discount_ Between_10_And_100 测试方法检测到一个问题。当单击这一失败的测试时,可以看到该测试期望得到的结果是5,但实际得到值是10此刻,重新审视代码便会发现,并未适当地实现预期的行为。特别是对总额10或100的折扣未做出适当处理。问题其实很容易被发现,就是 MinimumDiscountHelper 出现问题,其实就是一句话:
else if(totalParam>10 && totalParam<=100)
这里,我们没有对恰好值为 10 做出判断,所以修改为
else if(totalParam>=10 && totalParam<=100)
再次运行结果如下:、
这里只是对单元测试的简单介绍,后面会进一步加强演示。
使用 Moq 库
上述测试如此简单的原因是建立了一个不依赖其他类而起作用的单一类,而在实际项目中,往往不能独立运行某一个类的对象,这时候,需要将注意力集中在感兴趣的类或者方法上,才能不必对依赖类也能进行测试。
一个有用的办法是使用模仿对象,它能够以一种特殊而受控的方式来模拟项目中实际对象的功能。模仿对象让你能够缩小测试的侧重点,以使你只检查感兴趣的功能Visual studio的付费版本包含了对创建模仿对象的支持,这是通过一个叫做 fakes 的特性来实现的。但本人更喜欢使用一个名为 Moq 的库,它简单易用,而且能够用于包括免费版在内的所有 Visual Studio版本。
理解问题
这里,我们即将演示对 LinqValueCalculator 类进行单元测试。类内容如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
namespace EssentialTools.Models
{
public class LinqValueCalculator : IValueCalculator
{
private IDiscountHelper discounter;
private static int counter = 0;
public LinqValueCalculator(IDiscountHelper discountParam)
{
discounter = discountParam;
System.Diagnostics.Debug.WriteLine(string.Format("Instance {0} Created", ++counter));
}
public decimal ValueProducts(IEnumerable<Product> products)
{
return discounter.ApplyDiscount(products.Sum(p => p.Price));
}
}
}
为了测试这个类,为该测试项目添加一个新的单元测试类。在解决方案资源管理器窗口中右击测试项目,从弹出的菜单中选择“Add(添加)”→“ Unit Test(单元测试)”,便可添加一个单元测试类。如果“Add(添加)”菜单中没有“ Unit Test(单元测试)”选项,请选择“ New item(新项)”,并使用“ Basic Unit Test(基本单元测试)”模板。如下所示中看到对新文件所做的修改, Visual studio的默认命名为 UnitTest2.cs。注意引用命名空间 using EssentialTools.Models。using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using EssentialTools.Models;
using System.Linq;
namespace EssentialTools.Tests
{
[TestClass]
public class UnitTest2
{
private Product[] products =
{
new Product {Name = "Kayak",Description = "Watersports",Category = "Watersport",Price = 122M },
new Product {Name = "Lifejacket",Description = "Watersports",Category = "Watersports",Price = 162M },
new Product {Name = "Soccer ball",Description = "Soccer",Category = "Soccer",Price = 172.25M },
new Product {Name = "Corner flag",Description = "Soccer",Category = "Soccer",Price = 82.15M }
};
[TestMethod]
public void Sum_Products_Correctly()
{
//准备
var discounter = new MinimumDiscountHelper();
var target = new LinqValueCalculator(discounter);
var goalTotal = products.Sum(p => p.Price);
//动作
var result = target.ValueProducts(products);
//断言
Assert.AreEqual(goalTotal, result);
}
}
}
要面临的问题是, LinqValueCalculator 类依赖于 IDiscountHelper 接口的实现才能进行操作。在此例中,使用了 MinimumDiscountHelper 类(这是 IDiscountHelper 接口的实现类),它表现出以下两个不同的问题。第一个问题是,已经使单元测试变得复杂和脆弱。为了创建一个能够进行工作的单元测试,需要考虑 IDiscountHelper 实现中的折扣逻辑,以便判断出 ValueProducts 方法的预期值。脆弱来自这样一个事实:一旦该实现中的折扣逻辑发生变化,即使 LinqValueCalculator 类可以很好地正常工作,测试仍会失败。第二个也是最令人担忧的问题是,已经延展了这一单元测试的范围,使它隐式地包含了MinimumDiscountHelper类。当单元测试失败时,不易知道问题是出在 LinqValueCalculator类中,还是 MinimumDiscountHelper 类中,最好的单元测试是简单且焦点集中的,而当前的设置让这两个特征都不能得到满足。在接下来将向你展示,如何在 MVC 项目中添加并运用Moq,以使你能够避免这些问题。
将Moq添加到 VS项目中
正如之前讲解添加 Ninject 一样,利用 NuGet 在 EssentialTools.Tests 项目内添加 Moq,如下图所示:对单元测试添加模仿对象
对 LinqValueCalculator 修改如下:
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using EssentialTools.Models;
using System.Linq;
using Moq;
namespace EssentialTools.Tests
{
[TestClass]
public class UnitTest2
{
private Product[] products =
{
new Product {Name = "Kayak",Description = "Watersports",Category = "Watersport",Price = 122M },
new Product {Name = "Lifejacket",Description = "Watersports",Category = "Watersports",Price = 162M },
new Product {Name = "Soccer ball",Description = "Soccer",Category = "Soccer",Price = 172.25M },
new Product {Name = "Corner flag",Description = "Soccer",Category = "Soccer",Price = 82.15M }
};
[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 discounter = new MinimumDiscountHelper();
// var goalTotal = products.Sum(p => p.Price);
//动作
var result = target.ValueProducts(products);
//断言
Assert.AreEqual(products.Sum(p => p.Price), result);
}
}
}
创建模仿对象
第一步告诉 Moq,想使用哪一种模仿对象:
Mock<IDiscountHelper> mock = new Mock<IDiscountHelper>();
这里创建的是一个强类型的 Mock<IDiscountHelper> 对象,告诉 Moq 库,要处理的是那种类型,这就是要用于该单元测试的 IDiscountHelper 接口,为了改善单元测试的侧重点,这可以是你想要隔里出来的任何类型。选择方法
除了创建强类型的 Mock 外,还要指定他的行为方式,这是模仿过程的核心,他让你建立模仿所需要的基准行为,为模仿对象建立了所希望的行为。
mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total);
用 Setup方法给模仿对象添加一个方法,Moq 可以使用 LINQ 和 lambda 表达式进行工作。在调用 Setup 方法时,Moq 会给程序传递要求它实现的接口,它巧妙地封装了一些不打算细说的 LINQ 魔力,这种魔力让程序可以选择想要通过 lambda 表达式进行配置或检查的方法( Discount.cs 中 IDiscountHelper 接口有一个 ApplyDiscount方法,这里之所以能够选择该方法并对它进行配置,应当正是 LINQ 的作用)。对于该单元测试,希望定义 ApplyDiscount方法的行为,它是 IDiscountHelper接口的唯一方法,也是对 LinqValueCalculator 类进行测试所需要的方法也必须告诉 Moq,程序感兴趣的参数值是什么,这是要用 It 类来做的事情。这个 It 类定义了许多以泛型类型参数进行使用的方法。在此例中,用 decimal 作为泛型类型调用了 IsAny 方法。这是告诉 Moq,当以任何十进制值为参数来调用 ApplyDiscount 方法时,它应该运用程序定义的这一行为。下表给出了 It 类所提供的方法,所有这些方法都是静态的。
定义结果
Returns 方法让程序指定在调用模仿方法时 Moq 返回的结果,其类型参数用以指定结果的类型,而 lambda 表达式来指定结果。如下所示:
mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total);
通过调用带有 decimal 类型参数的 Returns 方法(即 Returns< decimal>),这是告诉 Moq ,要返回一个十进制的值。对于 lambda 表达式,Moq 给程序传递了一个在 ApplyDiscount 方法中接收的类型值。在此例中,创建了一个穿透方法。在该方法中,程序返回了传递给模仿 ApplyDiscount 方法的值并未对这个值执行任何操作。这是一种最简单的模仿方法,不过接下来很快会展示一些更复杂的示例。
使用模仿对象
最后一个步骤就是在单元测试中使用这个模仿对象,通过读取 Mock<IDiscountHelper> 对象的 Object 属性值实现。
var target = new LinqValueCalculator(mock.Object);
总结上述示例, object属性返回 IDiscountHelper 接口的实现,该实现中的 ApplyDiscount 方法返回它传递的十进制参数的值。这使单元测试很容易执行,因为可以自行求取 Product 对象的价格总和,并检查 LinqValueCalculator 对象,得到了相同的值。 Assert.AreEqual(products.Sum(p => p.Price), result);
以这种方式使用 Moq 的好处是,单元测试只检查 LinqValueCalculator 对象的行为,并不依赖于任何 Models 文件夹中 IDiscountHelper 接口的真实实现。这意味着,当测试失败时,便知道问题出在 LinqValueCalculator 实现中,或是出在建立模仿对象的方式中。而解决源自这些方面的问题比处理实际对象链及其相互交互,要更加简单且容易。创建更复杂的模拟对象
给 UnitTest2 添加一个新的单元测试,模仿更为复杂的 IDiscountHelper 接口实现,如下所示:
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using EssentialTools.Models;
using System.Linq;
using Moq;
namespace EssentialTools.Tests
{
[TestClass]
public class UnitTest2
{
private Product[] products =
{
new Product {Name = "Kayak",Description = "Watersports",Category = "Watersport",Price = 122M },
new Product {Name = "Lifejacket",Description = "Watersports",Category = "Watersports",Price = 162M },
new Product {Name = "Soccer ball",Description = "Soccer",Category = "Soccer",Price = 172.25M },
new Product {Name = "Corner flag",Description = "Soccer",Category = "Soccer",Price = 82.15M }
};
[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 discounter = new MinimumDiscountHelper();
// var goalTotal = products.Sum(p => p.Price);
//动作
var result = target.ValueProducts(products);
//断言
Assert.AreEqual(products.Sum(p => p.Price), result);
}
private Product [] createProduct(decimal value)
{
return new[] { new Product { Price = value } };
}
[TestMethod]
[ExpectedException(typeof(System.ArgumentOutOfRangeException))]
public void Pass_Throught_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, TenDollarDiscount, "$5 Fail");
Assert.AreEqual(5, HundredDollarDiscount, "$10 Fail");
Assert.AreEqual(45, FiftyDollarDiscount, "$50 Fail");
Assert.AreEqual(95, FiftyDollarDiscount, "$100 Fail");
Assert.AreEqual(450, FiftyDollarDiscount, "$500 Fail");
target.ValueProducts(createProduct(0));
}
}
}
在单元测试期间,复制另一个模型类期望的行为似乎是在做一件奇怪的事情,但这能够完美演示Moq的一些不同特性。根据所接收到的参数值,定义了 ApplyDiscount 方法的4个不同行为。最简单行为的是“全匹配”,它直接返回任意的 decimal 值,像这样:
mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total);
这是用于上一示例的同一行为,把它放在这儿是因为调用 Setup方法的顺序会影响模仿对象的行为。Moq会以相反的顺序评估所给定的行为,因此,它首先会考虑调用最后一个 Setup方法。这意味着,你必须按从最一般到最特殊的顺序,小心地创建模仿行为。It. Isany< decima1>是此例所定义的最一般的条件,因而首先运用它。
模仿特定值(并抛出异常)
对于 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));
It.Is 方法是为不同参数建立指定行为最灵活的方式,因为可以返回 true 或 false。
模仿值的范围
It 对象最后是与 IsInRange 方法一起使用,从而能捕捉到参数值的范围。
mock.Setup(m => m.ApplyDiscount(It.IsInRange<decimal>(10,100,Range.Inclusive))).Returns<decimal>(total => total-5);
当然也可以如下实现,效果是相同的。
mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v>=10 && v <=100)).Returns<decimal>(total => total - 5);
上一篇: Redis 的底层数据结构(跳跃表)
下一篇: asp.net 数据绑定的实例代码