测试驱动 ASP.NET MVC
模型-视图-控制器 (MVC) 模式的核心是将 UI 功能划分成三个组成部分。模型表示您的领域的数据和行为。视图管理模型的显示并且处理与用户的交互。控制器协调视图和模型之间的交互。通过这样将本质上就难于测试的 UI 逻辑与业务逻辑分离开来,使得使用 MVC 模式实现的应用程序非常易于测试。在本文中,我将论述用于增强您的 ASP.NET MVC 应用程序的可测试性的最佳做法和技术,包括如何建立您的解决方案的结构、设计代码架构以便处理依赖关系注入以及使用 StructureMap 实现依赖关系注入。
建立您的解决方案的结构以便实现最高的可测试性
与每个开发人员都开始一个新的项目(即创建解决方案)相比,再没有更好的方式 来开始我们的讨论了。我将基于我在使用测试驱动开发 (TDD) 来开发大企业 ASP.NET MVC 应用程序方面的经验,论述用于规划您的 Visual Studio 解决方案的一些最佳做法。首先,我建议在创建 ASP.NET MVC 项目时使用空的项目模板。其他模板很适合于试验或创建概念证明,但它们通常会包含许多会让人分神且在真正的企业应用程序中不必要的干扰内容。
在您创建任何类型的复杂应用程序时,都应该使用 n 层方法。对于 ASP.NET MVC 应用程序开发,我建议使用在图 1 和图 2 中阐释的方法,其中包含以下项目:
- Web 项目包含所有特定于 UI 的代码,包括视图、视图模型、脚本和 CSS 等。该层只能访问 Controllers、Service、Domain 和 Shared 项目。
- Controllers 项目包含 ASP.NET MVC 使用的控制器类。该层与 Service、Domain 和 Shared 项目通信。
- Service 项目包含应用程序的业务逻辑。该层与 DataAccess、Domain 和 Shared 项目通信。
- DataAccess 项目包含用于检索和操作驱动应用程序的数据的代码。该层与 Domain 和 Shared 项目通信。
- Domain 项目包含应用程序使用的域项目,并且禁止与任何项目通信。
- Shared 项目包含可用于其他多个层的代码,例如记录程序、常量和其他常见实用工具代码。仅允许该项目与 Domain 项目通信。
图 1 各层之间的交互
图 2 解决方案结构示例
我建议将您的控制器放置于一个单独的 Visual Studio 项目中。有关如何轻松实现此建议的信息,请参见 bit.ly/K4mF2B 上的博客文章。通过将您的控制器放置于单独的项目中,您可以进一步将处于控制器中的逻辑与 UI 代码分离开来。结果就是您的 Web 项目仅包含真正与 UI 相关的代码。
在哪里放置您的测试项目 在哪里放置您的测试项目以及如何对这些项目进行命名十分重要。在您开发复杂的、企业级应用程序时,解决方案往往会变得相当大,因此,很难在解决方案资源管理器中定位代码的特定类或部分。将多个测试项目添加到您的现有代码库中只会导致在解决方案资源管理器中进行导航更复杂。我强烈建议您将测试项目与实际的应用程序代码从物理上分隔开来。我建议将所有测试项目都放置于解决方案级别的 Tests 文件夹中。在单个解决方案文件夹中定位您的所有测试项目和测试将会显著减少默认解决方案资源管理器视图中的干扰内容,从而允许您轻松地定位您的测试。
接下来,您将要分离测试的类型。您的解决方案很可能将包含多种测试类型(单元、集成、性能、UI 等),因此,对每种测试类型进行隔离和分组十分重要。这不仅可以便于定位特定的测试类型,而且还使您可以轻松地运行某个特定类型的所有测试。如果您在使用最流行的 Visual Studio 高效工具套件 ReSharper (jetbrains.com/ReSharper) 或 CodeRush (devexpress.com/CodeRush) 中的一个,则会获得一个上下文菜单,该菜单允许您右键单击解决方案资源管理器中的任何文件夹、项目或类,并且运行在该项中包含的所有测试。若要按测试类型对测试进行分组,请在 Tests 解决方案文件夹内为您计划编写的每种测试类型都创建一个文件夹。
图 3 显示了一个 Tests 解决方案文件夹的示例,其中包含多个测试类型文件夹。
图 3 Tests 解决方案文件夹示例
命名您的测试项目 测试项目的命名方式与测试项目的定位同样重要。您希望能够轻松地区分每个测试项目中待测试的应用程序部分以及项目包含的测试类型。因此,最好使用以下约定命名您的测试项目: [待测试项目的完整名称].Test.[测试类型]。这使您可以迅速准确地确定待测试项目所处的层以及要执行的测试的类型。您可能会认为将测试项目放置于特定于类型的文件夹中并且在测试项目的名称中包含测试类型是多余的,但请记住,解决方案文件夹仅用于解决方案资源管理器中,而不包含在项目文件的命名空间中。因此,尽管 Controllers 单元测试项目位于 Tests\Unit 解决方案文件夹中,但命名空间 (TestDrivingMVC.Controllers.Test.Unit) 未反映该文件夹结构。在命名项目时添加测试类型是很有必要的,可避免命名冲突以及确定您在编辑器内处理的测试类型。图 4 显示具有测试项目的解决方案资源管理器。
图 4 解决方案资源管理器中的测试项目
介绍针对您的体系结构的依赖关系注入
在您的待测试代码中遇到依赖关系前,对 n 层应用程序进行的单元测试不会前进多远。这些依赖关系可以是您的应用程序的其他层,或者可以完全处于您的代码的外部(例如数据库、文件系统或 Web 服务)。在您撰写单元测试时,需要正确处理此情况,并且在遇到外部依赖关系时使用 Test Double(模拟、虚设或存根)。有关 Test Double 的详细信息,请参考《MSDN 杂志》2007 年 9 月号刊载的“探索 Test Double 的状态集”(msdn.microsoft.com/magazine/cc163358)。但在您可以利用 Test Double 所提供的灵活性之前,必须对您的代码进行设计,以便处理依赖关系的注入。
依赖关系注入 依赖关系注入是注入一个类所要求的具体实现(而不是直接实例化该依赖关系的类)的过程。使用类并不知道其任何依赖关系的实际具体实现,仅知道支持依赖关系的接口;具体实现由使用类或依赖关系注入框架提供。
依赖关系注入的目标是创建松散耦合程度高的代码。通过松散耦合,您在撰写单元测试时可以轻松地替换您的依赖关系的 Test Double 实现。
有三种主要方法可用于实现依赖关系注入:
- 属性注入
- 构造函数注入
- 使用依赖关系注入框架/控制容器反转(自此以后称作 DI/IoC 框架)
使用属性注入,您公开对象上的公共属性,以便能够设置其依赖关系,如图 5 中所示。此方法简单明了并且不需要工具。
图 5 属性注入
- // Employee Service
- public class EmployeeService : IEmployeeService {
- private ILoggingService _loggingService;
- public EmployeeService() {}
- public ILoggingService LoggingService { get; set; }
- public decimal CalculateSalary(long employeeId) {
- EnsureDependenciesSatisfied();
- _loggingService.LogDebug(string.Format(
- "Calculating Salary For Employee: {0}", employeeId));
- decimal output = 0;
- /*
- * Complex logic that needs to be performed
- * in order to determine the employee's salary
- */
- return output;
- }
- private void EnsureDependenciesSatisfied() {
- if (_loggingService == null)
- throw new InvalidOperationException(
- "Logging Service dependency must be satisfied!");
- }
- }
- }
- // Employee Controller (Consumer of Employee Service)
- public class EmployeeController : Controller {
- public ActionResult DisplaySalary(long id) {
- EmployeeService employeeService = new EmployeeService();
- employeeService.LoggingService = new LoggingService();
- decimal salary = employeeService.CalculateSalary(id);
- return View(salary);
- }
- }
-
此方法有三个缺点。首先,它让使用者负责提供依赖关系。其次,它要求您在对象中实现对代码的保护,以便确保在使用前设置依赖关系。最后,随着您的对象的依赖关系数目的增加,实例化对象所需的代码量也将增加。
使用构造函数注入实现依赖关系注入涉及在实例化构造函数时通过其构造函数向某个类提供依赖关系,如图 6 中所示。此方法也简单明了,但与属性注入不同,您可以确保始终设置该类的依赖关系。
图 6 构造函数注入
- // Employee Service
- public class EmployeeService : IEmployeeService {
- private ILoggingService _loggingService;
- public EmployeeService(ILoggingService loggingService) {
- _loggingService = loggingService;
- }
- public decimal CalculateSalary(long employeeId) {
- _loggingService.LogDebug(string.Format(
- "Calculating Salary For Employee: {0}", employeeId));
- decimal output = 0;
- /*
- * Complex logic that needs to be performed
- * in order to determine the employee's salary
- */
- return output;
- }
- }
- // Consumer of Employee Service
- public class EmployeeController : Controller {
- public ActionResult DisplaySalary(long employeeId) {
- EmployeeService employeeService =
- new EmployeeService(new LoggingService());
- decimal salary = employeeService.CalculateSalary(employeeId);
- return View(salary);
- }
- }
-
遗憾的是,此方法仍要求使用者提供依赖关系。此外,它确实仅适合于小型应用程序。较大的应用程序通常具有过多的依赖关系,以致无法通过对象的构造函数提供它们。
实现依赖关系注入的第三种方法是使用 DI/IoC 框架。DI/IoC 框架完全消除了由使用者提供依赖关系的责任,并且允许您在设计时配置依赖关系、在运行时解析依赖关系。有许多可用于 .NET 的 DI/IoC 框架,包括 Unity(Microsoft 的产品)、StructureMap、Castle Windsor 和 Ninject 等。作为所有不同 DI/IoC 框架的基础的概念是相同的,而选择哪一种框架通常由个人偏好决定。为了在本文中演示 DI/IoC 框架,我将使用 StructureMap。
利用 StructureMap 让依赖关系注入更上一层楼
StructureMap (structuremap.net) 是一种广泛采用的依赖关系注入框架。您可以使用程序包管理器控制台 (Install-Package StructureMap) 或 NuGet 程序包管理器 GUI(右键单击您的项目的引用文件夹,然后选择“管理 NuGet 程序包”)通过 NuGet 来安装该框架。
使用 StructureMap 配置依赖关系 在 ASP.NET MVC 中实现 StructureMap 的第一步是配置您的依赖关系,以便 StructureMap 知道如何对它们进行解析。您可以通过以下两种方法中的一种在 Global.asax 的 Application_Start 方法中配置依赖关系。
第一种方法是手动指示 StructureMap,对于特定的抽象实现,它应该使用特定的具体实现:
- ObjectFactory.Initialize(register => {
- register.For<ILoggingService>().Use<LoggingService>();
- register.For<IEmployeeService>().Use<EmployeeService>();
- });
-
此方法的缺点是您必须手动注册您的应用程序中的每个依赖关系,因此,对于大型应用程序而言,工作量可能会很大。此外,因为您在 ASP.NET MVC 站点的 Application_Start 中注册依赖关系,因此,您的 Web 层必须直接知道绑定有依赖关系的应用程序的其他每个层。
您还可以使用 StructureMap 自动注册和扫描功能自动检查您的程序集和绑定依赖关系。通过此方法,StructureMap 将扫描您的程序集,并且在它遇到某一接口时,会查找关联的具体实现(基于一个概念,即依据惯例,名为 IFoo 的方法将映射到具体实现 Foo):
- ObjectFactory.Initialize(registry => registry.Scan(x => {
- x.AssembliesFromApplicationBaseDirectory();
- x.WithDefaultConventions();
- }));
-
StructureMap 依赖关系解决程序 在配置了您的依赖关系后,您需要能够从您的代码库访问这些依赖关系。这是通过创建依赖关系解决程序并将其定位于 Shared 项目中来实现的(因为它将需要由具有依赖关系的所有应用程序层来访问):
- public static class Resolver {
- public static T GetConcreteInstanceOf<T>() {
- return ObjectFactory.GetInstance<T>();
- }
- }
-
Resolver 类(我喜欢这么称呼它,因为 Microsoft 与 ASP.NET MVC 3 一起引入了 DependencyResolver 类,稍后我将讨论它)是包含一个函数的简单静态类。该函数接受泛型参数 T,该参数表示为其查找具体实现的接口;并且返回 T,这是传入接口的实际实现。
在我跳转到如何在您的代码中使用新的 Resolver 类之前,我想要介绍一下为什么我编写了自己开发的依赖关系解决程序,而不是创建实现随 ASP.NET MVC 3 引入的 IDependencyResolver 接口的类。包含 IDependencyResolver 功能是对 ASP.NET MVC 的很棒的补充,并且在促进正确的软件行为方面取得了很大的进步。但遗憾的是,它驻留在 System.Web.MVC DLL 中,而我不希望在应用程序体系结构的非 Web 层中具有对特定于 Web 技术的库的引用。
解析代码中的依赖关系 在完成了所有困难工作后,解析代码中的依赖关系就很简单了。您需要完成的全部工作就是调用 Resolver 类的静态 GetConcreteInstanceOf 函数,并且将其传递给您在为其查找具体实现的接口,如图 7 中所示。
图 7 解析代码中的依赖关系
- public class EmployeeService : IEmployeeService {
- private ILoggingService _loggingService;
- public EmployeeService() {
- _loggingService =
- Resolver.GetConcreteInstanceOf<ILoggingService>();
- }
- public decimal CalculateSalary(long employeeId) {
- _loggingService.LogDebug(string.Format(
- "Calculating Salary For Employee: {0}", employeeId));
- decimal output = 0;
- /*
- * Complex logic that needs to be performed
- * in order to determine the employee's salary
- */
- return output;
- }
- }
-
利用 StructureMap 在单元测试中注入 Test Double 现在已完成了代码的结构设计,因此,您可以注入依赖关系而无需来自使用者的介入,让我们回到在单元测试中正确处理依赖关系这个最初的任务中来吧。它的具体情形是这样的:
- 该任务是使用 TDD 撰写逻辑,以便生成要从 EmployeeService 的 CalculateSalary 方法返回的薪金值。(您将会在图 7 中发现 EmployeeService 和 CalculateSalary 函数。)
- 有一个要求,即必须记录对 CalculateSalary 函数的所有调用。
- 将定义针对日志记录服务的接口,但实现不完整。调用日志记录服务当前会引发一个异常。
- 需要在针对日志记录服务的工作按计划开始前完成该任务。
很有可能您在以前遇到过这种类型的情况。但现在,您具有了正确的体系结构,能够通过实施 Test Double 摆脱依赖关系的束缚。我喜欢在一个项目中创建可在我的所有测试项目中共享的 Test Double。如图 8 中所示,我已在 Tests 解决方案文件夹中创建了一个 Shared 项目。在该项目中,我添加了一个 Fakes 文件夹,因为为了完成我的测试,我需要 ILoggingService 的虚设实现。
图 8 用于共享测试代码和虚设的项目
为日志记录服务创建虚设十分简单。首先,我在 Fakes 文件夹内创建了一个名为 LoggingServiceFake 的类。LoggingServiceFake 需要满足 EmployeeService 预期的约定,这意味着它需要实现 ILoggingService 及其方法。按照定义,虚设是一种替代物,包含对满足接口刚好足够的代码。通常,这意味着它具有 void 方法的空实现,并且函数实现包含返回硬编码值的返回语句,如下所示:
- public class LoggingServiceFake : ILoggingService {
- public void LogError(string message, Exception ex) {}
- public void LogDebug(string message) {}
- public bool IsOnline() {
- return true;
- }
- }
-
现在已实现了虚设,我可以编写测试了。开始时,我将在 TestDrivingMVC.Service.Test.Unit 单元测试项目中创建一个测试类,按照前面所述的命名约定,我将其命名为 EmployeeServiceTest,如图 9 中所示。
图 9 EmployeeServiceTest 测试类
- [TestClass]
- public class EmployeeServiceTest {
- private ILoggingService _loggingServiceFake;
- private IEmployeeService _employeeService;
- [TestInitialize]
- public void TestSetup() {
- _loggingServiceFake = new LoggingServiceFake();
- ObjectFactory.Initialize(x =>
- x.For<ILoggingService>().Use(_loggingServiceFake));
- _employeeService = new EmployeeService();
- }
- [TestMethod]
- public void CalculateSalary_ShouldReturn_Decimal() {
- // Arrange
- long employeeId = 12345;
- // Act
- var result =
- _employeeService.CalculateSalary(employeeId);
- // Assert
- result.ShouldBeType<decimal>();
- }
- }
-
大多数情况下,测试类代码非常简单。您要特别注意的代码行是:
- ObjectFactory.Initialize(x =>
- x.For<ILoggingService>().Use(
- _loggingService));
-
这是在我们之前创建的 Resolver 类尝试解析 ILoggingService 时指示 StructureMap 使用 LoggingServiceFake 的代码。我将此代码放置于用 TestInitialize 标记的方法中,这指示单元测试框架在测试类中运行每个测试前都执行该方法。
通过使用功能强大的 DI/IoC 和 StructureMap 工具,我能够完全摆脱日志记录服务的束缚。这样做使我能够在不受到日志记录服务状态的影响下完成编码和单元测试,并且编写不依赖于任何依赖关系的真正的单元测试代码。
使用 StructureMap 作为默认的控制器工厂 ASP.NET MVC 提供了一个扩展点,使您能够添加在您的应用程序中实例化控制器的方式的自定义实现。通过创建从 DefaultControllerFactory 继承的类(参见图 10),您可以控制创建控制器的方式。
图 10 自定义控制器工厂
- public class ControllerFactory : DefaultControllerFactory {
- private const string ControllerNotFound =
- "The controller for path '{0}' could not be found or it does not implement IController.";
- private const string NotAController = "Type requested is not a controller: {0}";
- private const string UnableToResolveController =
- "Unable to resolve controller: {0}";
- public ControllerFactory() {
- Container = ObjectFactory.Container;
- }
- public IContainer Container { get; set; }
- protected override IController GetControllerInstance(
- RequestContext context, Type controllerType) {
- IController controller;
- if (controllerType == null)
- throw new HttpException(404, String.Format(ControllerNotFound,
- context.HttpContext.Request.Path));
- if (!typeof (IController).IsAssignableFrom(controllerType))
- throw new ArgumentException(string.Format(NotAController,
- controllerType.Name), "controllerType");
- try {
- controller = Container.GetInstance(controllerType)
- as IController;
- }
- catch (Exception ex) {
- throw new InvalidOperationException(
- String.Format(UnableToResolveController,
- controllerType.Name), ex);
- }
- return controller;
- }
- }
-
在这个新的控制器工厂中,我具有一个公共的 StructureMap 容器属性,它基于 StructureMap ObjectFactory 获取集(在图 10 的 Global.asax 中配置)。
接下来,我具有执行某种类型检查的 GetControllerInstance 方法的替代方法,然后使用 StructureMap 容器基于提供的控制器类型参数解析当前控制器。因为我在最初配置 StructureMap 时使用了 StructureMap 自动注册和扫描功能,所以无需执行任何其他操作。
创建自定义控制器工厂的好处在于,对于您的控制器,不再局限于无参数构造函数。此时您可能会有这样的疑问:“我如何向控制器的构造函数提供参数呢?”。借助于 DefaultControllerFactory 和 StructureMap 的可扩展性,您不必提供参数。当您为控制器声明参数化的构造函数时,将在新的控制器工厂中解析控制器时自动解析依赖关系。
如图 11 中所示,我已将一个 IEmployeeService 参数添加到了 HomeController 的构造函数。在新的控制器工厂中解析控制器时,将自动解析该控制器的构造函数所要求的所有参数。这意味着您无需手动添加代码来解析控制器的依赖关系 — 但您仍可以按照前述内容来使用虚设。
图 11 解析控制器
- public class HomeController : Controller {
- private readonly IEmployeeService _employeeService;
- public HomeController(IEmployeeService employeeService) {
- _employeeService = employeeService;
- }
- public ActionResult Index() {
- return View();
- }
- public ActionResult DisplaySalary(long id) {
- decimal salary = _employeeService.CalculateSalary(id);
- return View(salary);
- }
- }
-
通过在您的 ASP.NET MVC 应用程序中使用这些实践和技术,整个 TDD 过程将更加轻松和简明。
Keith Burnell 是 Skyline Technologies 的高级软件工程师。 他从事软件开发工作已经 10 多年了,并专门从事大规模的 ASP.NET 和 ASP.NET MVC 网站开发。 Burnell 积极参与开发人员社区,您可以访问 dotnetdevdude.com 查看他的博客,或访问 twitter.com/keburnell 查看他的微博
原文: http://msdn.microsoft.com/zh-cn/magazine/jj190803.aspx
衷心感谢以下技术专家对本文的审阅: John Ptacek 和 Clark Sell