[翻译]Mocks Aren't Stubs
只是大致翻译,非全文正式翻译。
==============================
mock与stub代表的是不同的unit testing方法(分别是 interaction-based 和state-based)。
State-Based Testing
一个例子:
例子大意是把warehouse里的东西fill到每一个order里,有可能会出现warehouse的存货能满足/不能满足order所要求的商品数目这两种情况,两个test case也正是针对满足和不满足这两种情况。public class OrderStateTester extends TestCase {
private static String TALISKER = "talisker";
private static String HIGHLAND_PARK = "Highland Park";
private Warehouse warehouse = new WarehouseImpl();
protected void setUp() throws Exception {
warehouse.add(TALISKER, 50);
warehouse.add(HIGHLAND_PARK, 25);
}
public void testOrderIsFilledIfEnoughInWarehouse() {
Order order = new Order(TALISKER, 50);
order.fill(warehouse);
assertTrue(order.isFilled());
assertEquals(0, warehouse.getInventory(TALISKER));
}
public void testOrderDoesNotRemoveIfNotEnough() {
Order order = new Order(TALISKER, 51);
order.fill(warehouse);
assertFalse(order.isFilled());
assertEquals(50, warehouse.getInventory(TALISKER));
}
}
state-based tests采用固定的模式。先准备好测试所需要的所有对象(包括测试的主要对象和能让测试成功的辅助对象)。这个对象的集合一般被称为fixture。本例中在fixture里只有一个次要的对象(warehouse),但很有可能在其他fixture中存在大量次要对象。在JUnit中,一般利用
setUp方法来初始化这个fixture。
一旦建立了fixture,就可以开始测试主要对象(例子中的order)的行为,结束后用一些assert来测试是否一切正常(assert会针对主要和次要对象)。
(state-based中的主要/次要对象的区别很模糊,我把主要对象认定为你在测试中最关心的那个对象,而次要对象有它们自己的测试而且是假设它们能正常工作。次要对象要优于库对象,我们假设它们可以正常工作,但是对于库来写测试一般是不必要的。可能会有多个主要对象,但文中只考虑一个主要对象的情况)。
Interaction-Based Tests
用jMock。
public class OrderInteractionTester extends MockObjectTestCase {
private static String TALISKER = "Talisker";
public void testFillingRemovesInventoryIfInStock() {
//setup
Order order = new Order(TALISKER, 50);
Mock warehouse = new Mock(Warehouse.class);
//expectations
warehouse.expects(once()).method("hasInventory")
.with(eq(TALISKER),eq(50))
.will(returnValue(true));
warehouse.expects(once()).method("remove")
.with(eq(TALISKER), eq(50))
.after("hasInventory");
//execute
order.fill((Warehouse) warehouse.proxy());
//verify
warehouse.verify();
assertTrue(order.isFilled());
}
public void testFillingDoesNotRemoveIfNotEnoughInStock() {
Order order = new Order(TALISKER, 51);
Mock warehouse = mock(Warehouse.class);
warehouse.expects(once()).method("hasInventory")
.withAnyArguments()
.will(returnValue(false));
order.fill((Warehouse) warehouse.proxy());
assertFalse(order.isFilled());
}
}
Interaction-based测试有不同的模式。
和刚才从建立fixture开始不同,你之需要创建一个普通的主要对象的实例。你并没有建立次要对象的实例,而是建立了mock实例。当建立好mock以后,添加对mock的expectations,这些expectations说明了当主要对象被使用时,mock上会有哪些方法被调用。 Expectations是stubs和mocks的主要区别。
在设置要所有的expectations后,开始测试主要对象。接下来要干两件不同的事情:针对主要对象作assert,以及verrify了mock,来检查它们是否根据其expectations被调用。
在state-based测试中,我们通过检查测试后的状态来检查测试成功与否。在interaction-based测试中,我们检查是否正确的interactions被测试触发。如果主要对象的state没有被改变,就不需要来做asserts了,只需要verify那些mock就可以了。
在第二个测试(testFillingDoesNotRemoveIfNotEnoughInStock() )中,我做了两件不同的事情。首先以不同的方式创建了mock,在MockObjectTestCase中使用了mock方法,而不是在constructor中。这在jMock库中是一个convenience method,这意味着不需要显式来verify,因为任何利用convenience method创建的mock会在测试结束后被自动verify。 在第一个test(testFillingRemovesInventoryIfInStock() )中,其实也可以省略verify,在那里显式调用verify是为了更清楚一些。
第二件不同的事情就是我使用了withAnyArguments来放宽对于expectation的限制。因为第一个test检查了订货数量被传给了warehouse,所以第二个测试就不需要重复这一步骤了。如果order的逻辑需要在日后被改变,只有一个测试会失败,easing the effort of migrating the tests。
Using EasyMock
public class OrderEasyTester extends TestCase {
private static String TALISKER = "Talisker";
private MockControl warehouseControl;
private Warehouse warehouseMock;
public void setUp() {
warehouseControl = MockControl.createControl(Warehouse.class);
warehouseMock = (Warehouse) warehouseControl.getMock();
}
public void testFillingRemovesInventoryIfInStock() {
//setup
Order order = new Order(TALISKER, 50);
//expectations
warehouseMock.hasInventory(TALISKER, 50);
warehouseControl.setReturnValue(true);
warehouseMock.remove(TALISKER, 50);
warehouseControl.replay();
//execute
order.fill(warehouseMock);
//verify
warehouseControl.verify();
assertTrue(order.isFilled());
}
public void testFillingDoesNotRemoveIfNotEnoughInStock() {
Order order = new Order(TALISKER, 51);
warehouseMock.hasInventory(TALISKER, 51);
warehouseControl.setReturnValue(false);
warehouseControl.replay();
order.fill((Warehouse) warehouseMock);
assertFalse(order.isFilled());
warehouseControl.verify();
}
}
EasyMock使用了 record/replay的隐喻( metaphor)来设置expectations。对于每个你想要mock的对象,你建立一个control和mock对象。mock满足次要对象的接口,control可以提供额外的特性。为了说明一个 expectation,你调用了方法(带有期望的参数)。如果你需要一个返回值的话,接下来就调用control。一旦你结束了对 expectations的设置,你对control调用replay,那就是mock结束了记录而且准备好对主要对象做反应的时候。结束以后你对control调用verify。
人们最初一般会对record/replay隐喻有疑虑,但是很快会适应。它相比jMock的限制来说有优势,因为你可以实际调用方法,而不是利用string来制定方法的名字。这意味着你可以利用IDE提供的code-completion,而且任何对方法名的重构都会自动将test更新。缺点就是你不能拥有更松散的约束。
The Difference Between Mocks and Stubs
主要区别不是what they are,而是how they are used。
Stubs一般用来stub out那些难于创建或操纵的对象。一个经典的例子就是一个database connection。因此,一般的stub都被发现于系统边界,或者围绕着系统中复杂的对象群。为了建立一个stub,你建立了一个接口的另一种实现,利用简单的数据替换了真实的方法。
大多数团队一般编写custom stubs来stub任何需要的服务,毕竟数目并不多,而且它们经常可以在test cases之间复用。因此,你不需要构建一个通用的stub定义库。而利用mock库是可以很好地创建stub的。
因为其在测试中所担任的角色,大部分stub的实现就是对于带特定参数的方法调用返回已定义好的数据。在mock社区看来,最本质的区别就在于mock所具有的expectation setting机制,利用此机制可以测试mock上哪些方法被调用了。Mockists通常把一个刚刚返回值的mock object叫做'just a stub'。所以,一种观察mocks和stubs的区别的方法就是在使用mock的时候人们是把建立并且测试expectations当作测试的一部分的。似乎太简单了点 - 我也曾经编写过做一些简单形式expectation检查的stub(这些检查可能是设置一个布尔值来代表某个method是否被调用)。但是我想我们有理由认为expectations对于stubs来说是一种稀有的特性,而对于mocks来说是主要的特性。
最大的问题并不真的是mocks和stubs的区别,真正关心的是interaction和state style的对比。Interaction-based testers对于所有次要对象编写mocks。State-based testers仅仅对于那些不实际使用real object的object编写stubs (比如external services、开销很大的东西、cache等等)。
很有必要强调这一点,因为我也曾经多次感到迷惑。State based testers不避免使用stubs(或者mocks来实现stubs) - 他们只是在必须要的情况下才用。Interaction testers随时使用mocks,就算真实的次要对象用起来也一样容易。
Stylistic Consequences
每种style都有自己的优点和拥护者。我们要考虑很多因素。
Fixture Setup
如果使用state-based testing,你不得不创建所有测试中涉及的对象(可能是大量次级对象)。一般来说,这些对象在每次运行测试的时候被创建和摧毁。
Interaction-based tests只需要创建主要对象,对其它相关对象只要mock就可以。这可以避免某些在构建复杂fixtures时所作的工作。
实际上,state-driven testers喜欢尽可能的重用复杂的fixtures。最简单的方法就是把fixture setup code放到xUnit的setup method。更复杂的fixtures需要被多个test classes使用,因此在这种情况下你建立特别的fixture generated classes。我一般将其称为 'Object Mothers',基于一个早期ThoughtWorks XP项目用到的命名规约。在比较大的state based testing中使用“mother”是很必要的,但是mothers是需要维护的额外的代码,而且对mother的改变可能会对测试产生连锁反应。还可能在fixture创建时存在性能消耗(问题一般并不严重)。
两种style互相指责对方工作繁重,Interaction testers说建立fixtures很费事;但state testers说fixture是可以重用的,而mock是需要在每次测试中都被建立的。
Test Driven Development
Mock objects来源于XP社区,XP的主要特点之一就是它强调Test Driven Development。
所以,Mock Object的支持者谈论在设计阶段的interaction testing的作用,也就不令人惊讶了。 在这种style,你从编写第一个针对主要对象的测试来开始开发过程。通过考虑次要对象上的expectations,你研究了主要对象和其他对象的交互 - 有效设计主要对象的外部接口。一旦你让你的第一个测试运行起来了,mocks上的expectations提供了下一步骤的规范以及对测试的出发点。
特别的,这种途径鼓励了一种outside-in的途径来实现一个功能点。你首先利用底下的mock层来进行UI编程。接下来你编写底下一层的测试,逐步渗透进系统的所有层次。
State-based testing并未提供类似的向导。State-based testers 一般要考虑很多相关的对象。一个类似的渐进步骤可以被实施,我们可以先把次要对象用fake methods来实现,然后利用real methods来替代,然后可以进行测试了。在多层系统中,state-based testing似乎更普遍地和middle-out styles一起使用,开发者先实现domain logic然后再做UI。
Test Isolation
如果你在interaction testing时候引入了一个bug,这通常会仅导致那些主要对象包含了这个bug的测试失败。如果使用state-based方法,任何把有bug的对象当作次要对象使用的client objects的测试都会失败。因此,一个被广泛使用的对象的bug会波及到整个系统测试的失败。
Interaction testers认为这是state-based tests的主要问题,这对于寻找错误的真正源头比较麻烦。但是state-based testers并不认为这是一个问题。一般来说,出问题的地方可以根据测试失败的位置来做判断,开发者也可以分辨出其它失败是继承自根源错误。另外,如果你经常做测试,你就可以知道错误来源于你最近修改的部分,这样找错误也不难。
一个显著因素就是小间隔的测试。因为state-based tests操纵很多对象,你可以经常发现一个single state-based test作为一个对象群的主要测试,而不是针对一个对象。如果这个群涉及很多对象,那找出bug的根源就困难了 。What's happening here is that the tests are too coarse grained.
貌似interaction-based tests比较少遭遇此类问题,因为惯例就是mock out主要对象之外的所有对象,这就明确了次要对象需要更好的测试。也就是说,overly coarse grained tests的存在并不说明state-based testing是失败的,只是对于正确的进行state-based testing是一个失败。一个好的规则就是把把每个类的测试都分开。当对象群存在的时候,应该把它们限制在极少数的几个类(不超过6个),如果你发现了因为 overly coarse-grained tests引发的调试问题,你就应该用test driven的形式来debug,创建finer grained tests。
在基本的state-based tests中不只有单元测试,还有小的集成测试。因此很多人喜欢以下事实:client tests可能会catch到一个对象的主测试忽略的error,特别地可以深入类之间进行交互的区域。Interaction tests失去了这种性质。另外,你还要冒着interaction-based tests中的expectations可能是矛盾的这种风险,这会导致单元测试通过但掩盖了内部错误。
我要强调,不管你用哪种style来测试,你都必须把它结合上coarser grained acceptance tests that operate across the system as a whole。
Coupling Tests to Implementations
当你编写了一个interaction-based test,你就在测试主要对象的向外的调用,来确保它能和其suppliers合理交互。一个state-based test只考虑最终状态 - 而不是这个状态时如何推导出的。因此Interaction-based tests和一个方法的实现耦合比较紧。Changing the nature of calls to secondary objects将会导致一个interaction-based test失败。
这耦合会导致几个关注点。最重要的一个就是它对TDD的影响。当使用interaction-based testing时,编写测试让你考虑行为的实现 - 实际上interaction testers把它认为是优点。但是,State-based testers认为只考虑从外部接口的观点看发生了什么是重要的,在编写好测试后再考虑实现问题。
和测试的耦合还会影响到refactoring,因为实现上的变化更容易破坏测试(相对 state-based testing来说)。
This can be worsened by the nature of interaction-based toolkits。通常mock tools指定了很详细的method calls和参数匹配,就算它们和这个特定测试不相关。jMock toolkit的其中一个目标就是让它对于expectations的规格说明更有弹性一些,来让expectations在那些不要紧的领域松散一些。
Design Style
这些测试styles的最迷人的方面中其中一个就是它们如何影响设计决定。我意识到了一些styles鼓励的设计(初步认识)。
我已经提到了一个tackling layers的区别。Interaction-based testing supports an outside in approach starting with the presentation。喜欢domain model out style的开发者更喜欢state-based testing。
在一个更小的层次上我注意到interaction-based testers喜欢ease away from methods that return values,in favor of methods that act upon a collecting object。举个例子,从一组对象中收集信息来生成一个report string的行为。一个通常办法就是让reporting method在多个对象上调用string returning methods,然后在临时变量里生成结果string。一个interaction-based tester可能会把一个 string buffer传入多个对象来让它们在buffer中添加多个string - 把string buffer看作一个collecting parameter。
Interaction-based testers的确更多的讨论避免'train wrecks' - method chains of style of getThis().getThat().getTheOther()
。避免method chains即按照Law of Demeter。当method chains是smell的时候,the opposite problem of middle men objects bloated with forwarding methods也是smell。 (我一直觉得把Law of Demeter叫做Suggestion of Demeter比较好)
人们理解OO design的最难的一件事情就是"Tell Don't Ask" principle,它鼓励你告诉一个对象来做某事,而不是把数据从对象中掏出来而在client code中处理。Interaction testers认为利用interaction testing有助于此,而且避免 the getter confetti that pervades too much of code these days.
So which style is the best?
难以回答的问题。我个人总是一个 state-based tester,而且没觉得需要改变。我没发现 interaction based testing有什么特别的好处,而且我关注把测试和实现耦合起来的后果。
如果不把interaction based testing认真用在实际例子上(而不是仅仅[玩具]级别的例子),也是不妥的。我的确知道很多人在用interaction based testing - 他们是优秀的开发者,而且也对这个技术很满意。
所以,如果interaction-based testing对你有吸引力,那我建议你尝试一下,尤其是如果你在那些interaction-based testing特别加强的领域碰到问题的话。我主要发现了两个地方。一是,如果你当测试失败时在debugging上花费了很多时间,而原因是那些测试没有很清楚地告诉你问题在那里(你也可以利用state-based testing来改进这一点,只要注意 finer-grained clusters即可)。第二,如果你的对象没包含足够的行为), interaction-based testing可能会鼓励开发者添加更多的行为来丰富这些对象。
2006/4/11