单元测试: 探索 Test Double 的状态集

来源:百度文库 编辑:神马文学网 时间:2024/04/26 14:33:30
单元测试
探索 Test Double 的状态集
Mark Seemann
代码下载位置:Testing2007_09.exe (271 KB)
Browse the Code Online
本文讨论: 使用 test double 进行单元测试 虚拟、存根、监视、虚设和模拟 手动和动态模拟 了解使用每种方法的时机和方式
本文使用了以下技术:
单元测试
 目录
虚拟的单元测试
了解创建存根的时机
将监视添加到测试中
创建单元测试的模拟
返回值
将它虚设
手动模拟
模拟事实
比较类型
在过去的几年中,单元测试盛极一时;但是当大多数开发人员了解了整体概念后,发现某些方面变得更加难以捉摸。其中一个方面就是如何出于测试目的有效地替换组件服务器。大多数人将这些取代称为存根或模拟,但是正如我在本文中介绍的那样,存根和模拟只是更大的状态集中的两种取代类型。
对于为测试目的而替换实际组件服务器的对象,Gerard Meszaros (xunitpatterns.com) 建议为其使用术语“test double”(与术语“stunt double”相似)作为通用名称。虽然 test double 自身就是通用术语,但仍使用特定名称来描述特定实现,如图 1 所示。
 Figure 1 定义 Test Double
Test Double 类型 说明
虚拟 最简单最原始的 test double 类型。虚拟不包含任何实现,且主要是在需要作为参数值时使用,而不作他用。空值可以认为是虚拟,但是真正的虚拟是接口或基类的派生,根本没有任何实现。
存根 作为虚拟的进一步,存根是最低程度的接口或基类实现。返回 void 的方法通常根本不包含任何实现,而返回值的方法则通常会返回硬编码值。
监视 测试监视与存根相似,但是除了向客户端提供可以调用其上成员的实例之外,监视还会记录已调用了哪些成员,以便单元测试验证是否按预期调用了成员。
虚设 虚设包含更复杂的实现,通常处理它继承的类型的不同成员之间的交互。虚设虽然不是完整的产品实现,但是可能类似于产品实现,尽管它使用了一些快捷方式。
模拟 模拟是由模拟库动态创建的(其他类型通常由测试开发人员使用代码创建)。测试开发人员从来不会看到实现接口或基类的实际代码,但是他们可以配置模拟来提供返回值,预见将会调用特定成员等等。根据配置的不同,模拟的行为可以类似于虚拟、存根或监视。
尽管这些类型似乎在理论上是截然不同的,但在实践过程中这些差异变得越来越模糊了。因此,我认为将 test double 视为存在一个状态集是比较合理的,如图 2 所示。在一个极端,您会发现完全没有实现的虚拟;而在另一个极端,则是完全的生产实现。虚拟和生产实现都已明确定义,但是存根、监视和虚设就较难界定:测试监视什么时候会变成虚设呢?此外,模拟在状态集中占据了相当大的跨度,因为它们在某些实例中可能非常复杂,但是在某些实例中又非常简单。
图 2 Test Double 的范围 (单击该图像获得较大视图)
即使这听起来非常有道理,但实际上并非如此,我会在文章的其余部分使用大量的示例来阐明这些概念。由于篇幅所限,我决定不在各处介绍太多的类,而只介绍简单的实现。如果您感觉遗漏了什么,可从《MSDN® 杂志》网站获得本文的下载部分,其中包含完整代码。
虚拟的单元测试
在 test double 状态集中,虽然可以说模拟占据的极端位置比虚拟要稍多一些,但后者更易于了解,因此我将从一个涉及虚拟的示例开始介绍。
在特别缺乏想象力的时候,我决定在例子中使用经过试验的真正的在线商店订单处理情形。考虑一下图 3 中重现的简单 Order 类。最需要注意的是构造函数使用了 IShopDataAccess 的实例,它被定义为:
 Figure 3 Order 类
