设计模式如何帮助您开发支持单元测试的应用程序






4.61/5 (33投票s)
本文将展示如何结合使用模型-视图-呈现器(Model-View-Presenter)、领域模型(Domain Model)、服务(Services)、活动记录(ActiveRecord)和存储库(Repository)模式来创建可测试的应用程序。
1. 引言
单元测试无疑是软件开发周期中最常被忽视的环节之一。通常,开发人员会编写一组非常简单的测试(例如控制台应用程序),以确保实现的が功能正常工作,然后才将应用程序交给他人测试。但是,编写单元测试的代码通常不会像编写应用程序本身的代码那样受到重视,而普通开发人员肯定不愿意“浪费时间”维护测试代码。显然,对于那些拥抱测试驱动开发(TDD)的人来说,情况并非如此,TDD是极限编程(Extreme Programming)的衍生品之一。让我们列举一些 TDD 的原则:
- 做最简单的事情:程序员应该做任何可能实现的功能的最简单的事情。
- 先编写单元测试代码:程序员应该始终在编写要测试的功能之前编写自动化测试。
- 一次且仅一次:程序员应该系统地重构他们的代码,以避免重复并允许重用。
也就是说,TDD爱好者早就创建了一套编程工具和框架来简化自动化单元测试的编写,这也就不足为奇了。自动化单元测试的一些好处:
- 可维护性:就像应用程序代码一样,自动化单元测试也是用一组类来编写的,通常在测试项目中维护,并一一对应要测试的类。
- 可重用性:自动化单元测试可以被开发团队根据需要进行扩展和执行。此外,单元测试的成功执行可以是预构建阶段的要求。
- 可见性:单元测试可以被代码覆盖率工具使用,以便开发人员可以看到单元测试已经测试了多少应用程序代码。
- 开发者的信心:如果代码经过适当测试并且可以轻松执行自动化测试,那么开发人员就可以更有信心地进行重构或实现新代码。
- 文档:单元测试有助于扩展类文档,并提供其正确用法的实际示例。
不幸的是,前方的道路上有很多陷阱,编写单元测试可能是一项非常困难(如果不是不可能)的任务,这是由于在项目中仅仅应用遗留项目的相同范例而做出的糟糕设计选择。这就是为什么软件架构应该将可测试性作为其开发周期的要求。
本文的其余部分将尝试演示设计模式的使用如何使您的未来应用程序能够从单元测试中受益(并使其支持单元测试),即使您一开始没有选择测试驱动开发方法。
由于示例应用程序是用 C# 编写的,我将使用 NUnit 框架,这是 jUnit 的 .NET 版本。还有其他有趣的单元测试框架,如 MBUnit 和 Visual Team System,但我将只关注 NUnit,它是流行的 xUnit 框架家族的成员。
我想通过感谢那些启发我写这篇文章的人来结束这篇介绍,尤其是 Martin Fowler、Hamilton Verissimo、Jean-Paul Boodhoo、Billy McCafferty 和 Ayende Rahien。
2. 背景
可以说,如果一个应用程序的组件(程序集和类)易于测试,那么该应用程序就可以被认为是易于测试的。
“易于测试”是什么意思?
易于测试的类是指可以轻松地将其与其他交互的类隔离的类。也就是说,我们应该能够单独测试一个类,而不必关心其他类的实现。因此,如果单元测试失败,就更容易找到 bug 的来源。在单元测试中,我们通过创建它所依赖类的**模拟对象 (mocks)** 来隔离被测试的类。模拟对象是类的/接口的假实例,代表具体的对象。模拟对象是单元测试隔离的关键工具。但在继续讨论模拟对象之前,假设您创建了一个带有两个类的发票应用程序:InvoiceServices
和 TaxServices
。
public class InvoiceServices
{
TaxServices taxServices = new TaxServices();
public InvoiceServices() { }
public float CalculateInvoice(params int[] productCodes)
{
float invoiceAmount = 0;
//Here goes the code to calculate
//the invoice amount based on products
return invoiceAmount + taxServices.CalculateTax(invoiceAmount);
}
}
public class TaxServices
{
public TaxServices() { }
public float CalculateTax(float invoiceAmount)
{
float tax = 0;
// Here goes the code to calculate invoice tax
return tax;
}
}
您还有一个名为 InvoiceServicesTest
的类,用于测试 InvoiceServices
类。
[Test]
public void CalculateInvoiceTest()
{
InvoiceServices invoiceServices = new InvoiceServices();
float totalAmount = invoiceServices.CalculateInvoice(10001, 10002, 10003);
//we expect an invoice with product codes 10001, 10002 and 10003
//to have a total amount of $105.35
float expectedTotalAmount = 105.35F;
Assert.AreEqual(expectedTotalAmount, totalAmount,
string.Format("Total amount should be {0}",
expectedTotalAmount.ToString());
}
现在,假设您发现 CalculateInvoice()
函数返回一个不正确的值。您如何判断是这两个类(InvoiceServices
和 TaxServices
)中的哪一个没有正确地完成其工作?答案是,您应该重构您的测试代码并为 TaxServices
类引入一个模拟对象,以便可以隔离您的 InvoiceServices
类。不幸的是,您不能轻易做到这一点。问题在于 InvoiceServices
直接实例化了 TaxServices
使用的对象。这被称为“紧耦合”,是一种糟糕的设计,它阻碍了您应用程序的可测试性,因为您无法轻松地隔离这两个类。大多数模拟框架无法与紧耦合类一起工作。当然,有一些工具(如 TypeMock)可以完成这项工作。但是,由于我们试图在应用程序中应用最佳实践,并且避免依赖特定的模拟框架,因此我们应该重构我们的代码,通过依赖注入模式(也称为控制反转)来使用松耦合。
依赖注入模式(又名控制反转)
依赖注入模式,或控制反转(IoC),是一种能够在我们的应用程序中实现松耦合的模式。为了实现松耦合,我们应该重构我们的 InvoiceServices
类:首先,删除在 InvoiceServices
内部实例化 TaxServices
具体实例。第二,在 InvoiceServices
外部创建一个 TaxServices
实例,并通过构造函数或属性设置器注入来传递它。让我们看看重构后的样子:
public class InvoiceServices
{
ITaxServices taxServices;
public InvoiceServices(ITaxServices taxServices)
{
this.taxServices = taxServices;
}
.
.
.
}
既然我们不再创建 TaxServices
实例,我们必须在 InvoiceTax
外部创建它,并通过构造函数传递它。因此,让我们重构我们的 InvoiceServicesTest
类,使用 NMock 框架创建一个“模拟”的 TaxServices
对象。
using NMock2;
using NUnit.Framework;
using System;
using System.Collections.Generic;
using System.Text;
namespace InvoiceServices.Test
{
[TestFixture]
public class InvoiceServicesTest
{
[Test]
public void CalculateInvoiceTest()
{
Mockery mockery = new Mockery();
ITaxServices mockTaxServices = (ITaxServices)
mockery.NewMock<itaxservices>();
InvoiceServices invoiceServices =
new InvoiceServices(mockTaxServices);
Expect.Once.On(mockTaxServices).
Method("CalculateTax").With(100.00F).
Will(Return.Value(5.35F));
float totalAmount =
invoiceServices.CalculateInvoice(10001, 10002, 10003);
//we expect an invoice with product codes 10001, 10002 and 10003
//to have an amount of $100.00 + a tax amount of $5.35 = $105.35
float expectedTotalAmount = 105.35F;
Assert.AreEqual(expectedTotalAmount,
totalAmount,
string.Format("Total amount should be {0}",
expectedTotalAmount.ToString()));
}
}
}
那么,我们的重构有什么新变化?首先,我们添加了对 NMock 框架的引用(在行 `using NMock2;
` 中)。然后,我们实例化了一个 Mockery
对象。Mockery
是 NMock 中充当模拟工厂的类。然后,我们使用 Mockery
对象从 ITaxServices
接口实例化了模拟对象 mockTaxServices
。另一行是“期望”,它告诉模拟对象 mockTaxServices
等待对其 CalculateTax
函数的调用。由于模拟对象没有任何代码,该函数将返回 null
,并且我们的测试将毫无用处。幸运的是,我们刚刚添加的 Expect
行还告诉 NMock 框架,如果发票金额等于 $100.00,则返回 $5.35 的值。这表明依赖注入如何使您能够创建模拟对象来进行单元测试隔离。请注意,尽管我使用了 NMock,但必须说明有更好的选择,例如 Rhino Mocks 和 TypeMock,它们以强类型的方式实现期望,并使用所谓的记录-回放模型,允许 Intellisense 和重构工具重命名模拟成员。读者当然可以尝试使用这些其他模拟框架。
3. Northwind 解决方案:整合
我决定创建一个基于 Northwind 示例数据库(用于 SQL Server 2000)的易于测试的 Windows 窗体应用程序。
示例应用程序仅处理 Northwind 中的客户(customer)和订单(order)表。请注意,该应用程序只允许您检索、创建、更新和删除客户表的行,而订单表用作每个客户的查找表。
应用程序架构如下所示:
在上面的图中,您可以看到我不仅清晰地区分了 UI 和表示层,还将它们放在了不同的层中。这是为什么?要回答这个问题,让我们看看另一个设计模式。
模型-视图-呈现器(Model-View-Presenter)模式
用户界面难以测试是一个众所周知的问题。如果您将用户界面和表示逻辑都保留在视图对象(例如,Windows 窗体)中,并且需要对该视图对象应用自动化单元测试,您将陷入困境。为了解决这个问题,有一系列设计模式,如 模型-视图-控制器(Model-View-Controller)和模型-视图-呈现器(Model-View-Presenter)。我更喜欢使用 MVP 方法,因为它增强了可测试性。
MVP 模式的参与者是:
- 模型(Model):领域模型对象(例如,
Customer
类)。 - 视图(View):轻量级用户界面(例如,Windows 窗体)。
- 呈现器(Presenter):一个协调视图和模型之间交互的层。
Martin Fowler 描述了 MVP 中的视图和呈现器组件为 被动视图(Passive View) 和 监督控制器(Supervising Controller)。这意味着,视图不再简单地调用呈现器,而是将事件传递给呈现器。然后,视图变得被动,因为现在呈现器负责协调所有表示逻辑。使视图尽可能简单,使我们能够在表示层上创建自动化单元测试。此外,非测试视图功能的风险将非常小。
通常,在 MVP 实现中,呈现器负责更新模型。但在 Northwind 示例中,我使用了一个服务层作为访问模型的媒介。我将在本文稍后讨论服务层。
分离接口(Separated Interface)模式
仔细查看下面包图中的视图和呈现器之间的交互,我们会发现 Northwind 示例使用了 分离接口模式。名为 CustomerView
的 Windows 窗体实现了 ICustomerView
接口,该接口位于另一个程序集中(呈现器包)。CustomerPresenter
对象知道它需要控制一个视图,但由于它持有对 ICustomerView
接口的引用,因此它并不知道具体的视图将是什么样的。此模式是另一个有利于实现单元测试的良好设计,因为我们可以轻松地将“模拟”视图注入到呈现器中,从而对表示逻辑执行隔离的单元测试。
组件 (Widgets)
由于我们的 CustomerView
Windows 窗体是被动视图,它应该剥离任何表示逻辑,这不仅包括窗体级别的逻辑,还包括其 constituent 控件(又称“小部件”)所需的表示逻辑。这是通过将小部件公开为属性供 CustomerPresenter
对象访问来实现的。这些属性返回 UI 控件(请参阅 UI 控件包),这些控件充当真实 Windows 窗体控件的包装器。这些包装器类实现了位于 TransferObjects 包中的分离接口。为了阐明这个过程,让我们想象一下 CustomerPresenter
如何控制 CustomerView
Windows 窗体的 btnSaveButton
的行为。
- 在 TransferObjects 包中,有一个
IActionButton
接口,带有一个Click
事件和一个Enabled
属性。
public interface IActionButton
{
event EventHandler Click;
bool Enabled { get; set; }
}
WinActionButton
类,它实现了 IActionButton
接口。public class WinActionButton : IActionButton
{
public event EventHandler Click;
.
.
.
public bool Enabled
{
get { return underlyingButton.Enabled; }
set { underlyingButton.Enabled = value; }
}
}
IViewCustomerView
,带有一个名为 SaveActionButton
的属性,该属性返回一个 IActionButton
对象。public interface IViewCustomerView
{
.
.
.
IActionButton SaveActionButton { get;}
.
.
.
}
CustomerPresenter
类,带有一个公共方法 SaveCustomer
,该方法负责调用服务层中的保存功能。public class ViewCustomerPresenter
{
.
.
.
public void SaveCustomer()
{
.
.
.
}
}
CustomerView
Windows 窗体实现了 IViewCustomerView
接口。public partial class ViewCustomers : Form, IViewCustomerView
ViewCustomers
构造函数内部,它使用 CustomerPresenter
构造函数通过依赖注入创建一个 CustomerPresenter
实例。视图将自身注入到呈现器中,然后显式地将 btnSaveButton
的 Click
事件连接到 CustomerPresenter
的 SaveCustomer
公共方法。public ViewCustomers()
{
InitializeComponent();
presenter = new ViewCustomerPresenter(this);
.
.
.
this.SaveActionButton.Click += delegate { presenter.SaveCustomer(); };
.
.
.
}
表示层测试结果
服务层
上面提到,在 Northwind 应用程序中,呈现器不直接修改模型。相反,它应该调用服务层中的方法。服务层是一个很好的设计选择,因为它公开了应用程序的明确定义的流程。此外,它是一个定义事务范围的好地方;如果您想实现对域对象的事务性更新,您可以在服务层实现命令模式(Command pattern)(命令模式将使您能够支持域模型对象的撤销操作)。
事实上,呈现器甚至不知道领域模型。它只知道传输对象(Transfer Objects)(请参阅 TransferObjects 包)。应用程序示例中的传输对象是实际领域对象的只读副本。呈现器和服务层之间的所有交互都通过传输对象进行。应该强调的是,只有服务层知道如何以事务方式操作领域模型对象(即使我在 Northwind 示例中不使用事务性对象操作...)。服务层还保护领域模型不被表示层不正确地更新。
CustomerServices
类实现了 ICustomerServices
接口,该接口公开了 CRUD 方法(创建、检索、更新和删除),以及一个用于客户列表的额外方法:
NewCustomer(string customerId)
GetDetailsForCustomer(string customerId)
UpdateCustomer(CustomerTO customerTO)
DeleteCustomer(string customerId)
GetCustomerList()
CustomerServices
类引用了 IRepository
接口。CustomerServices
的客户端(在本例中是 Presenter 对象)可以使用其两种存储库“模式”之一来创建 CustomerServices
对象:数据库(Database)和内存(InMemory)。前者使 CustomerServices
实例化一个用于实时关系数据库(在我们的示例应用程序中是 SQL Server 2000)的存储库,而后者模式将指示 CustomerServices
使用内存存储库。存储库对象将在本文稍后讨论。
服务层测试结果
传输对象层
传输对象是只读的 POCO(Plain Old C Sharp Objects),它们在应用程序的层之间传输领域模型对象数据。在示例 Northwind 解决方案中,它们仅在表示层和服务层之间使用。传输对象定义上非常“笨拙”,并且不应携带任何类型的验证或业务逻辑。
TOHelper
类是一个静态类,提供两种功能:
- 从等效的领域模型对象创建传输对象;
- 从传输对象数据更新领域模型对象。
这两个函数都使用反射技术来检查对象属性并将数据从一个实例复制到另一个实例。这增加了数据传输过程的可重用性和可扩展性。
传输对象层测试结果
领域模型层
我从一个泛型类 DomainBase<t>
开始设计我的领域模型,其中参数化类型“t
”用于虚拟 ID 属性的类型。每个领域类都应该用一个合适的 ID 覆盖这个基 ID 属性。Customer ID 是一个字符串,而 Order ID 是一个整数。
Customer
类非常简单,它所做的唯一验证是针对 CompanyName
和 Phone
属性的 setter 中的非 null/非空值。
请注意,在 Northwind 示例中,领域对象类使用两个属性进行装饰:ActiveRecord
和 MappedTO
。
[ActiveRecord("Customers")]
[MappedTO("Northwind.TransferObjects.Model.CustomerTO")]
public class Customer : DomainBase<string>
ActiveRecord
属性提供领域对象映射到的表名。此属性由 ActiveRecord 框架(一个对象关系映射框架)使用,将在本文稍后讨论。MappedTO
属性提供传输对象的完整名称,并且由TOHelper
类(在 TransferObjects 包中)在领域对象到传输对象复制操作中使用。
工作单元(Unit of Work)模式
DomainBase
类还存储 IsNew
和 IsDirty
值,指示领域模型实例是新实例还是自上次持久化到存储库以来已被修改。
工作单元模式有助于最大程度地减少与数据库的往返次数。如果您要求存储库持久化一组对象,它将只针对标记为新或已修改的实例执行数据库往返。所有未标记为新且未修改的对象都将在过程中被忽略。
领域模型层测试结果
活动记录(Active Record)模式
Martin Fowler 将 Active Record 描述为:
一个封装数据库表或视图中的一行,封装数据库访问,并在此数据上添加领域逻辑的对象。
Northwind 示例使用 Castle Project ActiveRecord,这是一个非常好的 Active Record 模式实现。Castle ActiveRecord 构建在 NHibernate 之上,并提供了一套强大而简单的对象关系映射功能,使您可以非常轻松地持久化您的领域对象。使用 Castle ActiveRecord,您可以专注于设计您的领域模型,同时节省数据访问维护工作的时间。
正如我之前解释过的,领域对象充当 Active Record 对象。这是通过用 ActiveRecord
属性装饰每个领域对象类来实现的。此外,领域对象中的每个属性都必须用 PrimaryKey
或 Property
属性进行装饰,以指示属性映射到哪个表列。如果未提供表列名,ActiveRecord 框架将假定它与类属性同名。
[PrimaryKey(Column = "CustomerID", Generator = PrimaryKeyType.Assigned)]
public override string ID
{
...
[Property()]
public string CompanyName
{
...
存储库(Repository)模式
本文的最后一个模式是存储库模式。Edward Hieatt 和 Rob Mee 指出,存储库模式:
通过使用类似集合的接口来访问领域对象,在领域和数据映射层之间进行中介。
在 Northwind 示例中,存储库构成了数据访问层(Data Access layer)的核心。我实现了两个基于泛型、类似集合的 IRepository
接口的存储库:
public interface IRepository<t,t> where T : DomainBase<t>
{
T Load(t id);
void AddOrUpdate(ISession session, T domainModel);
void Remove(ISession session, T domainModel);
T[] FetchByExample(T example);
}
RepositoryBase
与 NHibernate 框架配合使用,负责将数据持久化到实时数据库(在本例中为 SQL Server 2000)或从中检索数据。让我们看看它如何实现AddOrUpdate
方法:
public void AddOrUpdate(ISession session, T domainModel)
{
if (domainModel.IsNew)
{
session.Save(domainModel);
domainModel.IsNew = false;
domainModel.IsDirty = false;
}
else
{
if (domainModel.IsDirty)
{
session.Update(domainModel);
domainModel.IsDirty = false;
}
}
}
InMemoryRepositoryBase
使用 Dictionary
变量,并使用此集合提供内存持久化功能。这是它实现 AddOrUpdate
方法的方式:public void AddOrUpdate(ISession session, T domainObject)
{
if (domainObject.IsNew)
{
domainObject.IsNew = false;
domainObject.IsDirty = false;
inMemoryDictionary.Add(domainObject.ID, domainObject);
}
else
{
if (domainObject.IsDirty)
{
domainObject.IsDirty = false;
inMemoryDictionary[domainObject.ID] = domainObject;
}
}
}
正如我在服务层部分之前描述的那样,呈现器将要求用户选择他们想要开始使用的存储库类型。然后,呈现器会创建一个 CustomerServices
实例,并将 RepositoryMode
传递给其构造函数。
数据访问层测试结果
4. 结论
通过本文,我希望为开发人员/学生提供有关将可测试性添加到其应用程序的重要性方面的见解。示例应用程序当然并不完美。也许它需要更多的解耦和工厂。我愿意尽我所能改进它,所以请随时发表您对此的看法。
文章历史
- 2007/05/23 - 初次发布。
- 2007/05/24 - 布局更改。
- 2007/05/30 - 添加了单元测试图像,更新了源代码。
- 2007/06/18 - Bug 修复:新订单未保存。