6.1 示例项目
项目结构:
6.1.1 创建模型类
Product.cs;计算Product对象集合的总价类LinqValueCalculator.cs;表示Product集合,并使用LinqValueCalculator来确定总价的类ShoppingCart.cs
1 public class Product 2 { 3 public int ProductID { get; set; } 4 public string Name { get; set; } 5 public string Description { get; set; } 6 public decimal Price { get; set; } 7 public string Category { get; set; } 8 } 9 10 /// <summary> 11 /// 计算Product对象集合总价 12 /// </summary> 13 public class LinqValueCalculator 14 { 15 public decimal ValueProducts(IEnumerable<Product> products) 16 { 17 return products.Sum(p => p.Price); 18 } 19 } 20 21 /// <summary> 22 /// 表示Product集合,并使用LinqValueCalculator来确定总价 23 /// </summary> 24 public class ShoppingCart 25 { 26 private LinqValueCalculator calc; 27 28 public ShoppingCart(LinqValueCalculator calcParam) 29 { 30 calc = calcParam; 31 } 32 33 public IEnumerable<Product> Products { get; set; } 34 public decimal CalculateProductTotal() 35 { 36 return calc.ValueProducts(Products); 37 } 38 }
6.1.2 添加控制器
1 public class HomeController : Controller 2 { 3 private Product[] products = 4 { 5 new Product {Name = "Kayak", Category = "Watersports", Price = 275M}, 6 new Product{Name = "Lifejacket", Category = "Watersports", Price = 48.95M}, 7 new Product{Name = "Soccer ball", Category = "Soccer", Price = 19.50M}, 8 new Product{Name = "Corner flag", Category = "Soccer", Price = 34.95M} 9 }; 10 11 // GET: Home 12 public ActionResult Index() 13 { 14 LinqValueCalculator calc=new LinqValueCalculator(); 15 ShoppingCart cart=new ShoppingCart(calc){Products = products}; 16 decimal totalValue = cart.CalculateProductTotal(); 17 18 return View(totalValue); 19 } 20 }
6.1.3 添加视图
1 @model decimal 2 @{ 3 Layout = null; 4 } 5 6 <!DOCTYPE html> 7 8 <html> 9 <head> 10 <meta name="viewport" content="width=device-width" /> 11 <title>Value</title> 12 </head> 13 <body> 14 <div> 15 Total value is $@Model 16 </div> 17 </body> 18 </html>
6.2 使用Ninject
DI的思想是,对MVC应用程序中的组件进行解耦,这是通过接口与DI容器相结合来实现的。DI容器创建了对象实例,这是通过创建对象所依赖的接口并将其注入构造器而实现 的。
6.2.1 理解问题
上述实例中,它含有用DI解决的基本问题:紧耦合类。ShoppingCart类与LinqValueCalculator类是紧耦合的,而HomeController类与ShoppingCart和LinqValueCalculator都是紧耦合的。
这意味着,如果替换LinqValueCalculator类,就必须在与它有紧耦合关系的累中找出对它的引用,并进行修改。
运用接口
通过使用C#接口,从计算器的实现中抽象出其功能定义,我们可以解决部分问题。在Models文件夹中添加一个IValueCalculator.cs类文件。
1 public interface IValueCalculator 2 { 3 decimal ValueProducts(IEnumerable<Product> products); 4 }
在LinqValueCalculator类中实现这一接口
1 /// <summary> 2 /// 计算Product对象集合总价 3 /// </summary> 4 public class LinqValueCalculator: IValueCalculator 5 { 6 public decimal ValueProducts(IEnumerable<Product> products) 7 { 8 return products.Sum(p => p.Price); 9 } 10 }
该接口可以打断ShoppingCart与LinqValueCalculator类之间的紧耦合关系
1 /// <summary> 2 /// 表示Product集合,并使用LinqValueCalculator来确定总价 3 /// </summary> 4 public class ShoppingCart 5 { 6 private IValueCalculator calc; 7 8 public ShoppingCart(IValueCalculator calcParam) 9 { 10 calc = calcParam; 11 } 12 13 public IEnumerable<Product> Products { get; set; } 14 public decimal CalculateProductTotal() 15 { 16 return calc.ValueProducts(Products); 17 } 18 }
上述过程已经解除了ShoppingCart与ValueCalculator之间的耦合,因为在使用ShoppingCart时,只要为其构造器传递一个IValueCalculator接口对象就行了,于是,ShoppingCart类与IValueCalculator的实现类不再有直接联系,但是C#要求在接口实例化时要指定其实现类,这很好理解,因为它需要指定你想要用的是哪一个实现类。这意味着,Home控制器在创建LinqValueCalculator对象时仍有问题,如下HomeController.cs文件中的Index方法所示:
1 public ActionResult Index() 2 { 3 //LinqValueCalculator calc=new LinqValueCalculator();//1,原始的:紧耦合 4 IValueCalculator calc = new LinqValueCalculator();//2,依赖接口:紧耦合 5 ShoppingCart cart=new ShoppingCart(calc){Products = products}; 6 decimal totalValue = cart.CalculateProductTotal(); 7 8 return View(totalValue); 9 }
使用Ninject的目的就是解决此问题,用以对IValueCalculator接口的实现进行实例化,但所需要的实现细节不是Home控制器代码的一部分(意即,通过Ninject,可以去掉上述Index方法中IValueCalculator这一行,这项工作由Ninject完成,这样便去掉了Home控制器与总价计算器LinqValueCalculator直接的耦合)。
这意味着告诉Ninject,LinqValueCalculator是你希望它用于IValueCalculator接口的实现,并且要修改HomeController类,以使它能够通过Ninject而不是new关键字来获取对象。
6.2.2 将Ninject添加到Visual Studio项目
使用“Package Manager Console(包管理控制台)”,输入以下命令。
Install-Package Ninject -version 3.0.1.10
Install-Package Ninject.Web.Common -version 3.0.0.7
Install-Package Ninject.MVC3 -Version 3.0.0.6
第一行用于安装Ninject内核包,其他命令用于安装内核的扩展包,以使Ninject能更好的与ASP.NET协同工作。
6.2.3 Ninject初步
为了得到Ninject的基本功能,要做的工作分3个阶段,下面首先对Home控制器做修改。
1 using System; 2 using System.Collections.Generic; 3 using System.Linq; 4 using System.Web; 5 using System.Web.Mvc; 6 using EssentialTools.Models; 7 using Ninject;//引入Ninject命名控件 8 9 namespace EssentialTools.Controllers 10 { 11 public class HomeController : Controller 12 { 13 private Product[] products = 14 { 15 new Product {Name = "Kayak", Category = "Watersports", Price = 275M}, 16 new Product{Name = "Lifejacket", Category = "Watersports", Price = 48.95M}, 17 new Product{Name = "Soccer ball", Category = "Soccer", Price = 19.50M}, 18 new Product{Name = "Corner flag", Category = "Soccer", Price = 34.95M} 19 }; 20 21 // GET: Home 22 public ActionResult Index() 23 { 24 //LinqValueCalculator calc=new LinqValueCalculator();//方式1,原始的:紧耦合 25 //IValueCalculator calc = new LinqValueCalculator();//方式2,依赖接口:紧耦合 26 IKernel ninjectKernel=new StandardKernel();//方式3,使用Ninject 27 ninjectKernel.Bind<IValueCalculator>().To<LinqValueCalculator>(); 28 IValueCalculator calc = ninjectKernel.Get<IValueCalculator>(); 29 ShoppingCart cart=new ShoppingCart(calc){Products = products}; 30 decimal totalValue = cart.CalculateProductTotal(); 31 32 return View(totalValue); 33 } 34 } 35 }
第一个阶段是准备使用Ninject。为此,创建一个Ninject的内核(Kernel)实例,该实例是一个对象(内核对象),它负责解析依赖项并创建新的对象(为依赖项创建的对象)。当需要一个对象时,将使用这个内核而不是使用new关键字。
IKernel ninjectKernel=new StandardKernel();//创建内核
我们需要创建一个Ninject.IKernel接口的实现,可通过创建一个StandardKernel类的新实例来完成。对Ninject进行扩展和定制,可以使用不同种类的内核,但本章只需要这个内置的StandardKernel(标准内核)。
第二阶段是配置Ninject内核,使其理解我们用到的每一个接口所希望使用的实现对象。以下是清单中完成这项工作的语句:
ninjectKernel.Bind<IValueCalculator>().To<LinqValueCalculator>();
Ninject使用C#的类型参数创建了一种关系:将想要使用的接口设置为Bind方法的类型参数,并在其返回的结果上调用To方法。将希望实例化的实现类设置为To方法的类型参数。该语句告诉Ninject,IValueCalculator接口的依赖项应该通过创建LinqValueCalculator类的实例进行解析。最后一个步骤是使用Ninject来创建一个对象,其做法是调用内核的Get方法,如下所示:
IValueCalculator calc=ninjectKernel.Get<IValueCalculator>();
Get方法所使用的类型参数告诉Ninject,我们感兴趣的是哪一个接口,而该方法的结果是刚才用To方法指定的实现类型的一个实例。
6.2.4 建立MVC的依赖项注入
1,创建依赖项解析器
MVC框架需要使用依赖项解析器来创建类的实例,以便对请求进行服务。通过创建自定义解析器,便能保证MVC框架在任何时候都能使用Ninject创建一个对象——包括控制器实例。新建Infrastructure文件夹,用于放置MVC应用程序中不适合放在其他文件夹的类。添加类NinjectDependencyResolver.cs,
1 public class NinjectDependencyResolver:IDependencyResolver 2 { 3 private IKernel kernel; 4 5 public NinjectDependencyResolver(IKernel kernelParam) 6 { 7 kernel = kernelParam; 8 AddBindings(); 9 } 10 public object GetService(Type serviceType) 11 { 12 return kernel.TryGet(serviceType); 13 } 14 15 public IEnumerable<object> GetServices(Type serviceType) 16 { 17 return kernel.GetAll(serviceType); 18 } 19 20 private void AddBindings() 21 { 22 kernel.Bind<IValueCalculator>().To<LinqValueCalculator>(); 23 } 24 }
NinjectDependencyResolver类实现了IDependencyResolver接口,它属于System.Mvc命名空间,也由MVC框架用于获取其所需的对象。MVC卷国家在需要实例以便对一个传入的请求进行服务时,会调用GetService或GetServices方法。依赖项解析器要做的便是创建这一实例——这是一项要通过调用Ninject的TryGet和GetAll方法来完成的任务。TryGet方法的工作方式类似与前面所用的Get方法,但当没有合适的绑定时,它会返回null,而不是抛出一个异常。GetAll方法支持对单一类型的多个绑定,当有多个不同的服务提供器可用时,可以使用它。
2,注册依赖项解析器
使用NuGet添加的Ninject包在App_Start文件夹中创建了一个名称为NinjectWebCommon.cs的文件,它定义了应用程序启动时会自动调用的一些方法,目的是将它们集成到ASP.NET的请求生命周期中。在NinjectWebCommon类的RegisterServices方法中,添加一条语句,用于创建一个NinjectDependencyResolver类的实例,并用System.Web.Mvc.DependencyResolver类定义的SetResolver静态方法将其注册为MVC框架的解析器,该语句的作用是为了在Ninject和MVC框架之间创建一个支持DI的桥梁。
1 private static void RegisterServices(IKernel kernel) 2 { 3 System.Web.Mvc.DependencyResolver.SetResolver(new EssentialTools.Infrastructure.NinjectDependencyResolver(kernel)); 4 }
3,重构Home控制器
1 public class HomeController : Controller 2 { 3 private IValueCalculator calc; 4 private Product[] products = 5 { 6 new Product {Name = "Kayak", Category = "Watersports", Price = 275M}, 7 new Product{Name = "Lifejacket", Category = "Watersports", Price = 48.95M}, 8 new Product{Name = "Soccer ball", Category = "Soccer", Price = 19.50M}, 9 new Product{Name = "Corner flag", Category = "Soccer", Price = 34.95M} 10 }; 11 12 public HomeController(IValueCalculator calcParam,IValueCalculator calc2) 13 { 14 calc = calcParam; 15 16 } 17 // GET: Home 18 public ActionResult Index() 19 { 20 //LinqValueCalculator calc=new LinqValueCalculator();//1,原始的:紧耦合 21 //IValueCalculator calc = new LinqValueCalculator();//2,依赖接口:紧耦合 22 //IKernel ninjectKernel=new StandardKernel();//3,创建内核 23 //ninjectKernel.Bind<IValueCalculator>().To<LinqValueCalculator>(); 24 //IValueCalculator calc = ninjectKernel.Get<IValueCalculator>(); 25 ShoppingCart cart=new ShoppingCart(calc){Products = products}; 26 decimal totalValue = cart.CalculateProductTotal(); 27 28 return View(totalValue); 29 } 30 }
所做的主要修改是添加了一个类构造器,用于接收IValueCalculator接口的实现,即修改HomeController类,使其声明一个依赖项。Ninject会在创建该控制器实例时,使用建立起来的配置,为该控制器创建一个实现IValueCalculator接口的对象。
所做的另一个修改是从控制器中删除了任何关于Ninject或LinqValueCalculator类的代码。最终,我们打破了HomeController与LinqValueCalculator类之间的紧耦合。
以上创建的是一个构造器注入示例,这是依赖项注入的一种形式。以下是运行示例应用程序,且Internet Explorer对应用程序的跟URL发送请求时所发生的情况。
- 浏览器向MVC框架发送一个请求Home的URL,MVC框架推猜出该请求意指Home控制器,于是创建HomeController类示例。
- MVC框架在创建HomeController类示例过程中会发现其构造器有一个对IValueCalculator接口的依赖项,于是会要求依赖项解析器对此依赖项进行解析,将该接口指定为依赖项解析器中GetService方法所使用的类型参数。
- 依赖项解析器会将传递过来的类型参数交给TryGet方法,要求Ninject创建一个新的HomeController接口类实例。
- Ninject会检测到HomeController构造器与其实现类LinqValueCalculator具有绑定关系,于是为该接口创建一个LinqValueCalculator类实例,并将其回递给依赖项解析器。
- 依赖项解析器将Ninject所返回的LinqValueCalculator类作为IValueCalculator接口实现类实例回递给MVC框架。
- MVC框架利用依赖项解析器返回的接口类实例创建HomeController控制器实例,并使用该控制器实例对请求进行服务。
这里所采取的办法其好处之一是,任何控制器都可以在应用程序中声明一个解析器,并由MVC框架使用Ninject来实现。
所得到的最大好处是,在希望用另一个实现来替代LinqValueCalculator时,只需要对依赖项解析器类进行修改。为了满足对于IValueCalculator接口的依赖项,这里是唯一一处需要为该接口指定实现类的地方。
6.2.5 创建依赖项链
当要求Ninject创建一个类型时,它会检查该类型所声明的依赖项。它也会检查这些依赖项是否依赖于其他类型,如果有额外的依赖项,Ninject会自动地解析这些依赖项,并创建所需要的所有类的实例,以这种方式处理依赖项链,最终便能够创建所需类型的实例(本段描述了Ninject处理依赖项链的工作方式)。
在Models文件夹中添加一个Discount.cs类,并用它定义了一个新的接口及其实现类,
1 public interface IDiscountHelper 2 { 3 decimal ApplyDiscount(decimal totalParam); 4 } 5 6 public class DefaultDiscountHelper : IDiscountHelper 7 { 8 //public decimal DiscountSize { get; set; } 9 public decimal discountSize; 10 11 public DefaultDiscountHelper(decimal discountParam) 12 { 13 discountSize = discountParam; 14 } 15 public decimal ApplyDiscount(decimal totalParam) 16 { 17 return (totalParam - (discountSize / 100m * totalParam)); 18 } 19 }
我们修改LinqValueCalculator类,以使它执行计算时使用IDiscountHelper接口,
1 /// <summary> 2 /// 计算Product对象集合总价 3 /// </summary> 4 public class LinqValueCalculator: IValueCalculator 5 { 6 private IDiscountHelper discounter; 7 public LinqValueCalculator(IDiscountHelper discountParam) 8 { 9 discounter = discountParam; 10 } 11 public decimal ValueProducts(IEnumerable<Product> products) 12 { 13 //return products.Sum(p => p.Price); 14 return discounter.ApplyDiscount(products.Sum(p => p.Price)); 15 } 16 }
如同对IValueCalculator所做的那样,在NinjectDependencyResolver类中用Ninject内核将IDiscountHelper接口与其实现类进行绑定,
1 private void AddBindings() 2 { 3 kernel.Bind<IValueCalculator>().To<LinqValueCalculator>(); 4 kernel.Bind<IDiscountHelper>().To<DefaultDiscountHelper>(); 5 }
上述这一做法已经创建了一个依赖项链。此时Home控制器依赖于IValueCalculator接口,我们已经告诉Ninject用LinqValueCalculator类对该接口进行解析。LinqValueCalculator类又依赖于IDiscountHelper接口,我们又告诉Ninject用DefaultDiscountHelper类对其进行解析。
Ninject能够平滑地解析这种依赖项链,创建所需的对象为每一个依赖项进行解析,于是最终能够创建一个HomeController类的实例,从而对一个HTTP请求进行服务。
6.2.6 指定属性和构造器参数值
在将接口与其实现进行绑定时,可以为属性提供一些值方面的细节,以便对Ninject创建的对象进行配置。修改DefaultDiscountHelper类,以使它定义一个DiscountSize属性,将其用于计算折扣量,
1 public interface IDiscountHelper 2 { 3 decimal ApplyDiscount(decimal totalParam); 4 } 5 6 public class DefaultDiscountHelper : IDiscountHelper 7 { 8 public decimal DiscountSize { get; set; } 9 10 public decimal ApplyDiscount(decimal totalParam) 11 { 12 return (totalParam - (DiscountSize / 100m * totalParam)); 13 } 14 }
在告诉Ninject一个接口需要使用的是哪一个类时,可以用WithPropertyValue方法为DefaultDiscountHelper类中的DiscountSize属性设置一个值。修改NinjectDependencyResolver类中的AddBindings,需要设置的属性名称是以字符串形式提供的。
1 private void AddBindings() 2 { 3 kernel.Bind<IValueCalculator>().To<LinqValueCalculator>(); 4 kernel.Bind<IDiscountHelper>().To<DefaultDiscountHelper>().WithPropertyValue("DiscountSize",50M); 5 }
如果需要设置多个属性值,可以链接调用WithPropertyValue方法,以涵盖所有这些属性,也可以用构造器参数做同样的事情。重写DefaultDiscountHelper,以使折扣大小作为构造器参数进行传递。
1 public interface IDiscountHelper 2 { 3 decimal ApplyDiscount(decimal totalParam); 4 } 5 6 public class DefaultDiscountHelper : IDiscountHelper 7 { 8 //public decimal DiscountSize { get; set; } 9 public decimal discountSize; 10 11 public DefaultDiscountHelper(decimal discountParam) 12 { 13 discountSize = discountParam; 14 } 15 public decimal ApplyDiscount(decimal totalParam) 16 { 17 return (totalParam - (discountSize / 100m * totalParam)); 18 } 19 }
为了绑定这个类,可以在AddBindings方法中用WithConstructorArgument方法来指定构造器参数的值。
1 private void AddBindings() 2 { 3 kernel.Bind<IValueCalculator>().To<LinqValueCalculator>(); 4 kernel.Bind<IDiscountHelper>().To<DefaultDiscountHelper>().WithConstructorArgument("discountParam", 50M); 5 }
6.2.7 使用条件绑定
Ninject支持多个条件的绑定方法,这让我们能够指定内核用哪一个类对某一特定的接口进行相应。在Models文件夹中添加FlexibleDiscountHelper.cs,
1 public class FlexibleDiscountHelper : IDiscountHelper 2 { 3 public decimal ApplyDiscount(decimal totalParam) 4 { 5 decimal discount = totalParam > 100 ? 70 : 25; 6 return (totalParam - (discount / 100m * totalParam)); 7 } 8 }
这个类会根据总额大小运用不同的折扣,于是我们需要对IDiscountHelper接口的实现类进行选择,这可以修改NinjectDependencyResolver的AddBindings方法,
1 private void AddBindings() 2 { 3 kernel.Bind<IValueCalculator>().To<LinqValueCalculator>(); 4 //kernel.Bind<IDiscountHelper>().To<DefaultDiscountHelper>().WithPropertyValue("DiscountSize",50M); 5 kernel.Bind<IDiscountHelper>().To<DefaultDiscountHelper>().WithConstructorArgument("discountParam", 50M); 6 kernel.Bind<IDiscountHelper>().To<FlexibleDiscountHelper>().WhenInjectedInto<LinqValueCalculator>(); 7 }
上述新绑定声明,在Ninject内核要创建一个LinqValueCalculator对象时,应该使用FlexibleDiscountHelper类作为IDiscountHelper接口的实现。注意,我们在适当的位置留下了对IDiscountHelper的原有绑定。Ninject会尝试找出具有最佳匹配,而且这有助于对同一个类或接口采用一个默认绑定,以便在条件判据不能满足时,让Ninject能够进行回滚。Ninject有许多不同的条件绑定方法,最有用的一些条件绑定如下,
6.2.8 设置对象作用域
最后一个Ninject特性有助于调整Ninject所建对象的生命周期,以满足应用程序的需求。默认情况下,Ninject会在每次请求一个对象时,为每个依赖项所需的各个对象创建一个新实例。
修改LinqValueCalculator类的构造器,以便在每次创建一个新实例时都向Visual Studio的输出窗口写一条消息,
1 public class LinqValueCalculator: IValueCalculator 2 { 3 private IDiscountHelper discounter; 4 private static int counter = 0; 5 public LinqValueCalculator(IDiscountHelper discountParam) 6 { 7 discounter = discountParam; 8 System.Diagnostics.Debug.WriteLine(string.Format("Instance {0} created",++counter)); 9 } 10 public decimal ValueProducts(IEnumerable<Product> products) 11 { 12 //return products.Sum(p => p.Price); 13 return discounter.ApplyDiscount(products.Sum(p => p.Price)); 14 } 15 }
System.Diagnostics.Debug类包含了一些用来写出调试信息的方法,修改Home控制器,它要求从Ninject获得theIValueCalculator接口的两个实现。
1 public HomeController(IValueCalculator calcParam,IValueCalculator calc2) 2 { 3 calc = calcParam; 4 }
我们并未用Ninject提供的对象执行任何有用的任务——重要的是请求了该接口的两个实现。
对于某些类,我们会希望在整个应用程序中共享一个单一的实例。而对于另一些类,又会希望ASP.NET平台多接受到的每个HTTP请求,都创建一个新的实例。Ninject让我们能够使用一种叫做“作用域(Scope)”的特性来控制所创建对象的生命周期,这是在建立接口与其实现之间的绑定时,通过方法电泳来表示的。如下代码可以看出如何将最有用的作用域运用于MVC框架的应用程序:在NinjectDependencyResolver中将“请求作用域(Request Scope)”运用于LinqValueCalculator类(注意,在以下代码中,对InRequestScope方法的调用就是运用“请求作用域”)。
1 public class NinjectDependencyResolver:IDependencyResolver 2 { 3 private IKernel kernel; 4 5 public NinjectDependencyResolver(IKernel kernelParam) 6 { 7 kernel = kernelParam; 8 AddBindings(); 9 } 10 public object GetService(Type serviceType) 11 { 12 return kernel.TryGet(serviceType); 13 } 14 15 public IEnumerable<object> GetServices(Type serviceType) 16 { 17 return kernel.GetAll(serviceType); 18 } 19 20 private void AddBindings() 21 { 22 //kernel.Bind<IValueCalculator>().To<LinqValueCalculator>(); 23 kernel.Bind<IValueCalculator>().To<LinqValueCalculator>().InRequestScope();//请求作用域 24 //kernel.Bind<IDiscountHelper>().To<DefaultDiscountHelper>().WithPropertyValue("DiscountSize",50M); 25 kernel.Bind<IDiscountHelper>().To<DefaultDiscountHelper>().WithConstructorArgument("discountParam", 50M); 26 kernel.Bind<IDiscountHelper>().To<FlexibleDiscountHelper>().WhenInjectedInto<LinqValueCalculator>(); 27 } 28 }
InRequestScope扩展方法属于Ninject.Web.Common命名空间,这是告诉Ninject,对于ASP.NET所接收到的每一个请求,应该只创建LinqValueCalculator类的一个实例。每一个请求都会获取各自独立的对象,但同一个请求中的多个依赖项将会用这个类的单一实例进行解析。启动应用程序,会看到Ninject仅创建一个LinqValueCalculator实例。如果刷新浏览器窗口但未重启应用程序,则会看到Ninject创建了第二个对象。Ninject提供了一系列不同的对象作用域,如下:
6.3 Visual Studio的单元测试
首先,对实例项目添加一个IDiscountHelper接口的新实现。在Models文件夹中创建一个名为MinimumDiscountHelper.cs的新闻界,如下,
1 public class MinimumDiscountHelper : IDiscountHelper 2 { 3 public decimal ApplyDiscount(decimal totalParam) 4 { 5 throw new ArgumentOutOfRangeException(); 6 } 7 }
此例的目标是让该类演示以下行为:
- 总额大于$100,折扣为10%
- 总额介于(并包括)$10到$100之间,折扣为$5
- 总额小于$10,无折扣
- 总额为负值时,抛出一个ArgumentOutOfRangeException异常。
MinimumDiscountHelper尚未实现上述行为,我们将遵循测试驱动开发(TDD)单元测试,并随之编写实现代码。
6.3.1 创建单元测试项目
添加对EssentialTools项目的引用
6.3.2 添加单元测试
修改UnitTest1,如下,
1 [TestClass] 2 public class UnitTest1 3 { 4 private IDiscountHelper getTestObject() 5 { 6 return new MinimumDiscountHelper(); 7 } 8 9 [TestMethod] 10 public void Discount_Above_100() 11 { 12 //准备 13 IDiscountHelper target = getTestObject(); 14 decimal total = 200; 15 16 //动作 17 var discountedTotal = target.ApplyDiscount(total); 18 19 //断言 20 Assert.AreEqual(total * 0.9m, discountedTotal); 21 } 22 }
提示:测试类只是一种规则的C#类,它并不具备关于MVC项目的特别含义。TestClass和TestMethod注解属性才是让项目具备测试魔力的关键因素。
Assert类有一系列可以在测试中使用的静态方法。如下:
提示:Microsoft.VisualStudio.TestTools.UnitTesting命名空间中一个值得注意的成员是ExpectedException属性。这是一个断言,只当单元测试抛出ExceptionType参数指定类型的异常时,该断言才是成功的。这是一种确保单元测试抛出异常的整洁方式,而不需要在单元测试中构造try...catch块。
修改UnitTest1.cs,如下,
1 [TestClass] 2 public class UnitTest1 3 { 4 private IDiscountHelper getTestObject() 5 { 6 return new MinimumDiscountHelper(); 7 } 8 9 [TestMethod] 10 public void Discount_Above_100() 11 { 12 //准备 13 IDiscountHelper target = getTestObject(); 14 decimal total = 200; 15 16 //动作 17 var discountedTotal = target.ApplyDiscount(total); 18 19 //断言 20 Assert.AreEqual(total * 0.9m, discountedTotal); 21 } 22 23 [TestMethod] 24 public void Discount_Between_10_And_100() 25 { 26 //准备 27 IDiscountHelper target = getTestObject(); 28 29 //动作 30 decimal TenDollarDiscount = target.ApplyDiscount(10); 31 decimal HandredDollarDiscount = target.ApplyDiscount(100); 32 decimal FiftyDollarDiscount = target.ApplyDiscount(50); 33 34 //断言 35 Assert.AreEqual(5, TenDollarDiscount, "$10 discount iswrong"); 36 Assert.AreEqual(95, HandredDollarDiscount, "$100 discount iswrong"); 37 Assert.AreEqual(45, FiftyDollarDiscount, "$50 discount iswrong"); 38 } 39 40 [TestMethod] 41 public void Discount_Less_Than_10() 42 { 43 //准备 44 IDiscountHelper target = getTestObject(); 45 46 //动作 47 decimal discount5 = target.ApplyDiscount(5); 48 decimal discount0 = target.ApplyDiscount(0); 49 50 //断言 51 Assert.AreEqual(5, discount5); 52 Assert.AreEqual(0, discount0); 53 } 54 55 [TestMethod] 56 [ExpectedException(typeof(ArgumentOutOfRangeException))] 57 public void Discount_Negative_Total() 58 { 59 //准备 60 IDiscountHelper target = getTestObject(); 61 62 //动作 63 target.ApplyDiscount(-1); 64 } 65 }
6.3.3 运行单元测试(并失败)
6.3.4 实现特性
实现MinimumDiscountHelper类,如下,
1 public class MinimumDiscountHelper : IDiscountHelper 2 { 3 public decimal ApplyDiscount(decimal totalParam) 4 { 5 if (totalParam < 0) 6 { 7 throw new ArgumentOutOfRangeException(); 8 } 9 else if (totalParam > 100) 10 { 11 return totalParam * 0.9M; 12 } 13 else if (totalParam >= 10 && totalParam <= 100) 14 { 15 return totalParam - 5; 16 } 17 else 18 { 19 return totalParam; 20 } 21 } 22 }
6.3.5 测试并修正代码
6.4 使用Moq库
上述实例是不依赖于其他类而起作用的单一类。实际项目中,往往还需要测试一些不能孤立运行的对象。在这种情况下,需要将注意力集中于感兴趣的类或方法上,才能是你不必对依赖类也进行隐式测试。
一个有用的办法是使用模仿对象,它能够以一种特殊而受控的方式来模拟项目中实际对象的功能。模仿对象能够让你缩小测试的侧重点,以使你只检查感兴趣的功能。
6.4.1 理解问题
在开始使用Moq库之前,演示一个视图要修正的问题。在本小节中,打算对LinqValueCalculator类进行单元测试,这个类是在实例项目的Models文件夹中定义的。如下:
1 public class LinqValueCalculator: IValueCalculator 2 { 3 private IDiscountHelper discounter; 4 private static int counter = 0; 5 public LinqValueCalculator(IDiscountHelper discountParam) 6 { 7 discounter = discountParam; 8 System.Diagnostics.Debug.WriteLine(string.Format("Instance {0} created",++counter)); 9 } 10 public decimal ValueProducts(IEnumerable<Product> products) 11 { 12 //return products.Sum(p => p.Price); 13 return discounter.ApplyDiscount(products.Sum(p => p.Price)); 14 } 15 }
为测试该类,添加一个新的单元测试类,UnitTest2.cs。
1 [TestClass] 2 public class UnitTest2 3 { 4 private Product[] products = 5 { 6 new Product {Name = "Kayak", Category = "Watersports", Price = 275M}, 7 new Product {Name = "Lifejacket", Category = "Watersports", Price = 48.95M}, 8 new Product {Name = "Soccer ball", Category = "Soccer", Price = 19.50M}, 9 new Product {Name = "Corner flag", Category = "Soccer", Price = 34.95M} 10 }; 11 [TestMethod] 12 public void Sum_Products_Correctly() 13 { 14 //准备 15 var discounter=new MinimumDiscountHelper(); 16 var target=new LinqValueCalculator(discounter); 17 var goalTotal = products.Sum(e => e.Price); 18 19 //动作 20 var result = target.ValueProducts(products); 21 22 //断言 23 Assert.AreEqual(goalTotal,result); 24 } 25 }
要面临的问题是,LinqValueCalculator类依赖于IDiscountHelper接口的实现才能进行操作。在此例中,使用了MinimumDiscountHelper类(这是IDiscountHelper接口的实现类,它表现出以下两个不同的问题。
一,已经是单元测试变得复杂和脆弱。为了创建一个能够进行工作的单元测试,需要考虑IDiscountHelper实现中的折扣逻辑,以便判断出ValueProducts方法的预期值。脆弱来自这样一个事实:一旦该实现中的折扣逻辑发生变化,即使LinqValueCalculator类可以很好的正常工作,测试仍会失败。
二,也是最令人担忧的问题,已经延展了这一单元测试的范围,使它隐式的包含了MinimumDiscountHelper类。当单元测试失败时,不易知道问题是出在LinqValueCalculator类中,还是MinimumDiscountHelper类中。
6.4.2 将Moq添加到Visual Studio项目
打开NuGet控制台并输入以下命令:
Install-Package Moq -version 4.1.1309.1617-projectnameEssentialTools.Tests
projectname参数告诉NuGet,我们希望Moq包安装到自己的哪个单元测试项目中,而不是安装到主应用程序中。
6.4.3 对单元测试添加模仿对象
对单元测试添加模仿对象,其目的是告诉Moq,你想使用哪一种对象,对它的行为进行配置,然后将该对象运用于测试目标。下面演示如何为LinqValueCalculator的单元测试添加模仿对象,
1 [TestClass] 2 public class UnitTest2 3 { 4 private Product[] products = 5 { 6 new Product {Name = "Kayak", Category = "Watersports", Price = 275M}, 7 new Product {Name = "Lifejacket", Category = "Watersports", Price = 48.95M}, 8 new Product {Name = "Soccer ball", Category = "Soccer", Price = 19.50M}, 9 new Product {Name = "Corner flag", Category = "Soccer", Price = 34.95M} 10 }; 11 [TestMethod] 12 public void Sum_Products_Correctly() 13 { 14 ////准备 15 //var discounter=new MinimumDiscountHelper(); 16 //var target=new LinqValueCalculator(discounter); 17 //var goalTotal = products.Sum(e => e.Price); 18 19 ////动作 20 //var result = target.ValueProducts(products); 21 22 ////断言 23 //Assert.AreEqual(goalTotal,result); 24 25 //准备 26 Mock<IDiscountHelper> mock = new Mock<IDiscountHelper>(); 27 mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())) 28 .Returns<decimal>(total => total); 29 var target=new LinqValueCalculator(mock.Object); 30 31 //动作 32 var result = target.ValueProducts(products); 33 34 //断言 35 Assert.AreEqual(products.Sum(e=>e.Price),result); 36 } 37 }
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表达式进行配置或检查的方法。对于该单元测试,我们希望定义ApplyDiscount方法的行为,它是IDiscountHelper接口的唯一方法,也是对LinqValueCalculator类进行测试所需要的方法。
也必须告诉Moq,我们感兴趣的参数值是什么,这是用It类来做的事情,如下,
mock.Setup(m=>m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total=>total);
这个It类定义了许多以类型参数进行使用的方法。在此实例中,用decimal作为泛型类型调用了IsAny方法。这是告诉Moq,当以任何十进制值为参数来调用ApplyDiscount方法时,它应该运用我们定义的这一行为。以下给出了It类提供的方法,所有这些方法都是静态的。
3,定义结果
Returns方法是让我们指定在调用模仿方法时Moq要返回的结果。其类型参数用以指定结果的类型,而用Lambda表达式来指定结果。
mock.Setup(m=>m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total=>total);
通过调用带有decimal类型参数的Returns方法(即Returns<decimal>),这是告诉Moq,要返回一个十进制的值。对于Lambda表达式,Moq给我们传递了一个在ApplyDiscount方法中接收的类型值。在此例中,创建了一个穿透方法。在该方法中,我们返回了传递给模仿ApplyDiscount方法的值,并未对这个值执行任何操作。
注:请理解上述过程的思想:wield对LinqValueCalculator进行单元测试,如果创建一个IDiscountHelper模仿对象,便可以在单元测试中排除IDiscountHelper接口的实现类MinimumDiscountHelper,从而使单元测试更为简单且容易。用Moq创建模仿对象的整个过程包括几个步骤:(1)用Mock创建模仿对象;(2)用Setup方法建立模仿对象的行为;(3)用It类设置行为的参数;(4)用Return方法执行行为的返回类型;(5)用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实现中,或是出在建立模仿对象的方式中。
6.4.4 创建更复杂的模仿对象
在UnitTest2.cs文件添加一个新的单元测试,它模仿更为复杂的IDiscountHelper接口实现。事实上,我们已经用Moq模拟了MinimumDiscountHelper类的行为。
1 [TestClass] 2 public class UnitTest2 3 { 4 private Product[] products = 5 { 6 new Product {Name = "Kayak", Category = "Watersports", Price = 275M}, 7 new Product {Name = "Lifejacket", Category = "Watersports", Price = 48.95M}, 8 new Product {Name = "Soccer ball", Category = "Soccer", Price = 19.50M}, 9 new Product {Name = "Corner flag", Category = "Soccer", Price = 34.95M} 10 }; 11 [TestMethod] 12 public void Sum_Products_Correctly() 13 { 14 ////准备 15 //var discounter=new MinimumDiscountHelper(); 16 //var target=new LinqValueCalculator(discounter); 17 //var goalTotal = products.Sum(e => e.Price); 18 19 ////动作 20 //var result = target.ValueProducts(products); 21 22 ////断言 23 //Assert.AreEqual(goalTotal,result); 24 25 //准备 26 Mock<IDiscountHelper> mock = new Mock<IDiscountHelper>(); 27 mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())) 28 .Returns<decimal>(total => total); 29 var target=new LinqValueCalculator(mock.Object); 30 31 //动作 32 var result = target.ValueProducts(products); 33 34 //断言 35 Assert.AreEqual(products.Sum(e=>e.Price),result); 36 } 37 38 private Product[] createProduct(decimal value) 39 { 40 return new[] {new Product() {Price = value}}; 41 } 42 43 [TestMethod] 44 [ExpectedException(typeof(System.ArgumentOutOfRangeException))] 45 public void Pass_Through_Variable_Discounts() 46 { 47 //准备 48 Mock<IDiscountHelper> mock=new Mock<IDiscountHelper>(); 49 mock.Setup(m => m.ApplyDiscount(It.IsAny<decimal>())).Returns<decimal>(total => total); 50 mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v == 0))).Throws<System.ArgumentOutOfRangeException>(); 51 mock.Setup(m => m.ApplyDiscount(It.Is<decimal>(v => v > 100))).Returns<decimal>(total => (total * 0.9M)); 52 mock.Setup(m => m.ApplyDiscount(It.IsInRange<decimal>(10, 100, Range.Inclusive))) 53 .Returns<decimal>(total => total - 5); 54 var target=new LinqValueCalculator(mock.Object); 55 56 //动作 57 decimal FiveDollarDiscount=target.ValueProducts(createProduct(5)); 58 decimal TenDollarDiscount = target.ValueProducts(createProduct(10)); 59 decimal FiftyDollarDiscount = target.ValueProducts(createProduct(50)); 60 decimal HundredDollarDiscount = target.ValueProducts(createProduct(100)); 61 decimal FiveHundredDollarDiscount = target.ValueProducts(createProduct(500)); 62 63 //断言 64 Assert.AreEqual(5,FiveDollarDiscount,"$5 Fail"); 65 Assert.AreEqual(5, TenDollarDiscount, "$10 Fail"); 66 Assert.AreEqual(45, FiftyDollarDiscount, "$50 Fail"); 67 Assert.AreEqual(95, HundredDollarDiscount, "$100 Fail"); 68 Assert.AreEqual(450,FiveHundredDollarDiscount, "$500 Fail"); 69 target.ValueProducts(createProduct(0)); 70 } 71 }
根据所接收到的参数值,我们定义了ApplyDiscount方法的4个不同行为。最简单行为的是“全匹配”,它直接返回任意的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);
It.Is方法是为不同参数值建立指定行为最灵活的方式,因为你可以使用任何谓词来返回true或false。
2,模仿值的范围
It对象最后是与IsInRange方法一起使用的,它让我们能够捕捉参数值的范围。
mock.Setup(m=>m.ApplyDiscount(It.IsInRange<decimal>(10,100,Range.Inclusive))).Returns<decimal>(total=>total-5);
这里介绍这一方法是出狱完整性,我们在项目中可以使用Is方法和一个谓词来做同样的事情,像这样:
mock.Setup(m=>m.ApplyDiscount(It.Is<decimal>(v=>v>=10&&v<=100))).Returns<decimal>(total=>total-5);
效果相同,但谓词方法更为灵活。