复制代码
public class Order{private int orderId_;private IShopDataAccess dataAccess_;private OrderLineCollection orderLines_;public Order(int orderId, IShopDataAccess dataAccess){if (dataAccess == null){throw new ArgumentNullException("dataAccess");}this.orderId_ = orderId;this.dataAccess_ = dataAccess;this.orderLines_ = new OrderLineCollection(this);}public OrderLineCollection Lines{get { return this.orderLines_; }}public void Save(){this.dataAccess_.Save(this.orderId_, this);}internal IShopDataAccess DataAccess{get { return this.dataAccess_; }}}
复制代码
public interface IShopDataAccess{decimal GetProductPrice(int productId);void Save(int orderId, Order o);}
为了对这个 Order 类进行单元测试,我需要提供 IShopDataAccess 的 test double,因为如果我传入空值,构造函数就会引发异常。我可以创建的最简单的 test double 是虚拟,Visual Studio® 2005 可使此过程变得非常容易:只要在单元测试项目中创建一个新类,称其为 DummyShopDataAccess,并让其实现 IShopDataAccess。然后在 Visual Studio 中,可以单击接口名称的智能标记,再选择“实现接口 IShopDataAccess”,这样会导致 Visual Studio 创建该接口的所有成员。除了它们的签名以外,所有成员的实现都相同,因此我在这里将只介绍 Save 方法:
复制代码
public void Save(int orderId, Order o){throw new Exception("The method or operation is not implemented.");}
使用 Visual Studio 创建虚拟只需要不到 10 秒的时间,不论这个类的成员数为多少,您都只需编写一行代码:包含接口声明的类声明。
有了 DummyShop DataAccess 类,就很容易编写 Order 类的简单单元测试了:
复制代码
[TestMethod]public void CreateOrder(){DummyShopDataAccess dataAccess = new DummyShopDataAccess();Order o = new Order(2, dataAccess);o.Lines.Add(1234, 1);o.Lines.Add(4321, 3);Assert.AreEqual(2, o.Lines.Count);// More asserts could go here...}
此时,虚拟就足够了,因为 IShopDataAccess 接口没有在任何针对测试目标的方法中使用,因此我只需要使用它来通过构造函数中的持续输入验证。显然,一旦我试图调用实际上使用 IShopDataAccess 实例的成员(例如 Save 方法),而且如果我准备使用 DummyShopDataAccess,我的测试就会崩溃,因为调用时它会引发异常。为进行这些测试,我可以使用其他 test double 类型之一,请继续观看演示。
了解创建存根的时机
一旦您的单元测试调用了测试目标上的成员,而该成员又随后调用了 test double 上的成员,那么您至少需要一个不会引发异常的类。满足此条件的最简单的 test double 类型是存根。
在下一个示例中,我将编写可以调用 Order 类上的 Save 方法的单元测试。这会随之调用 IshopDataAccess 接口上的 Save 方法,因此我需要以不引发异常的方式来实现 Save 方法。由于这个方法会返回 void,因此实现起来非常简单:
复制代码
public void Save(int orderId, Order o) { }
如果您对语义有兴趣,可能希望知道此实现究竟是虚拟还是存根。声称该代码行中有很多实现是一种夸大,如果我在引发异常时漏下了其他成员,您可能会认为这个 test double 实际上是一个虚拟。另一方面,虚拟应该只可用来填写必需的参数,而此实现可以用于其他类型,因为客户端可以调用其 save 方法而不发生崩溃;这样您可能会认为此实现是一个存根。
虽然有可能提出比虚拟或存根更强大的定义,但是我发现除了命名以外,这种模糊概念没什么实际效果。但是,它很好地解释了为什么我认为 test double 存在状态集,而不具有考虑周到的类型。在这种特殊的情况下,我将选择调用我的新类 StubShopDataAccess,因为稍后我可能要向其他一些成员添加更多实现。
将 StubShopDataAccess 放在适当的位置后,现在我就可以写入以下单元测试了:
复制代码
[TestMethod]public void SaveOrder(){StubShopDataAccess dataAccess = new StubShopDataAccess();Order o = new Order(3, dataAccess);o.Lines.Add(1234, 1);o.Lines.Add(4321, 3);o.Save();}
调用 Order 类上的 Save 会调用 StubShopDataAccess 上的 Save 方法,该方法不执行任何任务。因此,测试成功仅仅是因为它没有失败,而不会真正验证测试目标。
将监视添加到测试中
虽然研究 test double 是本文的主题,但请记住,单元测试的目的是验证测试目标(在这种情况下,是 Order 类)是否会像预期那样发挥作用。对于 Save 方法而言,预期的效果是它应该调用 IShopDataAccess 接口上的 Save 方法,但是之前定义的测试不会进行此验证,因为如果 Order.Save 具有一个空白实现(也就是说,如果它没有调用 IShopDataAccess.Save),它仍然会成功。
为验证是否已调用 Save 方法,test double 应对它是否被调用过的情况进行记录。由于记录成员调用以便此后进行验证是测试监视的主要区别特征,因此我会创建一个新类,称其为 SpyShopDataAccess,如图 4 所示。
 Figure 4 Spy 类
复制代码
internal class SpyShopDataAccess : IShopDataAccess{private bool saveWasInvoked_;#region IShopDataAccess Members// Other IShopDataAccess members ommitted for brevitypublic void Save(int orderId, Order o){this.saveWasInvoked_ = true;}#endregioninternal bool SaveWasInvoked{get { return this.saveWasInvoked_; }}}
我想再一次指出,测试监视类型不会占用 test double 状态集中经过慎重划分的空间。在这种情况下,如果 IShopDataAccess 接口的其他方法仍会引发异常,该怎么办呢?那么它会是半虚拟半监视吗?或者如果其他方法提供了硬编码形式的返回值,但不记录它是否已被调用,那么它会不会非常类似于存根?
即使在记录所有成员中的调用时,也可以选择不同的方式来执行。在图 4 中,我使用了最简单的方法(只需设置一个标志),但是我还可以记录调用的次数,如下所示:
复制代码
private int saveInvocationCount_;public void Save(int orderId, Order o){this.saveInvocationCount_++;}internal int SaveInvocationCount{get { return this.saveInvocationCount_; }}
此实现捕获的信息量明显多于第一次实现,但这是否意味着它比另一个监视进行更多的监视呢?我们甚至可以创建更多的高级监视,方法还是通过记录每个调用的输入参数,但是这需要实现更多代码,而且这样会将此类监视置于 test double 状态集的更右侧。再次重申,因为存根和监视间的边界比较模糊,因此将 test double 看成占据状态集是有道理的。
凭借 SpyShopDataAccess 类,现在我可以编写一个单元测试,验证 Order 是否真的已被保存:
复制代码
[TestMethod]public void SaveOrderWithDataAccessVerification(){SpyShopDataAccess dataAccess = new SpyShopDataAccess();Order o = new Order(4, dataAccess);o.Lines.Add(1234, 1);o.Lines.Add(4321, 3);o.Save();Assert.IsTrue(dataAccess.SaveWasInvoked);}
这个简单的测试仅验证是否调用了 Save 方法,但是不会测试参数被使用的次数或者使用的是哪些参数。验证此类型的数据通常也是一个好办法,但是这样您将必须编写一个更高级的测试监视或采用模拟,这在默认情况下通常会记录所有调用。
创建单元测试的模拟
模拟与虚拟、存根、监视和虚设非常不同,后者都是由测试开发人员创建的。模拟是通过调用在运行时创建的动态模拟对象上的方法而创建的。
在我 2004 年 10 月的《MSDN 杂志》的文章“单元测试:模拟对象派上用场!使用 NMock 测试您的 .NET 代码”(msdn.microsoft.com/msdnmag/issues/04/10/NMock) 中,我介绍了动态模拟的基本知识以及如何使用名为 NMock 的特殊模拟库。在此,我将使用名为 Rhino Mocks 的另一个可用模拟库(可在ayende.com/projects/rhino-mocks.aspx 上找到)。
这次我不会创建实现 IShopDataAccess 接口的新代码文件,而是指示 Rhino Mocks 在运行时创建实现接口的对象,如图 5 所示。为了可以按预期运行,需要将模拟配置为可以预见该接口上将调用哪些成员。模拟库会以另一种方式来实现,但是 Rhino Mocks 的工作方式是允许您从记录预期值开始。实质上,模拟有两种模式:一种是记录模式,在此模式下单元测试可以定义预期值;一种是重放模式,在此模式下客户端可以调用模拟上的成员。
 Figure 5 动态模拟
复制代码
[TestMethod]public void SaveOrderAndVerifyExpectations(){MockRepository mocks = new MockRepository();IShopDataAccess dataAccess = mocks.CreateMock();Order o = new Order(6, dataAccess);o.Lines.Add(1234, 1);o.Lines.Add(4321, 3);// Record expectationsdataAccess.Save(6, o);// Start replay of recorded expectationsmocks.ReplayAll();o.Save();mocks.VerifyAll();}
在图 5 的示例中,设置预期值非常容易,因为我期望的唯一结果只是调用一次 Save 方法。记录下所有预期值后,我通过调用 ReplayAll 将模拟切换为重放模式。
当我调用 Order 对象上的 Save 时,它会随之调用模拟上的 Save 方法。由于模拟处于重放模式,因此它会记录下已经使用预期的参数调用了该方法。如果调用了 Save,但使用的参数不同,则模拟会引发异常。在结束时调用 VerifyAll 可以完全确保在模拟中调用了 Save 方法 — 如果未调用此方法,则会引发异常。
由于模拟可以验证是否执行了预期调用,因此它的工作方式与测试监视非常相似。但是,它仅存在于运行时,因此要确定模拟在 test double 状态集上的具体位置并不容易。这就是我倾向于认为模拟占据状态集相当大一部分的原因。
返回值
至此,我仅介绍了不返回任何值的模拟单一方法 (Save) 的情况。正如我已说明过的那样,实现返回 void 的方法的存根可以非常简单。而对于返回值的方法,情况会变得稍微复杂一些。为了向您说明这种复杂性,我将通过 Order 示例展开论述。
Order 类包含 OrderLine 对象的集合。每个 OrderLine 对象都包含一个 productId、一个数量和一个对其所属的 Order 的引用。为了计算每行的总数,OrderLine 类包含有 Total 属性:
复制代码
private decimal? total_;public decimal Total{get{if (!this.total_.HasValue){decimal unitPrice =this.owner_.DataAccess.GetProductPrice(this.productId_);this.total_ = unitPrice * this.quantity_;}return this.total_.Value;}}
请注意,此实现会调用 IShopDataAccess 上的 GetProductPrice。这说明要测试 Total 属性,所用的任何 test double 都必须从 GetProductPrice 返回一个值。实现此效果最简单的方法就是返回一个硬编码值:
复制代码
public decimal GetProductPrice(int productId){return 25;}
使用 StubShopDataAccess 中的这个实现,我现在可以编写单元测试:
复制代码
[TestMethod]public void CalculateSingleLineTotal(){StubShopDataAccess dataAccess = new StubShopDataAccess();Order o = new Order(7, dataAccess);o.Lines.Add(1234, 2);decimal lineTotal = o.Lines[0].Total;Assert.AreEqual(50, lineTotal);}
因为第一个订单行的产品是 1234,数量为 2,所以该行的总计值是 50(因为 StubShopDataAccess 返回的单价是 25)。
尽管此实现非常简单,但是并没有太大的灵活性,因为无法使用多个订单行(每个产品都有不同的单价)来测试 Order。显然,一个可行的解决方案就是更改 GetProductPrice 的实现,如下所示:
复制代码
public decimal GetProductPrice(int productId){switch (productId){case 1234:return 25;case 2345:return 10;default:throw new ArgumentException("Unexpected productId");}}
请注意条件逻辑是如何应用到 test double 中,使实现变得稍微有些复杂的。此时,您可能希望知道它是否仍然是存根:它正在返回硬编码值,但是它包含了条件逻辑。
原则上,您可以添加任意数量的 case 语句,但是正如您可能已从上一个单元测试示例中注意到的那样,仅通过查看测试代码,不会很明确存根将要返回的内容。
将它虚设
在某些实例中,创建更易于配置的 test double 可能更可行。如果是 IShopDataAccess,则此接口是数据库的真正抽象,因此一种方法就是创建一个原始的内存中数据库。图 6 中显示的 FakeShopDataAccess 类大概是可能的最原始类,因为它不具有引用完整性,也不具有数据库中通常需要的任何其他功能。它包含一个集合对象,该对象基本上可以用作数据库表的替代项。因为我需要能够根据产品 ID 对产品编制索引,因此我创建了仅仅从 KeyedCollection 派生的 ProductCollection 类(未显示);Product 类基本上是产品数据的属性包,同时也是只为此目的而创建的另一个仅用于测试的类。
 Figure 6 原始的测试数据库
复制代码
internal class FakeShopDataAccess : IShopDataAccess{private ProductCollection products_;internal FakeShopDataAccess(){this.products_ = new ProductCollection();}#region IShopDataAccess Memberspublic decimal GetProductPrice(int productId){if (this.products_.Contains(productId)){return this.products_[productId].UnitPrice;}throw new ArgumentOutOfRangeException("productId");}public void Save(int orderId, Order o) { }#endregioninternal IList Products{get { return this.products_; }}}
您可能注意到了,FakeShopDataAccess 并不保存传递给其 Save 方法的任何订单。这是我选择使用的快捷方式,因为 IShopDataAccess 接口不包含任何会返回已保存订单的成员。但是,如果我想要使用这个类来验证 Save 方法,我会将 Order 对象添加到内部字典,稍后验证该字典的内容。这会将测试监视特征添加到虚设,再一次说明了 test double 各类型之间的边界是模糊的。
有了 FakeShopDataAccess 类,数据访问层中的内部情形现在变得更加清晰,正如您在图 7 中看到的那样。在创建测试目标(Order 对象)之前,我会创建并填充产品及其单价的列表。在执行测试的时候,GetProductPrice 会返回列表中所请求产品的单价。
 Figure 7 使用虚设数据库测试
复制代码
[TestMethod]public void CalculateLineTotalsUsingFake(){FakeShopDataAccess dataAccess = new FakeShopDataAccess();dataAccess.Products.Add(new Product(1234, 45));dataAccess.Products.Add(new Product(2345, 15));Order o = new Order(9, dataAccess);o.Lines.Add(1234, 3);o.Lines.Add(2345, 2);Assert.AreEqual(135, o.Lines[0].Total);Assert.AreEqual(30, o.Lines[1].Total);}
请注意,这个测试比利用存根的类似测试更易读,因为您不必转到存根代码来查看它将返回哪些值。创建虚设的缺点可能就是对其本身需要花费一些精力。对于 FakeShopDataAccess,我最终创建了三个类:FakeShopDataAccess 本身、ProductCollection 类和 Product 类。IShopDataAccess 接口相当简单,因此对于更为复杂的接口,这个缺点会更加明显。
手动模拟
单元测试的黄金规则之一就是,您应当编写一些相当简单的代码来测试复杂的代码。创建复杂的虚设会在某种程度上与该目的背道而驰。如果您曾经着手考虑对虚设本身进行单元测试,那么您对此可能做得有些过度。
动态模拟是这一难题的最终答案,但您可能并不是总希望使用完整的模拟库。模拟库是您需要在测试项目中引用的一个额外程序集,如果您正在从事一个基于团队的项目,就需要解决以下问题:标准化通用模拟库,将其安装在所有开发人员计算机上(或将二进制置于源代码控制之下),以及与应用程序部署同步的所有其他细节。这就是为什么有时候我喜欢将被我称为手动模拟的项放在一起的原因。
假设我想在不使用虚设的情况下重新编写图 7 中的测试,但仍保持 test double 处于半透明状态。对于这个测试,我关心的唯一 IShopDataAccess 成员是 GetProductPrice 方法,因此我创建图 8 中显示的 Manual Mock 类来处理这个特定方法。请注意,构造函数使用它保存的 Converter,并将其用于实现 GetProductPrice 方法。
 Figure 8 Manual Mock 类
复制代码
internal class ProductPriceMockShopDataAccess : IShopDataAccess{private Converter implement_;internal ProductPriceMockShopDataAccess(Converter productPriceCallback){this.implement_ = productPriceCallback;}#region IShopDataAccess Memberspublic decimal GetProductPrice(int productId){return this.implement_(productId);}public void Save(int orderId, Order o){throw new NotImplementedException();}#endregion}
Converter 是一个委托,它使用 int 形式的输入,并返回十进制的值。如果您参考了 IShopDataAccess 的定义,那么就会注意到,这个签名与 GetProductPrice 相同。这意味着,我可以使用 Converter 来实现 GetProductPrice,这正是我在图 8 中执行的操作。
这个手动模拟本身不包含任何实现,但它就是单元测试要使用的基本框架,如图 9 所示。在这个单元测试中,我会使用在调用 GetProductPrice 时将调用的匿名方法来初始化该模拟。从本质上来说,这与配置动态模拟的方法非常相似,您需要首先定义此模拟的预期值,然后再创建和实施测试目标。
 Figure 9 使用手动模拟
复制代码
[TestMethod]public void CalculateLineTotalsUsingDelegate(){ProductPriceMockShopDataAccess dataAccess =new ProductPriceMockShopDataAccess(delegate(int productId){switch (productId){case 1234:return 45;case 2345:return 15;default:throw new ArgumentOutOfRangeException("productId");}});Order o = new Order(10, dataAccess);o.Lines.Add(1234, 3);o.Lines.Add(2345, 2);Assert.AreEqual(135, o.Lines[0].Total);Assert.AreEqual(0, o.Lines[1].Total);}
虽然图 9 中的单元测试看起来比图 7 中显示的使用虚设的测试要复杂,但是表象有时是具有欺骗性的。尽管测试本身比较冗长,但是支持测试的代码的总大小还是比较小的,因为虚设需要三个支持类(包括虚设本身),而手动模拟仅需要模拟本身。
使用委托进行模拟的好处是测试开发人员会为每个单元测试编写一个新的实现。这不仅使您可以更改返回值和行为,而且还可以决定想要执行的监视级别。如果您只是想返回值,则可以通过使用与图 9 中显示的代码相似的代码来获得,但是如果还想为稍后的验证来记录方法调用,也可以轻松做到,因为匿名方法允许访问外部变量。
图 8 显示的 ProductPriceMockShopDataAccess 类的主要缺点是它虽然仅模拟 GetProductPrice 方法,但是会引发其他成员中的 NotImplementedException。显然,您可以通过一般化该方法来进一步推广手动模拟的概念。您可以在 IShopDataAccess 的通用手动模拟中做的一件事是为接口的每个成员定义 Converter,但是如果您的接口有很多成员,这很快会变得难以处理。
更好的方法可能是定义可用于实现任何成员的委托,如下所示:
复制代码
public delegate void ImplementationCallback(MemberData member);
此委托仅定义返回 void 的方法,该方法使用 MemberData 对象作为输入。MemberData 类相当简单,因此我不会在这里大废篇章来解释它,但已将其包含在本文的代码下载中。它基本上是一个属性包,包含了已调用成员的名称、参数值的列表,以及可用来设置某个实现可能需要提供的任何返回值的属性。返回值是可选的,因为并非所有的方法都需要返回值(例如,Save 方法返回 void)。
使用 ImplementationCallback 委托,我可以创建更通用的手动模拟,如图 10 所示。与之前一样,构造函数用于使用将包含实现的委托填充模拟。每个成员实现都遵循通用模式:将 MemberData 对象构造成包含已调用方法的名称,以及任何参数的名称和值。然后在调用委托的时候将成员变量用作参数。如果该方法应该返回任意值,则该值会从成员变量中提取并返回。实现委托的责任是设置 ReturnValue 属性。
 Figure 10 通用的手动模拟
复制代码
internal class MockShopDataAccess : IShopDataAccess{private ImplementationCallback implement_;internal MockShopDataAccess(ImplementationCallback callback){this.implement_ = callback;}#region IShopDataAccess Memberspublic decimal GetProductPrice(int productId){MemberData member = new MemberData("GetProductPrice");member.Parameters.Add(new ParameterData("productId", productId));this.implement_(member);return (decimal)member.ReturnValue;}public void Save(int orderId, Order o){MemberData member = new MemberData("Save");member.Parameters.Add(new ParameterData("orderId", orderId));member.Parameters.Add(new ParameterData("o", o));this.implement_(member);}#endregion}
使用该方法的测试看起来和之前的测试很相似,如图 11 所示。因为 MemberData 包含了有关调用过的成员的信息,因此执行初始检查是十分明智的,它会检查是否调用了预期方法,如果没有则引发异常。如果调用的方法是 GetProductPrice 方法,则测试代码会设置 MemberData 输入参数上的相关返回值。这个值是 MockShopDataAccess 随后将返回给其调用方的值。
 Figure 11 使用通用的模拟
复制代码
[TestMethod]public void CalculateLineTotalsUsingManualMock(){MockShopDataAccess dataAccess =new MockShopDataAccess(delegate(MemberData member){if (member.Name == "GetProductPrice"){int productId = (int)member.Parameters["productId"].Value;switch (productId){case 1234:member.ReturnValue = 45m;break;case 2345:member.ReturnValue = 15m;break;default:throw new ArgumentOutOfRangeException("productId");}}else{throw new InvalidOperationException("Unexpected member");}});Order o = new Order(11, dataAccess);o.Lines.Add(1234, 3);o.Lines.Add(2345, 2);Assert.AreEqual(135, o.Lines[0].Total);Assert.AreEqual(30, o.Lines[1].Total);}
您可能已注意到,创建这种通用手动模拟遵循一个可重复的模式:首先,创建类型及其成员;然后在每个成员中创建 MemberData 对象,调用委托并返回 ReturnValue 属性(如果适用)。此实现模式可以自动进行,因此可以使用一些 Reflection 和 CodeDOM 逻辑来创建和生成工作方式完全相同的运行时对象。虽然这不在本文的范围内,但是我已在代码下载中提供了阐明该方法的基本证明。在此,DelegateMock 类可以动态创建基于委托的模拟。该单元测试看起来几乎与图 11 中的测试一样,不同的只是创建模拟的那一行,该行现在如下所示:
复制代码
IShopDataAccess dataAccess =DelegateMock.Create(delegate(MemberData member){...});
正如常规模拟一样,这个手动模拟是动态创建的,且仅存在于运行时。实际上,DelegateMock 和动态模拟的唯一差别就在于它们实现接口或模拟的基类的方式。
基于委托的模拟的优势在于它们提供较高的自由度,以及相对较低的入门门槛。虽然很多开发人员认为,动态模拟难以理解和学习,但是却有更多的开发人员了解委托,并知道如何编写代码。因为所有的预期值、返回值和调用记录等等都必须以代码的形式明确地写出来,大多数开发人员已经知道如何完成这些任务,而不必学习新的对象模型。
模拟事实
虽然手动模拟有很多优点,但也不乏缺点,缺点之一就是在每个单元测试中提供基于委托的实现很快变得相当冗长,在您还需要记录诸如测试监视之类的调用时尤其如此。不过,也许最大的缺点就是将委托编写成实现可能会迅速成为一个相当乏味而又容易出错的工作。
尽管我发现手动模拟就像应急模拟那样便利,但是不瞒您说,我个人更喜欢动态模拟。好的模拟库会创建易于配置的模拟,而且这些模拟可以同时充当测试监视。
通过 Rhino Mocks,可以非常容易地编写我在本文中显示的不同变化的行总数示例。创建模拟本身只需要两行代码就够了,并且没有支持类:
复制代码
MockRepository mocks = new MockRepository();IShopDataAccess dataAccess = mocks.CreateMock();
然后,可以使用变量 dataAccess 创建新的 Order 实例,就像之前的示例一样。模拟是在记录模式下创建的,因此定义预期值和返回值只要使用两行代码就可以完成:
复制代码
Expect.Call(dataAccess.GetProductPrice(1234)).Return(45m);Expect.Call(dataAccess.GetProductPrice(2345)).Return(15m);
第一行只是声明模拟应预见用值 1234 来调用 GetProducPrice 方法,真正发生后应返回 45。很明显,第二行用于为产品 ID 2345 设置相似的预期值。
默认情况下,模拟可以充当测试监视,因此无需更多工作,在测试结束的时候调用 mocks.VerifyAll 来验证是否已满足所有的预期值即可。
比较类型
在图 12 中,我已列出了不同类型的 test double 及其优缺点。虽然这样的表格似乎指出了这些类型是经过了谨慎考虑而又各不相同的,但是我还是希望本文已经说明了这些边界实际上模糊不清,并且所有 test double 都是状态集的占据者。似乎这个区别主要是指语义值,但我坚信,我们使用的词语形成了我们思考抽象概念的方式,因此在描述这类现象时,尽可能地精准一些是非常重要的。
 Figure 12 Test Double 的优点和缺点
Test Double 类型 优点 缺点
虚拟 非常容易创建。 不是非常有用。
存根 容易创建。 灵活性有限。从单元测试观察时,是不透明的。无法验证成员是否被正确调用。
监视 可以验证成员是否被正确调用。 灵活性有限。从单元测试观察时,是不透明的。
虚设 提供可用于许多不同方案的半成品实现。 较难创建。可能非常复杂,甚至要求对自身进行单元测试。
模拟 能够有效创建 test double。可以验证成员是否被正确调用。从单元测试观察时,是透明的。 陡峭的学习曲线。
在我看来,至少对于大多数情形来说,好的动态模拟库可以代替虚拟、存根和监视。如果您的环境需要为一个相当复杂的接口或基类创建 test double,那么有时候虚设提供的生产力比模拟提供的生产力会更高。
模拟需要在每次和每个测试中明确定义所有的预期值。随着测试目标和 test double 之间的交互越来越密切,相对于编写可操作的虚设所需的工作量而言,有关定义多种预期值的工作也可能变得越来越单调和费力。遗憾的是,识别发生这种情况的关键点更是一种艺术形式,而非一门精密科学。
Mark Seemann供职于丹麦哥本哈根的 Microsoft Services 部门,其工作职责是帮助 Microsoft 客户及合作伙伴架构、设计和开发企业级应用程序。您可以通过 Mark 的博客blogs.msdn.com/ploeh 与他联系。