对于单元测试的一些新认识
程序员文章站
2024-01-31 18:30:58
...
我也忘了怎么机缘巧合的看到了Martin Fowler先生的这篇大作:Mocks Aren't Stubs。确实写的深刻,使我对单元测试的目的和方式又有了新的认识。
在这之前我就一直有个困惑,Unit Test究竟是黑盒测试还是白盒测试?其实这个问题换个说法也就是,到底是验证状态还是验证过程(behavior)?答案就在大神的这篇文章中。
我将按照这篇文章的叙述顺序,逐一总结文章中的关键内容,并附带部分原文引用。,
首先,Martin引入对mock和stub区别的思考。他一开始也认为两者是一致的( I saw them as similar for a while too)。后来渐渐认识到,它们主要有两个方面的区别:
1. 测试结果如何被验证。是验证状态还是验证行为。(how test results are verified: a distinction between state verification and behavior verification)
2. TDD的方式不同。(a whole different philosophy to the way testing and design play together, which I term here as the classical and mockist styles of Test Driven Development.)
接下来,Martin通过对一个测试用例,使用传统和mock的方式分别写出具体的测试代码,来更直观的阐述两者的区别。具体的代码可以参考原文,这里我简单描述一下被测试的类。被测试的类为Order,它有一个方法fill(WareHouse wh),该方法从仓库对象中拣出需要数量的货品来满足订单。如果库存不足,订单的完成标记就为否,否则为是。
传统测试是创建一个真实的WareHouse对象(如果WareHouse对象很复杂,不适合直接创建,则可能新建一个专门用于测试的Stub类对象),通过分别验证订单对象的完成标记和仓库对象的库存数量来验证测试是否通过。这就是传统的对状态的验证。
第二个例子是通过mock方式来测试的。Matrin先用了jMock框架,然后用EasyMock又写了一遍。作者对比了两个框架的具体实现方式。jMock更类似于传统方式,先进行setup(与传统方式setup不同的是,其增加了对mock对象行为的expectation。从而在verify阶段减少了对mock对象状态的验证),然后exercise,最后verify和tear down。但其不支持直接对mock对象方法进行调用,而是通过类似反射的调用方式。
EasyMock则支持进行真实的方法调用。这对于编译期检查和将来的自动化重构都有极大的好处。据说jMock也将支持真实方法调用。EasyMock引入了一个新的概念,叫“Record/Replay”。如果你看一下EasyMock测试的例子你就知道为什么它要引入这么一个模式。因为其无法区分你是在对行为进行expectation,还是真实的在exercise。它必须通过显示的调用replay,表示以后的操作将是exercise,而不是setup。这点和同样支持真实方法调用的Mockito不同。Mockito就不需要“Record/Replay”。因为其api可以显示的区分setup和exercice(不清楚的同学可以随便看两个例子就明白了)。因此我是比较喜欢Mockito的。因为其既支持真实方法调用,又不用显示的调用replay来区分record阶段和replay阶段。使得测试代码风格更接近传统的风格(setup,exercise,verify,teardown)。
接下来,作者总结了mock和stub的区别。并且在这里引入了一个新的名词Double,作为所有对模拟依赖对象手段的总称(mock.stub都是Double的一种方式)。我理解作者的意思就是模拟依赖对象相当于真实依赖对象的双胞胎,长得一样(接口一致),但实现不一样。实现Double可以通过mock,或者stub。
作者提出了Double的4种分类方式:Dummy, Fake, Stubs, Mocks。其实我也没太搞明白Dummy和Fake的分类依据。作者认为Stubs是专门为测试代码而写的实现,Stubs有可能会记录调用信息。而Mock是通过预先编写调用期望来实现的,并且通过验证期望行为而不是状态来决定测试是否通过。随后作者通过使用Mocks和Stubs方式来实现同一个测试用例,来解释两者的不同。
接下来作者简单总结了一下传统TDD和Mock方式TDD的区别。作者说,传统方式的TDD会尽量采用真实对象。如果真实对象复杂到难以直接创建,那么就新建一个该对象的stub实现类,使用stub代替真实实现。不过最后仍然要通过验证stub对象的状态来确定测试是否通过。
作者认为Mock方式的TDD,会始终采用mock的方式隔离依赖对象。
下一节中(Choosing Between the Differences),作者更详细的从如何驱动TDD开发,测试基础设施创建等多个方面对Mock和Classic方式的测试进行比较。其中在Fixture Setup中,我觉得有一点总结的很好:使用Mock方式的人经常认为传统方式需要编写大量的Fixture代码,但使用传统方式的人认为这些基础设施可以重用,并且认为Mock方式必须为每一次基础设施创建mock对象(As a result I've heard both styles accuse the other of being too much work. Mockists say that creating the fixtures is a lot of effort, but classicists say that this is reused but you have to create mocks with every test)。我觉得这句话点出了两种测试风格的缺点。
在介绍测试隔离性时(Test Isolation),作者从Mock Style支持者的角度,说明了传统测试并不是真正意义上的单元测试,而是一种迷你版本的集成测试(我以前一直被这个问题困惑。使用多个真实对象怎么能叫单元测试呢?因为别的类实现会影响当前被测试的类)。因为传统单元测试可能包括多个真实对象。同时,作者又强调了无论你选择何种风格的单元测试,都应该配以粗粒度的验收测试(acceptance tests)。
接下来作者比较了两种风格对真实实现的耦合程度,显然mock方式耦合度更高。这也是我写mock形式的单元测试中遇到的最头疼的问题。如果你改变了调用的接口,甚至调用的顺序,都可能导致mock验证失败!而传统方式只验证状态。相当于起点和终点是确定的,但是其路径确有无数条。mock是在验证路径,而传统方式只验证是否到达终点。另外,mock风格的测试在编写的时候,还要被具体实现牵扯精力。比如你要想依赖的mock对象的接口是什么样的。
作者在So should I be a classicist or a mockist这节中表明自己是一个传统的单元测试者,并没有深入的使用过mock方式的TDD。因此他将测试风格的选择权交给了读者。但他仍然对mock方式中,测试与被测代码耦合过高表示担心。
最后总结下自己的收获:
- 了解到传统方式的单元测试可能并不是真正意义上的单元测试(如果依赖对象使用其真实实现的话)。对于依赖对象,有可能采用真实实现。如果真实对象创建成本太高,则采用stub方式模拟。
- 我认为Mock方式的测试适用了层与层之间代码调用。比如Service调用DAO。对于业务层对象来说,可能传统方式的测试更合适。
上一篇: 使用fiddler联调本地服务接口的方法
下一篇: EasyMock让单元测试更"解耦"