模型-視圖-控制器 (MVC) 模式的核心是將 UI 功能划分成三個組成部分。 模型表示您的領域的數據和行為。 視圖管理模型的顯示並且處理與用戶的交互。 控制器協調視圖和模型之間的交互。 通過這樣將本質上就難於測試的 UI 邏輯與業務邏輯分離開來,使得使用 MVC 模式實現的應用程序非常易於測試。 建立您的解決方案的結構以便實現最高的可測試性
與每個開發人員都開始一個新的項目(即創建解決方案)相比,再沒有更好的方式 來開始我們的討論了。 我將基於我在使用測試驅動開發 (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 項目中。 通過將您的控制器放置於單獨的項目中,您可以進一步將處於控制器中的邏輯與 UI 代碼分離開來。 結果就是您的 Web 項目僅包含真正與 UI 相關的代碼。
在哪里放置您的測試項目 在哪里放置您的測試項目以及如何對這些項目進行命名十分重要。 在您開發復雜的、企業級應用程序時,解決方案往往會變得相當大,因此,很難在解決方案資源管理器中定位代碼的特定類或部分。 將多個測試項目添加到您的現有代碼庫中只會導致在解決方案資源管理器中進行導航更復雜。 我強烈建議您將測試項目與實際的應用程序代碼從物理上分隔開來。 我建議將所有測試項目都放置於解決方案級別的 Tests 文件夾中。 在單個解決方案文件夾中定位您的所有測試項目和測試將會顯著減少默認解決方案資源管理器視圖中的干擾內容,從而允許您輕松地定位您的測試。
接下來,您將要分離測試的類型。 您的解決方案很可能將包含多種測試類型(單元、集成、性能、UI 等),因此,對每種測試類型進行隔離和分組十分重要。 這不僅可以便於定位特定的測試類型,而且還使您可以輕松地運行某個特定類型的所有測試。 在該項中包含的所有測試。 若要按測試類型對測試進行分組,請在 Tests 解決方案文件夾內為您計划編寫的每種測試類型都創建一個文件夾。
圖 3 顯示了一個 Tests 解決方案文件夾的示例,其中包含多個測試類型文件夾。
圖 3 Tests 解決方案文件夾示例
命名您的測試項目 測試項目的命名方式與測試項目的定位同樣重要。 您希望能夠輕松地區分每個測試項目中待測試的應用程序部分以及項目包含的測試類型。 因此,最好使用以下約定命名您的測試項目: [待測試項目的完整名稱].Test.[測試類型]。 這使您可以迅速准確地確定待測試項目所處的層以及要執行的測試的類型。 您可能會認為將測試項目放置於特定於類型的文件夾中並且在測試項目的名稱中包含測試類型是多余的,但請記住,解決方案文件夾僅用於解決方案資源管理器中,而不包含在項目文件的命名空間中。 因此,盡管 Controllers 單元測試項目位於 Tests\Unit 解決方案文件夾中,但命名空間 (TestDrivingMVC.Controllers.Test.Unit) 未反映該文件夾結構。 在命名項目時添加測試類型是很有必要的,可避免命名沖突以及確定您在編輯器內處理的測試類型。 圖 4 顯示具有測試項目的解決方案資源管理器。
圖 4 解決方案資源管理器中的測試項目
介紹針對您的體系結構的依賴關系注入
在您的待測試代碼中遇到依賴關系前,對 n 層應用程序進行的單元測試不會前進多遠。 這些依賴關系可以是您的應用程序的其他層,或者可以完全處於您的代碼的外部(例如數據庫、文件系統或 Web 服務)。 在您撰寫單元測試時,需要正確處理此情況,並且在遇到外部依賴關系時使用 Test Double(模擬、虛設或存根)。 但在您可以利用 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 過程將更加輕松和簡明。