Pro ASP.NET MVC –第六章 MVC的基本工具
在本章,我们将介绍每个MVC程序员"武器库"的三个重要工具:依赖注入容器、单元测试框架和mock工具。在本书,对于三个工具分别都只用了一种方式实现,但每个工具都还有其他的实现方式。如果我们的实现方式不适合你的具体情况,那么你也不必担心。因为它们并不仅仅局限于我们采用的实现,你可以找到与你工作环境相适合的实现方式。
在第三章,我们提到Ninject是一个推荐的依赖注入容器。它非常简单,优美,并易于使用。虽然还有一些其他的可使用的依赖入住容器,但是我们喜欢只需要进行很少配置就可以工作的Ninject。如果从模式出发,我们还发现依赖关系和Ninject适用于多个不同的项目或工作流。当然,如果你就是不喜欢Ninject,那么我们推荐你使用Unity,因为它是微软的依赖注入容器。
对于单元测试,我们使用Visual Studio自带的单元测试。我们曾经使用NUnit,一个非常流行的.NET单元测试框架,但是微软对Visual Studio自带的单元测试做出了巨大的努力。其结果就是该单元测试框架紧密地与IDE集成在一起,而且也变地非常好用。
第三个工具我们选择的是Moq,一个mock工具集合。我们使用Moq实现接口以进行单元测试。程序员要么喜欢要么讨厌Moq,没有中立的。你要么认为Moq的语法非常优美并令人印象深刻,要么你捣鼓半天还没有适应它的语法。如果你确实不能适应这个工具,我们推荐你使用Rhino mocks。
我们将分别介绍这三个工具——其实每个工具都可以使用一本书来介绍——但在本章介绍的东西已完全足够你上手使用这三个工具;严格地说,你看完本书后续章节的示例后你肯定就会使用这三个工具。
1创建示例项目
我们首先创建一个简单的示例以供本章使用。我们创建一个名为EssentialTools的ASP.NET MVC 4空白工程。
创建模型类
首先先创建Prodcut类
publicclassProduct { publicint ProductID { get; set; } publicstring Name { get; set; } publicstring Description { get; set; } publicdecimal Price { get; set; } publicstring Category { get; set; } } |
然后,创建一个用于计算Products对象集合总价的类
publicclassLinqValueCalculator { publicdecimal ValueProducts(IEnumerable<Product> products) { return products.Sum(p => p.Price); } } |
最后,我们创建一个购物车类ShoppingCart,它其实就是产品对象的集合,并使用LinqValueCalculator来计算其产品集合的总价
publicclassShoppingCart { privateLinqValueCalculator _calcucator;
public ShoppingCart(LinqValueCalculator calcucator) { _calcucator = calcucator; }
publicIEnumerable<Product> Products { get; set; }
publicdecimal CalculateProductToTotal() { return _calcucator.ValueProducts(Products); } } |
添加控制器
添加一个名为HomeController的控制器。该控制器包含一个Product数组,然后在Index行为方法中使用LinqValueCalculator类计算产品数组的总价,最后把总价传递给View方法。
publicclassHomeController : Controller { privateProduct[] products = { newProduct {Name = "Kayak", Category = "Watersports", Price = 275M}, newProduct {Name = "Lifejacket", Category = "Watersports", Price = 48.95M}, newProduct {Name = "Soccer ball", Category = "Soccer", Price = 19.50M}, newProduct {Name = "Corner flag", Category = "Soccer", Price = 34.95M} };
publicActionResult Index() { LinqValueCalculator calculator = newLinqValueCalculator();
ShoppingCart shoppingCart = newShoppingCart(calculator){Products=products};
decimal totalValue = shoppingCart.CalculateProductToTotal();
return View(totalValue); } } |
添加视图
在HomeController的Index行为方法上点击右键,然后选择添加视图。创建一个名为Index,强类型为decimal的视图模型对象
@model decimal
@{ Layout = null; }
<!DOCTYPEhtml>
<html> <head> <metaname="viewport"content="width=device-width"/> <title>Index</title> </head> <body> <div> Total value is $@Model </div> </body> </html> |
才视图的内容非常简单,使用@Model表达式显示来自于行为方法的结果。如果你运行工程,那么会在浏览器中看到如下的结果:
这虽然是一个简单的工程,但是已经完全适用于我们要们在本章将要介绍的工具。
2使用Ninject
在第三章,我们介绍了依赖注入。现在我们来回顾一下,依赖注入就是在MVC程序中解耦组件,我们通过结果和依赖注入完成解耦。在接下来的章节,我们将解释在我们创建的示例程序中发生的问题,并展示如何使用Ninject来解决碰到的问题
理解问题
在示例程序中,我们创建了依赖注入可以解决的一种问题。我们的示例工程严重依赖紧密耦合类:ShoppingCart类依赖于LinqValueCalculator类、HomeControler类依赖于ShoppingCart和LInqValueCalculator类。这就意味着,如果我们想替换LinqValueCalculator类,我们必须找到并更改与其紧密关联的引用。在简单的工程中,这种依赖不是什么大问题,但是如果在一个现实的大项目中,特别是我们希望实现各种不同的计算时,那就不再是仅仅用一个类替换另一个类的问题了。
应用接口
通过使用接口,我们可以解决部分问题。为了证明这点,我们在models文件夹下创建IValueCalculator.cs文件
publicinterfaceIValueCalculator { decimal ValueProducts(IEnumerable<Product> products); } |
然后,我们让LinqValueCalculator实现该接口(只是添加加粗的部分)
publicclassLinqValueCalculator : IValueCalculator { publicdecimal ValueProducts(IEnumerable<Product> products) { return products.Sum(p => p.Price); } } |
接口IValueCalculator可以使ShoppingCart与LinqValueCalculator类解耦,实现代码如下(加粗部分)
publicclassShoppingCart { privateIValueCalculator _calcucator;
public ShoppingCart(IValueCalculator calcucator) { _calcucator = calcucator; }
publicIEnumerable<Product> Products { get; set; }
publicdecimal CalculateProductToTotal() { return _calcucator.ValueProducts(Products); } } |
我们已经完成了一些更改,但是C#需要我们在实例化接口时为接口指定实现类。这就意味这我们需要在Home控制器的Index行为方法中,创建IValueCalculator的具体实现
publicActionResult Index() { IValueCalculator calculator = newLinqValueCalculator();
ShoppingCart shoppingCart = newShoppingCart(calculator){Products=products};
decimal totalValue = shoppingCart.CalculateProductToTotal();
return View(totalValue); } |
我们使用Ninject就是就是在我们想实例化接口IValueCalculator的时候,指定具体的实现类;而需要哪一个实现并不是Home控制器的一部分。
添加Ninject到Visual studio项目中
添加Ninject到MVC工程中最简单的方法就是使用Visual Studio自带的Nuget工具。在Visual Studio的菜单中,我们选择工具—Library package manager—package manage console(你也可以选择UI工具;或者直接下载dll,然后引入到工程中)
输入install-package ninject后,按回车键
Package manager 为自动找到Ninject的最新版本,并为你安装到当前的工程中
开始使用Ninject
为了使Ninject工作,需要三个基本的步骤。你可以在下面的代码中发现这三个步骤
………… using EssentialTools.Models; using Ninject;
namespace EssentialTools.Controllers { publicclassHomeController : Controller { privateProduct[] products = { newProduct {Name = "Kayak", Category = "Watersports", Price = 275M}, newProduct {Name = "Lifejacket", Category = "Watersports", Price = 48.95M}, newProduct {Name = "Soccer ball", Category = "Soccer", Price = 19.50M}, newProduct {Name = "Corner flag", Category = "Soccer", Price = 34.95M} };
publicActionResult Index() { IKernel ninjectKernal = newStandardKernel(); ninjectKernal.Bind<IValueCalculator>().To<LinqValueCalculator>();
IValueCalculator calculator = ninjectKernal.Get<IValueCalculator>();
ShoppingCart shoppingCart = newShoppingCart(calculator){Products=products};
decimal totalValue = shoppingCart.CalculateProductToTotal();
return View(totalValue); }
} } |
第一个步骤就是准备使用Ninject。我们需要创建Ninject的Kernel实例,该对象将用于和Ninject通讯,并从Ninject获取接口的具体实现类。下面就是创建kernal的代码
…… |
我们需要为Ninject.IKernel接口创建一个实现,我们创建了一个新的StandardKernel类的实例。这就允许我们进入第二个步骤,该步骤就是设置应用程序中接口和我们希望使用的实现类之间的关系:
…… ninjectKernal.Bind<IValueCalculator>().To<LinqValueCalculator>(); …… |
Ninject使用C#类型参数创建关系:我们设置我们将要使用的接口作为Bind方法的类型参数,然后调用To方法指定返回类型。我们为To方法指定我们想实例化的实现类。该语句告诉Ninject,当我们需要IValueValculator接口的实现时,Ninject将创建一个新的LInqValueCalculator实例。
最后一个步骤就是真正地使用Ninject,我们可以通过Get方法来完成
…… IValueCalculator calculator = ninjectKernal.Get<IValueCalculator>(); …… |
Get方法所使用的类型参数告诉Ninject,我们将使用哪一个接口;该方法的返回结果就是我们在To方法中指定的实现类的实例。
设置MVC的依赖注入
前面三个步骤的结果就是如何在Ninject完成当请求IValueCalculator接口时将使用哪个实现类的知识。然后,实际上,我们并没有最我们的应用做任何改进,因为上述的知识点只应用在Home控制器上——这也就意味着Home控制器仍然与LinqValueCalculator类有紧密的关系。
在接下来的章节中,我们将为你演示如何把Ninject嵌入我们示例的核心中;完成嵌入后,就将简化我们的控制器,扩大了Ninject的影响力,从而使其可以跨越整个应用程序,并在这个过程中发现一些Ninject的其他特性。
创建依赖链
首先,我们需要创建一个自定义的依赖解析器。MVC框架使用依赖解析器根据请求创建类的实例。通过创建自定义解析器,我们可以确保任何时候创建一个对象时都使用了Ninject。
在我们的示例工程中添加一个新的名为Infrastructure的文件夹,并添加一个名为NinjectDependencyResolver.cs文件,并添加如下文件
using System; using System.Collections.Generic; using System.Web.Mvc; using System.Configuration;
using Ninject; using Ninject.Parameters; using Ninject.Syntax; using EssentialTools.Models;
namespace EssentialTools.Infrastructure { publicclassNinjectDependencyResolver : IDependencyResolver { privateIKernel kernal;
public NinjectDependencyResolver() { kernal = newStandardKernel(); AddBindings(); }
publicobject GetService(Type serviceType) { return kernal.TryGet(serviceType); }
publicIEnumerable<object> GetServices(Type serviceType) { return kernal.GetAll(serviceType); }
privatevoid AddBindings() { kernal.Bind<IValueCalculator>().To<LinqValueCalculator>(); } } } |
为了服务一个请求所需要的实例时,MVC框架将调用GetService或GetServices方法。依赖解析器的职责就是创建实例——其实就是调用Ninject的TryGet和GetAll方法。TryGet方法和Get方法相类似,只不过当没有合适的绑定时返回null,而不是扔出一个异常。GetAll方法支持一个简单类型的多重绑定,它适用于多个服务提供者的场景。
上面的依赖解析类也就是我们完成设置Ninject绑定的地方。在AddBindings方法中,我们使用Bind方法和To方法设置IValueCalculator接口和LinqValueCalculator类之间的关系。
注册依赖解析器
完成依赖解析器后,我们还必须告诉MVC框架我们将使用自定义的依赖解析器。因为我们需要修改global.asax
protectedvoid Application_Start() { AreaRegistration.RegisterAllAreas();
DependencyResolver.SetResolver(newNinjectDependencyResolver());
WebApiConfig.Register(GlobalConfiguration.Configuration); FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); RouteConfig.RegisterRoutes(RouteTable.Routes); } |
添加这行代码后(加粗),Ninject将为MVC框架创建其需要的任何对象实例,这就把依赖注入放到MVC应用程序的核心位置了。
重构Home控制器
最后一步就是重构Home控制器:
publicclassHomeController : Controller { privateProduct[] products = { newProduct {Name = "Kayak", Category = "Watersports", Price = 275M}, newProduct {Name = "Lifejacket", Category = "Watersports", Price = 48.95M}, newProduct {Name = "Soccer ball", Category = "Soccer", Price = 19.50M}, newProduct {Name = "Corner flag", Category = "Soccer", Price = 34.95M} };
privateIValueCalculator calculator;
public HomeController(IValueCalculator calc) { calculator = calc; }
publicActionResult Index() { ShoppingCart shoppingCart = newShoppingCart(calculator){Products=products};
decimal totalValue = shoppingCart.CalculateProductToTotal();
return View(totalValue); } } |
最主要的变化就是我们添加了一个类构造器,该构造器接收一个IValueCalculator接口的实现。但我们并没有指定我们将使用哪一个实现,此外我们还添加了一个实例变量calculator,我们将使用它执相我们在构造器中接受的到IValueCalculator。另一个变化就是我们在Index行为方法中移除了与Ninject或LinqValueCalculator有关的所有代码——这就实现了Home控制器与LinquValueCalculator类解耦。此时,运行项目,你会得到同样的结果:
在示例程序中,我们演示了通过构造器完成注入,这只是依赖注入的一种方式。下面列举了当程序运行时具体发生了什么:
- MVC框架接收到请求,并发现该请求指向Home控制器(我们将在十五章介绍MVC框架如何识别请求并关联对应的控制器)
- MVC框架要求自定义依赖解析器类创建一个新的HomeController类的实例,通过使用GetService方法的Type参数指定HomeController类
- 通过调用TryGet方法上传递的类型对象,自定义依赖解析器要求Ninject创建一个新的HomeController类
- Ninject识别HomeController的构造器函数,并发现该构造器函数要求IValueCalculator的实现,这就是因为使用了Binding
- Ninject创建LInqValueCalculator类的实例,并用该实例创建HomeController实例
- Ninject把创建的HomeController实例传递给自定义依赖解析器,然后依赖解析器将其返回给MVC框架。MVC框架使用这个控制器实例相应请求。
我们只是花了少量的精力在这上面,这是因为在第一次使用DI的时候,DI可能会比较令人费解。我们上述所采用的实现方式的最大好处就是任何控制器都可以在其构造方法中声明需要一个IValueCalculator,并且Ninject将使用我们在AddBindings方法中指定的实现,尽管我们使用自定义依赖解析器。
上述实现方式,最好的地方在于,当我们需要替换LinqValueCalculator到其他实现类时,我们仅仅需要修改依赖解析器类就可以了。这是因为我们仅仅在AddBindings()方法中指定了IValueCalculator接口的具体实现类。
创建依赖链
当你要求Ninject创建一个类型时,Ninject检查该类型和其他类型之间的耦合关系。如果还存在额外的依赖关系,Ninject将自动解析依赖关系并创建需要的所有实例。为了演示该特性,我们在Models文件夹下创建一个名为Discount.cs的文件
namespace EssentialTools.Models { publicinterfaceIDiscountHelper { decimal ApplyDiscount(decimal total); }
publicclassDefaultDiscountHelper : IDiscountHelper { publicdecimal ApplyDiscount(decimal total) { return total - (10 / 100 * total); } } } |
IDiscountHelper定义了ApplyDiscount方法,该方法将对一个decimal值应用折扣。DefaultDiscountHelper类实现了该接口,并应用了1折的折扣。接下来,我们修改LinqValueCalculator类使其在计算总价的时候使用IDiscountHelper接口
publicclassLinqValueCalculator : IValueCalculator { privateIDiscountHelper _discountHelper;
public LinqValueCalculator(IDiscountHelper discountHelper) { _discountHelper = discountHelper; }
publicdecimal ValueProducts(IEnumerable<Product> products) { return _discountHelper.ApplyDiscount(products.Sum(p => p.Price)); } } |
我们新增了一个构造函数,该函数接受一个IDiscountHelper接口的实现,最后使用该实现产品的总价上应用折扣。
接下来,我们需要在自定义依赖解析器中的AddBindings方法中绑定IDiscountHelper接口和我们想使用的实现类的关系:
…… privatevoid AddBindings() { kernal.Bind<IValueCalculator>().To<LinqValueCalculator>(); kernal.Bind<IDiscountHelper>().To<DefaultDiscountHelper>(); } |
这样,我们就创建了依赖链,Ninject将使用我们定义在自定义绑定解析器中的绑定来解析依赖关系。为了处理对HomeController类的请求,Ninject意识到需要创建IValueCalculator接口的实现类,Ninject通过寻找绑定然后发现在我们的策略中该接口使用LinqValueCalculator类。但为了创建LinqValueCalculator对象,Ninject意识到还需要使用IDiscountHelper接口的实现,因为它再次查询绑定然后创建DefaultDiscountHelper对象。Ninject创建了DefaultDiscountHelper对象后,将该对象传递给LinqValueCalculator的构造函数从而创建LinqValueCalculator对象,然后把LinqValueCalculator对象传递给HomeController类,最后使用HomeController对象处理来自用户的请求。无论依赖链有多长,Ninject使用这种方式检查需要通过依赖关系实例化的类。
指定属性和构造参数值
我们还可以通过属性注入来完成绑定接口和实现类。为了演示该特性,我们修改了DefaultDiscountHelper
…… publicclassDefaultDiscountHelper : IDiscountHelper { publicdecimal DiscountSize { get; set; }
publicdecimal ApplyDiscount(decimal total) { return total - (DiscountSize / 100 * total); } } |
当我们在绑定在Ninject中绑定实现类时,我们需要通过WithPropertyValue方法来指定DefaultDiscountHelper类DiscountSize属性的值:
privatevoid AddBindings() { kernal.Bind<IValueCalculator>().To<LinqValueCalculator>(); kernal.Bind<IDiscountHelper>().To<DefaultDiscountHelper>().WithPropertyValue("DiscountSize", 50m); } |
请注意,我们必须提供属性的名字。此外,我们不需要更改其他的绑定,或则会更爱Get方法去获取ShoppingCart类的方式。现在我们会商品总价应用5折的折扣,为了验证其有效,现在我们运行应用程序,将会得到如下的结果:
如果你有多个属性需要设置,你可以多次调用WIthPropertyValue方法以覆盖所有的属性。我们也可以通过构造器参数来完成。
publicclassDefaultDiscountHelper : IDiscountHelper { privatedecimal _discountSize;
public DefaultDiscountHelper(decimal discountSize) { _discountSize = discountSize; }
publicdecimal ApplyDiscount(decimal total) { return total - (_discountSize / 100 * total); } } |
那么在这种情况下,当在Ninject绑定DefaultDiscountHelper时,我们需要这么做:
privatevoid AddBindings() { kernal.Bind<IValueCalculator>().To<LinqValueCalculator>(); kernal.Bind<IDiscountHelper>().To<DefaultDiscountHelper>().WithConstructorArgument("discountSize", 50m); } |
这种方式可以使我们通过构造器注入值。同样地,如果构造器函数包含多个擦书,我们需要多次的使用WithConstructorArgument方法以对每个参数赋值。Ninject会自动找到我们需要的并为我们自动创建。
使用条件绑定
Ninject支持一些条件绑定方法,这就允许我们指定根据特定的请求哪一个类将被该请求使用。为了演示这个特性,我们在Models文件夹下添加一个名为FlexibleDiscountHelper.cs的文件
publicclassFlexiableDiscountHelper : IDiscountHelper { publicdecimal ApplyDiscount(decimal total) { decimal discount = total > 100 ? 70 : 25;
return total - (discount / 100 * total); } } |
FlexiableDiscountHelper类根据总价的不同应用不同的折扣。然后我们修改NinjectDependencyResolver类的AddBindings方法,使Ninject在何时使用DefaultDiscountHelper何时使用FlexiableDiscountHelper
privatevoid AddBindings() { kernal.Bind<IValueCalculator>().To<LinqValueCalculator>(); kernal.Bind<IDiscountHelper>().To<DefaultDiscountHelper>().WithConstructorArgument("discountSize", 50m); kernal.Bind<IDiscountHelper>().To<FlexiableDiscountHelper>().WhenInjectedInto<LinqValueCalculator>(); } |
新的绑定指定当Ninject使用LinqvalueCalculator对象时,将选择FlexiableDiscountHelper作为IDiscountHelper接口的实现。
我们仍然保留了IDiscountHerlper使用DefaultDiscountHelper。Ninject将尝试找到最匹配的实现,这就有助于对于其他的接口和类(不使用IValueCalculator和LinqValueCalculator时)将使用默认的绑定。这样,当Ninject发现条件绑定不能被满足时,有一个备选。Ninject支持的其他条件绑定还有:
方法 |
作用 |
When(predicate) |
当条件(lamdba)为真时,使用该绑定 |
WhenClassHas<T>() |
当某个已经注入的类特性的类型时是T,使用该绑定 |
WhenInjectedInto<T>() |
当注入的类是类型T时,使用该绑定 |
3 单元测试
当前,有许多单元测试软件,其中许多都是开源并免费使用的。在本书,我们将使用Visual studio自带的单元测试,当然你也可以使用其他的.net单元测试软件。虽然NUnit最流行,但是单元测试软件基本上都完成同样的事情。我们选择Visual Studio自动单元测试的理由是我们喜欢使用和IDE集成在一起的工具,虽然Visual Studio 2012已经支持使用第三方测试工具,二期使用第三方和自带的工具的工作方式完全相似。
为了演示Visual studio自带的单元测试,我们对IDiscountHelper添加一个新的实现。我们在Models文件夹下添加一个名为MinimumDiscountHelper.cs文件
publicclassMinimumDiscountHelper : IDiscountHelper { publicdecimal ApplyDiscount(decimal total) { thrownewNotImplementedException(); } } |
在示例中我们使用MinimumDiscountHelper的目的是:
- 如果总价高于100,那么打九折
- 如果总价在10和100之间,那么总价减去5
- 如果总价少于10,那么没有折扣
- 如果总价为负数,抛出ArgumentOutOfRangeException异常
当前,我们的MinimumDiscountHelper并没有实现上述的任务目的,我们现在世界遵循测试驱动开发方式,首先编写单元测试,然后再实现代码
创建单元测试工程
首先,我们需要创建单元测试工程,我们在解决方案上点击右键,然后选择添加新的工程,并选择测试工程
设置工程的名字为EssentialTools.Test,然后点击确认俺就以创建新的工程,该工程自动添加到当前的解决方案中
接下来,我们需要为测试工程添加引用,从而允许我们使用测试工程来测试MVC工程中的类。在测试工程上的引用上点击右键,然后染香添加引用,然后选择解决方案—工程—EssentialTools。最后点击确认按钮安成添加引用。
创建单元测试
我们将在测试工程的Unitest1.cs文件中添加我们的单元测试。付费的Visual studio有一个好的特性就是可以直接为一个类自动生成测试方法,然后该特性在Visual Studi o Express版本中并没有。但我我们仍然可以创建有用的有意义的测试。下面我们手动添加单元测试到Unittet1.cs中
[TestClass] publicclassUnitTest1 { privateIDiscountHelper getTestObject() { returnnewMinimumDiscountHelper(); }
[TestMethod] publicvoid DiscountAbove200() { // arrange IDiscountHelper target = getTestObject(); decimal total = 200;
// act var discountedTotal = target.ApplyDiscount(total);
// assert Assert.AreEqual(total*0.9m, discountTotal); } } |
我们今天添加了一个单元测试。一个单元测试类必须添加[TestClass]特性,并且每个测试方法也必须添加[TestMethod]特性。并不是说有测试类的方法都必须是测试方法。为了演示这点,我们特别创建了一个名为getTestObject的方法,该方法没有添加[TestMeethod]特性,因此Visual Stuido不会将其当作一个测试方法。
你可以看到我们在测试方法中遵循了声明/行为/断言(A/A/A)模式。如何命名单元测试有许多规定,但本书我们的方针是你仅仅只需要使你的测试名清楚即可。我们的测试方法命名为DiscountAbove200,对于我们来讲,它非常清楚并且具有是一个有意义的名字。关于命名最关键的是你或者你的端对能理解你所采用的命名模式,因为你可以选择不同的命名方式以适应你的实际情况。
在我们的测试方法中,你首先调用getTestObject方法,该方法创建我们将测试的实例,在我们的例子中就是MinimumDiscountHelper类;我们还定义了一个total值用于测试。这就是单元测试中的声明。
对于单元测试中的行为,我们调用了MinimumDiscountHelper方法的ApplyDiscount方法,然后将其结果赋值给discountedTotal变量。最后在测试的断言部分,我们使用Assert.AreEqual方法检查我们从ApplyDiscount方法获取的值是否与总价的90%相等。
在单元测试中,你可以调用Assert类的众多静态方法。该类包含在Microsoft.VisualStudio.TestTools.UnitTesting命名空间下,这个命名空间下还包含了许多其他有用的单元测试类。你可以通过下面的链接了解到更多信息
- http://msdn.microsoft.com/en-us/library/ms182530.aspx
- http://msdn.microsoft.com/en-us/library/jj159340.aspx
因为Assert使用的最多,关于Assert的方法的使用,请参阅http://msdn.microsoft.com/en-us/library/microsoft.visualstudio.testtools.unittesting.assert.aspx
我们已经为你演示了如果完成一个单元测试,那么接着我们完成剩余的测试方法
[TestClass] publicclassUnitTest1 { privateIDiscountHelper getTestObject() { returnnewMinimumDiscountHelper(); }
[TestMethod] publicvoid DiscountedAbove200() { // arrange IDiscountHelper target = getTestObject(); decimal total = 200;
// act var discountTotal = target.ApplyDiscount(total);
// assert Assert.AreEqual(total*0.9m, discountTotal); }
[TestMethod] publicvoid DiscountedBetween10And100() { // arrange IDiscountHelper target = getTestObject();
// act var tenDollarDiscount = target.ApplyDiscount(10); var hundredDollarDiscount = target.ApplyDiscount(100); var fiftyDollarDiscount = target.ApplyDiscount(50);
// assert 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] publicvoid DiscountedLessThan10() { // arrange IDiscountHelper target = getTestObject();
// act var tenDollarDiscount = target.ApplyDiscount(10); var zeroDollarDiscount = target.ApplyDiscount(0);
// assert Assert.AreEqual(5, tenDollarDiscount); Assert.AreEqual(0, zeroDollarDiscount); }
[TestMethod] [ExpectedException(typeof(ArgumentOutOfRangeException))] publicvoid DiscountedNegativeTotal() { // arrange IDiscountHelper target = getTestObject();
// act target.ApplyDiscount(-1); } } |
运行单元测试
Visual Studio中引入了一个非常有用的单元测试窗口,通过该窗口可以管理和运行单元测试。在Visual Studio的测试菜单中,选择测试浏览器你就会看到该窗口。然后选择执行所有测试,你将会得到如下结果
你可以在测试浏览器看到一个测试列表。所有的测试都失败了,很显然,这是因为我们还没有对实现我们所测试的方法。你可以点击窗口中的任意一个在测试,那么测试失败的详细细信息会显示在测试窗口中。测试浏览器窗口提供了许多不同的方法去选择和过滤单元测试,然后选择需运行的测试。对于我们的示例工程,我们值需要点击run all命令运行所有单元测试即可。
实现特性
现在,我们就应该实现特性,这样当我们完成特性后,就可以使我们的代码满足我们的期望。因为我们经过精心地准备,现在实现MinimumDiscountHelper类的实现就非常容易了
publicclassMinimumDiscountHelper : IDiscountHelper { publicdecimal ApplyDiscount(decimal total) { if (total < 0) thrownewArgumentOutOfRangeException(); elseif (total > 100) return total * 0.9m; elseif (total > 10 && total <= 100) return total - 5; else return total; } } |
测试和修改代码
在上面的代码中,我们留了一个错误,以演示单元测试如何与Visual Studio交互,并看到错误的结果。我们再次点击run all;然后会得到如下的结果
这是因为我们的实现中,并没有涉及到总价等于10的情况。这样就不能满足我们的需求。OK知道原因后,我们做如下的修改
publicclassMinimumDiscountHelper : IDiscountHelper { publicdecimal ApplyDiscount(decimal total) { if (total < 0) thrownewArgumentOutOfRangeException(); elseif (total > 100) return total * 0.9m; elseif (total >= 10 && total <= 100) return total - 5; else return total; } } |
然后,我们再次点击运行所有测试,那么你将会看到所有的测试都会通过。
我们快速地为你演示了如何进行单元测试,在后续的章节里我们还会继续为你演示单元测试。Visual Studio可以完美地支持单元测试,因此我们推荐你通过链接http://msdn.microsoft.com/en-us/library/dd264975.aspx在MSDN上查阅单元测试的详细内容。
4使用Moq
在上一章中,我们能够保证我们测试简单的一个原因是我们测试的是单个类,而且这个类不依赖于其他的类。这样的对象在现实的工程中的确存在,但是你还需要测试不能单独工作的类(测试的类依赖于其他类)。在这种情况下,你需要把注意力放在你感兴趣的类和方法上,而不应该同时隐式地测试了其依赖的类。
一种有效的实现方式就是使用mock对系那个,它用以模拟工程中真是的对象的功能,以一种特性的和可控的方式。Mock对象允许你集中注意力在测试上,从而使你只需要测试你需要测试的类或方法。
付费版的Visual Studio 2012自带了通过fakes创建mock对象,但是我们推荐使用Moq库,它非常简单,易于使用,而且能和Visual Studio的各个版本集成在一起,此外,它还是免费的。
理解问题
在我们使用Moq之前,我们将为你演示我们试图修复的问题。在本小节,我们将对LinqValueCalculator类进行单元测试。为了测试该类,我们在测试工程中添加一个名为UnitTest2.cs单元测试文件
[TestClass] publicclassUnitTest2 { privateProduct[] products = { newProduct {Name = "Kayak", Category = "Watersports", Price = 275M}, newProduct {Name = "Lifejacket", Category = "Watersports", Price = 48.95M}, newProduct {Name = "Soccer ball", Category = "Soccer", Price = 19.50M}, newProduct {Name = "Corner flag", Category = "Soccer", Price = 34.95M} };
[TestMethod] publicvoid SumProductsCorrectly() { // arrange var discounter = newMinimumDiscountHelper(); var target = newLinqValueCalculator(discounter); var goalTotal = products.Sum(p => p.Price);
// action var result = target.ValueProducts(products);
// assert Assert.AreEqual(goalTotal, result); } } |
现在的问题在于,LinqValueCalculator类依赖于接口IDiscountHelper的实现。在我们的例子中,我们使用了MinimumDiscountHelper类,那么这就会带来两个问题
- 我们使我们的测试框架变得复杂且脆弱。为了创建一个可以工作的单元测试,我们需要考虑IDiscountHelper实现类的折扣逻辑,从而指定来自于ValueProducts的期望值。而脆弱性则是以为,如果我们更改折扣逻辑,那么我们的测试将失败
- 其次,最麻烦的是,我们扩大了单元测试的范围,从而导致隐式地包含了MimimumDiscountHelper类。如果我们的单元测试失败了,我们将不知道失败的原因到底是LinqValueCalculator引起的还是由MinimumDIscountHelper引起的。
当单元测试简单而又专注时,单元测试可以更好的工作。我们现在建立的单元测试不满足任何一个条件。因为,我们接下来将介绍如何在MVC项目中应用Moq从而避免这些问题。
添加Moq到Visual Studio项目中
添加Moq对象到单元测试
[TestMethod] publicvoid SumProductsCorrectly() { // arrange Mock<IDiscountHelper> mock = newMock<IDiscountHelper>(); mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total); var target = newLinqValueCalculator(mock.Object);
// action var result = target.ValueProducts(products);
// assert Assert.AreEqual(products.Sum(p=>p.Price), result); } |
创建Mock对象
第一步是告诉Moq我们将使用什么类型的mock对象。Moq深度依赖generic类型参数,上面示例中我们告诉Moq我们希望创建一个mock的IDiscountHelper的实现
Mock<IDiscountHelper> mock = newMock<IDiscountHelper>(); |
我们创建了一个强类型的Mock<IDiscountHelper>对象,这就告诉Moq库需要处理的类型—在我们的示例程序中,这个类型就是IDiscountHelper接口。
选择一个方法
在创建完墙类型的Mock对象后,我们需要为Moq指定一个执行的方法—这也是整个mocking过程最核心的部分,因为这允许你确保你为你的mock对象创建了一个基本的行为底线,从而你就可以在单元测试中使用mock对象特使你关注对象的功能。这个声明在示例程序中是这样的:
mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total); |
我们使用Setup方法添加一个放到到mock对象。Moq与LINQ相似,使用lamdab表达式。当我们调用Setup方法后,Moq把我们要求并用以实现的接口传递给我们。请注意后面的lamdba表达式实现的方式非常灵活,我们并不打算深入讲解该lamdba表达式。结合实例程序,该lamdba表达式要做的事情就是:我们希望定义ApplyDiscount方法的行为,ApplyDiscount也是IDiscountHelper接口的唯一方法,并且也是我们需要测试的类LinqValueCalculator的方法。
我们还告诉Moq我们感兴趣的参数值,在这里我们使用It类。It类定义了一系列的方法,这些方法使用generic类型的参数。在我们的例子者噢乖,我们调用了IsAny方法,并使用decimal作为generic类型参数。这就是告诉Moq,一旦带decimal参数的ApplyDiscount方法被调用时,我们定义的行为就会发生作用。(在本章后续的内容中,我们会继续介绍It类的其他方法的使用。)
定义结果
Returns方法允许我们指定当mocked方法调用后将返回的结果。我们通过类型参数指定结果的类型,并且通过lamdba表达式指定返回的结果
mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total); |
当调用带有deciaml类型参数的Returns方法时,我们告诉Moq我们将返回decimal值。对于lamdba表达式(total=>total),Moq把我们从ApplyDiscount方法中接收到的类型的值传递给我们——在示例中,我们创建了一个pass-through方法,通过这个方法,我们为mock的 ApplyDiscount方法传递一个值,然后不对这个值执行任何操作,最后返回该值。这就是最简单的mock方法,很快我们会为你介绍复杂的mock方法。
使用Mock对象
最后一个步骤就是在单元测试中使用mock对象,我们所做的就是读取Mock<IDiscountHelper>对象的Object属性
var target = newLinqValueCalculator(mock.Object); |
简而言之,在我们的示例中,Object属性返回IDiscountHelper接口的实现,而ApplyDiscount方法根据传入参数dicimal,返回一个decimal值。
这就使得我们单元测试的执行变得非常简单,因为我们可以计算测试产品对象的总价,并检查该总价是否与通过LinqValueCalculator对象计算的结果一致。
Assert.AreEqual(products.Sum(p=>p.Price), result); |
使用Moq的好处是:通过这种方式,我们的单元测试仅仅检查LinqValueCalculator对象的行为,而且不需要依赖IDiscounthHelper接口任何具体的实现。这就意味着即使我们的单元测试失败,我们就知道问题要么是因为LinqValueCalculator实现,要么是因为我们建立mock对象的方式。那么,从这两个方面去解决问题就比从真正对象链以及它们之间的互操作来得简单和容易
创建复杂的Mock对象
在前面,我们演示了一个简单的mock对象。但是Moq的强大之处是能够快速地创建复杂的行为,以测试不同的场景。在下面的代码中,我们在unittest2.cs中添加一个新的单元测试,mock一个复杂的IDiscountHelper接口的实现
[TestClass] publicclassUnitTest2 { ……
privateProduct[] createProduct(decimal value) { returnnew[] { newProduct { Price = value } }; }
[TestMethod] [ExpectedException(typeof(ArgumentOutOfRangeException))] publicvoid PassThroughVariableDiscoutns() { // arrange Mock<IDiscountHelper> mock = newMock<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<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 = newLinqValueCalculator(mock.Object);
// act 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 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方法的顺序影响mock对象的行为。归于给定的mock对象的顺序,Moq按照反方向的顺序评估mock对象的行为。这就意味着你必须小心地创建mock行为,应当先创建通用的行为,然后特定的行为。在上面示例程序中,It.IsAny<Deciaml>条件就是最通用的条件条件,因为它最先应用。如果我们调换Setup的顺序,那么该行为将会捕获所有对于ApplyDiscount的调用,并生成错误的结果。
Mocking特定的值
第二个Setup方法,我们是哟给了It.Is方法。当传递给ApplyDiscount方法的参数值为0时,该方法返回true。我们使用Throws方法,而非返回一个结果,这会使Moq根据类型参数扔出一个异常;同样地,当参数值大于100时,我们同样地使用了Is方法。 It.Is方法是创建特定行为的最灵活的方式,因为你可以使用任何判断来返回true或false。这也是我们在创建复杂mock对象时经常使用的方法
Mocking一个值的范围
最后,我们使用了IsInRange方法,通过该方法我们可以捕获一个参数的范围。我们使用IsInRange方法是为了演示It类的各种方法。但是在实际的项目中,我们还是推荐按照下面的方式使用Is方法
mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v >= 10 && v <= 100))).Returns<decimal>(total => total - 5); |
作用是一样的,但是我们认为lamdab判断更为灵活。Moq有大量非常有用的特性,如果你有兴趣,请参阅http://code.google.com/p/moq/wiki/QuickStart
总结
在本章,我们介绍了三个MVC开发的基本工具,它们是Ninject,自带的单元测试工具,以及Moq。当然对于这三个工具,还有许多替换产品,既有开源的,也有商业的;因此对于这三个产品,你并不缺少替代品,当你比喜欢我们介绍的三个工具时,你可以使用你喜欢的其他类似产品。
你或许会发现你不喜欢TDD或者单元测试,或者你喜欢使用DI并手动实现Mock。当然,所有这些,都取决于你的选择。但是,我们认为在开发过程中使用这三个工具会带来大量的好处。如果你因为未使用过它们而在犹豫是否接受它们,那么我们鼓励你停怀疑并大胆的使用它们,至少在阅读本书的过程中使用它们。