TDD这个概念出现至少也有两年多了, 在大家刚接触它时候, 几乎没人不拍手鼓掌, 测试驱动的概念确实可以为我们带来很多的好处. 泡泡在前不久写了一篇很不错的TDD的文章, 可是我还是忍不住在他的评论中说到这篇文章有点”老”. 确实,同样的概念在书中, 在大家的口中已经被说烂了. 但是TDD真正给我们带来什么呢? 有多少人在用TDD呢? 为什么这么好的技术到了应用中就被人们”抛弃”了呢?
(注: Design Pattern面世都十几年了, 现在还是有不少好书在介绍它,所以”老”不代表不好,泡泡的总结在园子里还是最好的一篇TDD文章, 特别建议刚接触TDD的参考之.)
TDD的应用是阻挠TDD发展的重要原因, 回想一下有关TDD书中举的那些例子. 再想想你学习了解TDD自己又做了哪些应用? --- 类库, 最基本的几个类之间协作, 不涉及数据库,不涉及UI,不涉及企业服务. 这样看来TDD最适用的场合就是不涉及复杂应用的类库. 比如一个保龄球游戏, 一个Money兑换系统. 如果是这样那TDD自然没有人用了, 真正的项目有几个不涉及复杂应用呢, 有几个不和数据库,界面打交道? 可是一旦你想将TDD应用于此时, 你就会发现烦不胜烦, 无从下手.
难道TDD真得这么不堪一击? 先来看一个小例子.
在我们的一个应用中需要将事情的处理结果用邮件发送到相应的客户, 企业应用中很常见的一个例子. 这时我们需要使用到邮件服务器了, 算是一个稍微复杂的应用.
3 public class BusinessReporter
4 {
5 public IEmailService emailService; // an external object.
6
7 public BusinessReporter(IEmailService email)
8 {
9 this.emailService = email;
10 }
11
12 // method under test.
13 public void Report()
14 {
15 //do something real
16 string to="idior"; //just for test
17 string message="hello";
18 emailService.Send(message,to);
19 }
20 }
就这样一段简单的代码, 你如何编写测试代码? 如果你想真实的测试邮件到底有没有发出去, 你甚至得专门为此写一个读取邮箱内容的组件.
19 [Test]
20 public void TestReport()
21 {
22 EmailService emailService=new EmailService();
23 BusinessReporter bizReporter = new BusinessReporter(emailService);
24 bizReporter.Report();
25 EmailChecker emailChecker=new EmailChecker();
26 Assert.Equals("hello",emailChecker.GetEmail("idior"));
27 }
看看你要做什么, 在实现这个Report功能前,你要实现EmailService, 并且还要专门为测试去做一个EmailChecker这个类.但是别忘了你的目的仅仅是测试BusinessReporter类下 Report的方法. 这里是单元测试不是集成测试, 如果为了测试一个功能而牵涉到很多的对象是不利于隔离错误的. 但是不这样做,又如何测试Report方法是否正确呢?
Introduce Mock Object
从这个例子可以看出仅仅利用NUnit的TDD是无法很好的完成单元测试的任务. 甚至根本无法进行实际的TDD开发. 试想一下我在测试我的业务逻辑的时候, 其中调用了Persistence Logic, 比如在Domian Object中使用了DAL. 这个时候我怎么测试我的业务逻辑, 难道必须先写好DAL再来做吗? 难道我要把被测对象所依赖的对象都实现了,才能完成我的被测对象? 因此光靠NUnit是很难胜任那种需要多对象协作的业务的开发的. 或许你已经知道我要引出谁了. 没错 Mock Object. 这个远没有NUint为人熟悉, 但是在TDD实际开发中绝对重要的角色. 可以说没有它(Mock)你根本无法在TDD下完成实际中的企业开发.
让我们看看使用了Mock对象后,上面那个BusinessReporter又是如何测试的.
1 [Test]
2 public void TestReport()
3 {
4 using (MockRepository mocks = new MockRepository())
5 {
6 //set up
7 IEmailService emailSvcMock= mocks.CreateMock(typeof(IEmailService)) as IEmailService;
8 BusinessReporter bizReporter = new BusinessReporter(emailSvcMock);
9
10 //what we expect to happen
11 emailSvcMock.Send("hello", "idior");
12
13 //end record
14 mocks.ReplayAll();
15
16 //execute the test method
17 bizReporter.Report();
18 }//verify when mocks dispose
19 }
20 //注: 本文采用了Rhino Mocks2作为Mock框架,详见Rhino Mocks2介绍
怎么样比上面的方法简单了许多吧.
既然说到了Enterprise Test Driven, 就不能不提有关Data Access Logic的测试, 下面就来看一个例子. 这是一个类似电子购物的系统. 你有一个购物篮, 篮子中装有一些Item, 每个Item对应了一种商品以及它的数量. 当我们设定购买一类商品时, 就创建一个Item, 并为该Item赋上它的商品ID和数量, 在设定ID的同时,我们从数据库中获得该商品的价格以及名称(见Class BasketItem
1 [Test]
2 public void BasketStub()
3 {
4 using (MockRepository mocks = new MockRepository())
5 {
6 IShoppingDataAccess dataAccess = mocks.CreateMock(typeof(IShoppingDataAccess)) as IShoppingDataAccess;
7 Basket b = new Basket(dataAccess);
8
9 SetupResult.On(dataAccess).Call(dataAccess.GetUnitPrice(1)).Return(new decimal(99));
10 SetupResult.On(dataAccess).Call(dataAccess.GetProductName(1)).Return("The Moon");
11 SetupResult.On(dataAccess).Call(dataAccess.GetUnitPrice(5)).Return(new decimal(47));
12 SetupResult.On(dataAccess).Call(dataAccess.GetProductName(5)).Return("Love");
13
14 mocks.ReplayAll();
15
16 b.AddItem(new BasketItem(1, 2, dataAccess));
17 b.AddItem(new BasketItem(5, 1, dataAccess));
18
19 Assert.AreEqual(99*2+1*47, b.CalculateSubTotal());
20 }
21 }
1 public class Basket
2 {
3 private ArrayList basketItems;
4 private Guid basketID;
5 private IShoppingDataAccess dataAccess;
6 public Basket(IShoppingDataAccess dataAccess)
7 {
8 Initialize(dataAccess);
9 }
10 public void AddItem(BasketItem item)
11 {
12 basketItems.Add(item);
13 }
14 public void Save()
15 {
16 dataAccess.SaveBasketItems(basketID, (BasketItem[])basketItems.ToArray(typeof(BasketItem)));
17 }
18 public decimal CalculateSubTotal()
19 {
20 decimal subTotal = 0;
21 foreach (BasketItem item in basketItems)
22 {
23 subTotal += item.GetPrice();
24 }
25 return subTotal;
26 }
27 private void Initialize(IShoppingDataAccess dataAccess)
28 {
29 this.dataAccess = dataAccess;
30 basketItems = new ArrayList();
31 basketID = Guid.NewGuid();
32 }
33 }
1 public class BasketItem
2 {
3 private decimal unitPrice;
4 private int productID;
5 private int quantity;
6 private IShoppingDataAccess dataAccess = null;
7 private string productName;
8
9 public BasketItem(int productID, int quantity, IShoppingDataAccess dataAccess)
10 {
11 Initialize(productID, quantity, dataAccess);
12 }
13
14 public decimal UnitPrice { get { return unitPrice; } }
15
16 public int ProductID
17 {
18 get { return productID; }
19 set //when the productID being setted, we get the price and name from database synchronized
20 {
21 productID = value;
22 unitPrice = dataAccess.GetUnitPrice(productID);
23 productName = dataAccess.GetProductName(productID);
24 }
25 }
26
27 public int Quantity
28 {
29 get { return quantity; }
30 set { quantity = value; }
31 }
32
33 public string ProductName { get { return productName; } }
34
35 public decimal GetPrice()
36 {
37 return unitPrice * quantity;
38 }
39
40 private void Initialize(int productID, int quantity, IShoppingDataAccess dataAccess)
41 {
42 this.dataAccess = dataAccess;
43 ProductID = productID;
44 Quantity = quantity;
45 }
46 }
记得以前看<<Test-Driven Development in Microsoft .NET>>这本书的时候对开篇的DAL测试很不感冒.那本书就丢之一旁. 如果用传统的方法来测试DAL, 你会发现不仅麻烦而且丑陋.
细心的读者可能注意到了这里测试的方法名是BasketStub 而不是BasketMock. 可能你以前也听说过Martin Fowler的”Mock Aren’t Stub”. 那么什么是Stub 什么是Mock呢? 让我们从面向对象一个最重要的原则说起.
Don’t Ask, Tell
先举一个计算工资的老例子. 不同的员工有各种不同的工资计算方法, 这种情况下如何为全公司的员工计算呢? 想想现实中的情况, 在公司可能有一个会计部,每到发薪的日子. 会计部就会把全公司的员工资料集中到一起, 看看张三是什么类型的员工, 如果是小时工就按小时工来计算,如果是办公室主任就按主任的方法来计算. 基于这样的考虑, 我们在建模的时候也会相应的建立一个类似于会计部的类, 其中定义了一个计算工资的方法, 在方法中每计算一个员工前先会询问一个这个员工的类型, 如果是A类型怎么算, 如果是B类型怎么算, 可以想象这个计算工资的方法将十分庞大,并充满了if else(switch case)的代码.将来如果多了一个员工类型,还要来修改这个方法.
Ask员工类型, 根据类型选择相应的处理逻辑, 这就是以上的方案,可以看出这不是一个好的解决办法. 好的方法是什么呢? 既然我们要根据员工的类型来判断采用何种计算方法, 而员工显然知道自己是何种类型以及自己的工作量, 那么为什么不交给员工自己来算呢?(具体实现的时候就会涉及到多态等等方法, 这不是本文讨论的重点,在此不做详细介绍)此时会计部干什么呢? 计算工资这个活动总要有人发起, 所以会计部现在的工作就是Tell所有的员工, 让大家计算工资,然后把结果汇总.
现在你可以看到Don’t Ask, Tell的影子了吧.
Ask 带来的坏处:
1. 破坏对象的封装性, 你不得不暴露很多的属性供别人Ask.
2. 容易使得一些对象过于复杂(会计部), 而一些对象(员工)又过于简单, 甚至成为了仅仅包含数据的”哑”对象.
Tell 带来的好处:
1. 更多的考虑责任分配的合理性, 方法涉及的数据在哪里方法就应该在哪里. 这样对象的内聚性就大大加强了.
2. 增强了对象的封装性, 对象不必暴露更多的属性.
3. 可以充分发挥面向对象的特性,比如多态, 减少”哑”对象从而获得更强的可维护性,扩展性以及面对变化的能力.
上面那个计算工资的例子可以说是被用滥了, 再来看看在其他场合如何运用Don’t Ask, Tell的思想. 集合的遍历操作, 在日常的编程中屡见不鲜. 你是否考虑过它也在一定程度上破坏了Don’t Ask, Tell原则呢? We ask for every element in Collection, then operator on it. Why not just tell the collection to do something. 如果采用ask的办法, 在我们的程序中将不断的出现遍历集合的操作. (重复代码,Bad Smell) 所以我们应该将尽量将集合遍历的操作放在集合内即Refactory away External Loops. 如果你留意了.Net2.0对集合类的最新支持ForEach,你就会发现MS也考虑到了这点.
1 class Program
2 {
3 static void Main(string[] args)
4 {
5 List<string> strs = new List<string>();
6 strs.Add("hello");
7 strs.Add("world");
8
9 strs.ForEach( Console.WriteLine);
10 }
11 }
不过如我在.Net2.0的集合操作 --- What i hope? 一文中提到, 似乎考虑的还不是非常完善.
当然事情没有绝对, 有人会问究竟Tell到什么程度呢? 是不是Money还要负责自己的兑换操作? 这就看你把汇率放哪了. 如果汇率也在Money中, 那Money可以提供兑换的功能, 但是可以看出这不是一个好的设计. 汇率或许应该在Bank对象中, 那Money的兑换操作还是放在Bank中比较合适. 这里就涉及到责任分配的问题, 责任分配的好坏将决定对象的内聚性及耦合性. 其关键在于责任(方法)所涉及的主要状态(数据)到底在哪个对象当中.
Don’t Ask, Tell可以说是面向对象中一个非常重要的原则(Martin Fowler甚至将其称为面向对象最难理解的一个原则之一), 如果将其引申到TDD中又带来什么样的新观点呢?
State Based Test vs. Interaction Based Test
先回想一下以往使用NUnit的时候我们是如何测试. We ask for some state of an object, then we make some assertion on it. Why not just tell what we want to happen? 这里将要发生的事也就是我们在Expectation阶段定义好的那些方法和方法中的参数及返回值..
由此可见, xUnit和xMock, 不仅仅是两个工具这么简单, 它们分别代表了两种不同的测试思想, 是基于状态的测试还是基于行为的测试?
记得上面所说的Ask带来的坏处吗? 它也会影响到基于状态测试而开发出来的系统. 为了使用Assert测试对象, 你不得不暴露出很多的属性, 甚至于这些属性在正常的应用并不被使用, 但是为了测试,你不得不暴露它. 破坏了对象的封装性. 使用基于状态的测试由于较少的考虑对象的行为, 自然会导致其驱动开发出来的对象缺少行为,以至成为”哑”数据.
如果你遵循"Tell, Don't Ask"准则的话, 由于对象的封装性更好了, 将没有太多的属性暴露出来供你Assert. 在编程的时候, 我们应该更多的关注于程序是如何做的而不是关注于程序内部的状态变化.
在面向过程的方法中, 模块之间通过读写一个共享数据结构来进行交互. 这时我们可以通过对共享数据的状态变化进行测试来保证程序的正确性. 但是在面向对象的方法中, 功能是通过对象之间相互发送消息来实现, 因此这个相互发送消息的过程应该被测试. 因此在面向对象的测试中应该更多的基于行为来进行测试.
面向对象的程序中由于引入了对象的概念, 每个对象负责其自身所涉及的责任, 因此一个功能通常是由多个对象协作而完成, 此时如果采用基于状态的测试方法你将很难进行测试, 比如文章一开始提到的那个使用EmailService的例子. 当一个模块被分为多个对象的时候, 重点在于对象间如何交互而不是每个对象的内部状态. 因此对于那种需要多对象协作的功能使用基于行为的测试方法将更加合理,也更加方便进行测试. 并且在使用Mock对象的时候你会不自觉的使用接口而不是具体类(在Mock框架中通常是为接口创建Mock对象,而不是对具体类),从而降低对象之间的依赖程度. 这个过程也就是GOF所说的Design to interface.
当你使用Interaction Based Test的时候, 一旦你发现了错误,那么基本上就是被测试的对象出了问题。而在State Based Test中由于依赖对象采用的是实际的对象,产生错误的可能性非常大, 因此错误的来源可能是测试对象所依赖的其他对象而非测试对象本身。实际上State Based Test已经不仅仅是单元测试,它在某些场合已经有了集成测试的味道。而使用Interaction Based Test,则更接近于单元测试,这样有它的优点---隔离了错误的发生,更容易找到错误,但是却也失去了集成测试所带来的保障,很有可能你的单元测试全部通过了,但是实际上在其中隐藏了一些集成时会发生的Bug。但是作为程序员的测试, 个人还是偏向于单元测试.
Interaction Based Test还会给系统的设计带来好处,当你在为主对象编写测试代码的时候,你考虑到了它与其他(依赖)对象的交互, 这时你就会为其他对象设计好一些行为规范(Interface),当你完成主对象的测试时, 依赖对象的影子也出来了。
Mock Aren’t Stub
现在让我们回到先前那个数据库的例子. 那里的测试方法名是BasketStub, 显然使用的是Stub. 它们的区别是什么? 不如先来看看如果使用Mock那么测试方法又将是什么样.
1 [Test]
2 public void BasketMock()
3 {
4 using (MockRepository mocks = new MockRepository())
5 {
6 IShoppingDataAccess dataAccess = mocks.CreateMock(typeof(IShoppingDataAccess)) as IShoppingDataAccess;
7 Basket b = new Basket(dataAccess);
8
9 Expect.On(dataAccess).Call(dataAccess.GetUnitPrice(1)).Return(new decimal(99));
10 Expect.On(dataAccess).Call(dataAccess.GetProductName(1)).Return("The Moon");
11 Expect.On(dataAccess).Call(dataAccess.GetUnitPrice(5)).Return(new decimal(47));
12 Expect.On(dataAccess).Call(dataAccess.GetProductName(5)).Return("Love");
13
14 mocks.ReplayAll();
15
16 b.AddItem(new BasketItem(1, 2, dataAccess));
17 b.AddItem(new BasketItem(5, 1, dataAccess));
18 Assert.AreEqual(99*2+1*47, b.CalculateSubTotal());
19 }
20 }
再把上面那段Stub的测试代码放在这对比一下.
1 [Test]
2 public void BasketStub()
3 {
4 using (MockRepository mocks = new MockRepository())
5 {
6 IShoppingDataAccess dataAccess = mocks.CreateMock(typeof(IShoppingDataAccess)) as IShoppingDataAccess;
7 Basket b = new Basket(dataAccess);
8
9 SetupResult.On(dataAccess).Call(dataAccess.GetUnitPrice(1)).Return(new decimal(99));
10 SetupResult.On(dataAccess).Call(dataAccess.GetProductName(1)).Return("The Moon");
11 SetupResult.On(dataAccess).Call(dataAccess.GetUnitPrice(5)).Return(new decimal(47));
12 SetupResult.On(dataAccess).Call(dataAccess.GetProductName(5)).Return("Love");
13
14 mocks.ReplayAll();
15
16 b.AddItem(new BasketItem(1, 2, dataAccess));
17 b.AddItem(new BasketItem(5, 1, dataAccess));
18 Assert.AreEqual(99*2+1*47, b.CalculateSubTotal());
19 }
20 }
不同之处仅仅在于Line9-12. 它们的执行效果也完全一样, 不同之处在于如果你把Stub的L17-18注释掉, 测试依然通过, 而把Mock的L17-18注释掉, 测试失败. 为什么? 因为Mock注重的是对象的行为而不是状态. 你在L11-12已经声明将会调用到dataAccess.GetUnitPrice(5).但是由于你的注释, 使得该行为没有发生, 自然应该报错. 而在Stub中,你仅仅声明当调用到dataAccess.GetUnitPrice(5)返回10,并没有表示该行为一定要发生, 所以即使L17被注释掉, 测试依然通过..
由此看来Stub 和Mock虽然都是模拟对象, 甚至它们的创建方法都一摸一样, 但是它们的用法却不一样, 它们代表的测试模式也完全不同, Stub是模拟对象在State Base Test下的代表,而Mock则是模拟对象在Interaction Base Test下的代表.
通常会把那种不关心调用过程和参数,而仅仅返回”假数据”的模拟对象叫做Stub. 它被用于创建那些需要被模拟的代价昂贵的对象, 最常见的例子就是模拟数据库连接, 如果在测试中真正去连接一个数据并从中获得数据结果是很麻烦的一件事,而且在测试中不利于隔离错误的发生,因为数据库访问出错的原因太多了. 所以我们经常创建一个Stub对象输出假数据用于模拟从数据库获得我们期望的数据. 而Mock则更多的关注于Mock 对象的某个方法是否被调用(行为是否发生), 以及调用时的参数和返回值.
我们之所以需要Stub或者Mock是因为我们将要测试的对象依赖于其他的对象.如果是State Based Test, 我们只有在依赖对象很难使用真实对象的时候(比如我们还未实现依赖对象)才会使用模拟对象(Stub)来返回一些假数据供我们测试. 而Interaction Based Test却毫不忌讳对模拟对象(Mock)的使用, 即使我们已经实现了依赖对象依然会使用Mock. 因为Interaction Based Test关注的是对象行为的发生(方法的调用)以及发生时的参数和返回值,而这只有依赖于优秀的Mock框架我们才能方便的测试, 使用了真实对象反而不利于测试.
Other Words
不要去测试类中的每个方法. 要测试这个类对外所能提供的功能, 这些功能可能是其中的几个重要方法,可能需要类中的几个方法协作. 记住一点, 在TDD中测试代码也是文档的一部分, 你应该通过你的测试代码告诉别人如何使用这个类.
Resources:
MSDN Magazine October 2004 Unit Testing: Mock Objects to the Rescue! Test Your .NET Code with
NMock
AssertThat --- 测试的重用
注:本文编写时间比较早,现在的RhinoMock已经发生了一些大的变化,详细内容参考RhinoMock2 续
。不过主要问题出在示例代码上,本文所述之思想依然有效。