Martin Fowler的一篇文章。
Key point: two differences; SUT
'Mock Objects'这个术语最近经常用来描述某些在测试中用来模拟真实Object的特殊对象。很多语言现在都有自己的框架使得mock object更加简单。然而,很多人经常不了解的是,mock object仅仅只是一种形式的特殊测试对象,一个带来了不同的测试风格的Object。 这篇文章中,我将解释:
mock objects是如何工作的
mock object是如何鼓励基于行为验证的测试的
以及如何用它们来进行一种不同形式的测试。
我第一次遇到"mock object"这个术语是在几年前的一次 Extreme Programming (XP) 社区中。自那之后,我越来越多地遇到mock objects. 一部分原因是很多主导mock object的开发人员是我在ThoughtWorks的同事;另一部分原因是 我越来越多地在XP-influenced测试讲座中见到它们。
然而,我很少见到有关mock object的描述。尤其是,我经常看到它们与stubs(一个常见的测试环境中的helper)的混淆。我理解这种混淆——我也曾经觉得它们很相似,但是与mock developers的交流让我对mock有了更多的一点了解。
事实上有两点区别。一方面,在如何对测试结果进行验证方面的不同:状态验证(state verification)和行为验证(behavior verification)的区别;另一方面,在测试方式和设计的整体哲学方面的区别,我把它们称为classical style和mockist style(Test Driven Development)。
Regular Tests
我会通过一个简单的例子来解释这两种风格。我们想获取一个order对象,并且从warehouse对象中获取。这个order很简单,有一个product以及quantity。warehouse 负责各种products的库存。当我们请求order对象从warehouse中获取product来填充自己时,有两种可能的回应。
- product充足,order得到满足,warehouse中相应product的数量减少
- 库存不足,order没有被满足,warehouse中不会发生任何事情。
这两种行为会有一系列的测试,JUnit测试的代码可能如下。
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));
}
这是一个典型的四阶段的测试序列:setup, exercise, verify, teardown。
在这个例子中,setup阶段一部分是在setUp方法(set up warehouse)中, 另一部分是在test方法(set up order)中;对order.fill的调用是在exercise执行阶段,这是object执行我们需要测试的地方;assert语句则是verfify的部分,检查exercised方法是否被正确执行;这个例子中没有显式的teardown阶段,垃圾收集器隐式地为我们执行了。
在setup的过程中,我们将两种Object放在了一起。Order是我们的测试对象,但是Order.fill需要一个Warehouse的instance。在这种情况下,Order是我们集中与测试的对象,面向测试(Testing-oriented)的人们喜欢用术语object-under-test或者system-under-test来描述它。我会使用System Under Test, 或者简写为SUT,尽管我个人觉得并不好听。
因此对这个test而言,我需要一个SUT(Order)和一个collaborator(warehouse)。我需要warehouse出于两个原因:一个是为了让测试行为能够工作(Order.fill会调用warehouse的方法),另一个原因是为了verification(因为Order.fill会导致warehouse的某个状态的改变)。当我们更深入讨论这个问题时,你会看到我们会对SUT和collaborator进行很多区分。
这种风格的测试使用的是状态验证:这意味着我们通过检查SUT和collaborator在方法执行之后的状态来确认执行的方法是否正确工作。我们会看到,mock object实现了另一种方式的验证。
Tests with Mock Objects
现在我会使用mock objects,并作出相同的操作。在这段代码中,我使用jMock library来定义mocks。jMock是一个Java mock Object的库。现在又很多mock object的库,但是jMock是一个由这项技术的发起人写的最新的库,所以是一个很好的用来作为起点的库。
public class OrderInteractionTester extends MockObjectTestCase {
private static String TALISKER = "Talisker";
public void testFillingRemovesInventoryIfInStock() {
//setup - data
Order order = new Order(TALISKER, 50);
Mock warehouseMock = new Mock(Warehouse.class);
//setup - expectations
warehouseMock.expects(once()).method("hasInventory")
.with(eq(TALISKER),eq(50))
.will(returnValue(true));
warehouseMock.expects(once()).method("remove")
.with(eq(TALISKER), eq(50))
.after("hasInventory");
//exercise
order.fill((Warehouse) warehouseMock.proxy());
//verify
warehouseMock.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());
}
首先集中于看testFillingRemovesInventoryIfInStock。setup的阶段与之前很不一样,它分为两个部分:data(数据)和expectations(期望)。data的部分set up我们感兴趣的用来work的object,和之前的类似;不同之处在于创建的对象。SUT(Order)和之前一样,但是collaborator不再是warehouse,而是一个mock的warehouse,也就是Mock类的一个instance(Mock warehouseMock = new Mock(Warehouse.class)).
第二部分是setup会在mock object上建一些expectations。这个expectations表明在SUT执行的时候,mock对象的哪些方法需要被调用。
当所有的expectations都就位之后,我执行了SUT。执行完成后,我会进行verification,这包括两方面。一方面,我对SUT执行了assert——和之前类似。但是,我同样verify了mock——验证它们是否根据expectations被调用。
关键的不同之处在于我们如何确认Order在于warehouse的协作中做了正确的事情。通过状态的验证,我们利用assert warehouse的状态来实现。Mocks使用了behavior verification,其中,我们check Order是否在warehouse上做了正确的调用——通过在setup过程中告诉mock什么是被期望的,并告诉mock在verification中自行验证。只有Order是需要通过assert来check的,如果方法没有改变Order的状态,则不需要任何assert。.
在第二个测试中我做了一些别的事情。首先,我创建mock的方式不一样了,直接使用mock()方法;这个方法的好处是,我不需要显式地去call verify了, 所有通过mock方法建立的mock对象都会在测试最后自动的verify。(我本可以在第一个测试中也这么做,但是我为了展示显式地verification)。
我在第二个测试用例中做的第二个不同的事情是我在expectations中使用了更为宽松的限制——withAnyArguments。这样就算逻辑修改了,该测试也不需要被修改。
使用EasyMock
如今有很多mock Object的library,有一个我遇到过的是EasyMock,包括Java和.NET版本。EasyMock也实现了行为验证,但是和jMock在风格上有一些值得讨论的不同。如下使我们已经熟悉的tests:
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 - data
Order order = new Order(TALISKER, 50);
//setup - expectations
warehouseMock.hasInventory(TALISKER, 50);
warehouseControl.setReturnValue(true);
warehouseMock.remove(TALISKER, 50);
warehouseControl.replay();
//exercise
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的比喻来实现expectations。对于每个你希望mock的Object,你需要创建一个control和一个mock object。mock对象满足collaborator的接口,control则给你提供额外的feature。为了实现一个expectation,你使用你所期望在mock上的参数调用方法。如果需要返回值,则需要调用control对象。
一旦你完成了expectations的设置,你需要显示地调用control.replay()——在该点上,mock的recording结束,并且可以对SUT作出响应。最后,再调用control.verify()。
人们第一眼似乎总是对record/replay的比喻充满畏惧。它与jMock相比有个好处,你是实实在在地在调用mock的方法,而不是以方法名作为参数。
JMock的developer也正在更新版本以满足调用真实方法的需求。
Difference between Mocks and Stubs
当第一次被引入时,人们总是很容易将mock object和常提及的stubs混淆。然而,为了完全了解如何使用mock,了解mocks和其他类型的test doubles是很重要的。
当你在做这样的测试:专注于软件中某个元素的测试——这是常用的术语unit testing。 问题在于,为了使得某个单独的unit工作,你经常需要其他的units——比如我们例子中的warehouse。
我上面描述的两种测试风格中,第一个使用了真实的warehouse对象,第二个则mock了warehouse(当然这不是一个真正的warehouse对象)。使用mock时不用真实的warehouse的一种方法,但是依然在测试中依然有很多类似的别的形式的非真实的objects。
我们接下来要讨论的词汇可能会比较混乱——stub, mock, fake, dummy。在这篇文章中,我将会遵循Gerard Meszaros书中的词汇。它可能不是所用人都使用的,但我觉得还不错。
Meszaros使用了一个术语——Test Double作为为了测试目的替代真实Object的所有类型的假的object。他定义了四种特定类型的double:
- Dummy对象是会被传递的,但从来不会被真正使用的。它们通常来说仅仅用于填充参数列表。
- Fake对象事实上会有一些working implementations, 但是通常在实现上走了一些捷径,从而不适用于production。(an in memory databaseis a good example).
- Stubs在测试中对所有的调用提供固定的答案,经常对于外界的调用不做任何回应。
- Spies是一种stub, 同时还会根据它是如何被调用的进行一些信息的记录。比如email service来记录它发送了多少消息。
-
Mocks也就是我们这里讨论的:被预先定义好的有expectations的对象,它定义了需要被接收的调用。
在以上的这些doubles中,只有mock是坚持在行为验证上的。其他doubles可以并经常用于状态验证。Mocks事实上在执行阶段和其他doubles的行为是一致的——它们需要让SUT相信自己适合真正的collaborators工作的,只是mocks在setup和verification阶段会有所不同。
Choosing Between the Differences
在这篇文章中,我解释了很多对的不同:state和behavior verification、classic和mockist TDD。 我们该如何在它们之间做选择呢? 我从state vs behavior verification开始。
第一个需要考虑的事情是上下文(context)。我们正在考虑的是一个简单的collaboration,比如Order与warehouse之间,还是一个棘手的,比如Order和mail service之间。
如果是一个简单的collaboration,那么选择很简单。如果我是一个classic TDDer,那么我不会用mock,stub或者别的double,我会使用真实的object和state verification。如果我是一个mockist TDDer,我会用Mock和behavior verification。不需要任何抉择。
如果是 一个棘手的collaboration,如果我是一个mockist,无需选择——Mock plus behavior verification。 如果我是一个classicist, 那么我需要作出选择,但是这个选择影响并不大。通常来说,classicists会根据case作出决定,选择最简单的方式。
因此我们可以看出,state vs behavior并不是什么大的决定。关键在于classic和mockist TDD之间的选择。但是state和behavior verification的特征会影响这个决定,这也是我的关注点。
在我对此作出讲解之前,首先让我抛出一个极端例子。有时候,你会发现很难使用state verification,及时它们不是awkward collaborations。 一个很好的例子是cache。cache本身的一个point就是你无法根据它的状态来判断它被命中还是miss了——这种情况下使用behavior verification就是一个更明智的选择。
在我们深入探讨classic/mockist的选择之前,我们有很多因素要考虑,我把它们分为了几组。
Driving TDD
Mock objects是从XP community中传出的,并且XP的一个重要原则就是对于Test Driven Development的强调——一个系统的设计是在由测试不断驱动的迭代中发展的。因此,对于mockists尤其爱讨论在设计时采用mockist 测试的影响也就不足为奇了。他们尤其提倡使用一种叫做need-driven development的风格。
在这种风格中,你会从通过撰写你系统的第一个测试开发一个 user story开始,实现你SUT的一些接口。通过思考collaborators的一些expectations,你探索SUT和它的协作者之间的交互——高效地设计出SUT的外围接口。
一旦你的第一个test开始run,mocks的expectations提供了下一步骤的规格(specification),并且是你tests的起点。你将这些expectations变成collaborator的test,并且逐步进入系统内,一次对一个SUT重复刚刚的步。这个风格又被称为outside-in,一个很好的描述。这种风格对分层系统很有用。你从对UI的下层进行mock实现编程开始,然后对低一层撰写test,渐渐地逐步每次实现系统的一层。这是一个很有结构并且很有控制性的方法,一个很多人相信对指导OO和TDD的初学者很有用的方法。
经典的TDD有所不同。你可以做类似的这种逐步方法,用stub代替mock。每当你需要collaborator做一些事情使SUT工作,你需要hard-code一些test需要的Response就可以。当green之后,你可以使用proper code代替hard code。
但是经典的TDD还做了一些别的事情。一种常用的风格叫做middle-out。在这种风格中,你选择一些feature,然后决定让这个feature工作你所需要的domain。你让这些domain Object做你需要的事情,当它们成功工作时,你将UI层置于上方。这样做的话,你不需要fake任何事。很多人喜欢这么做,因为它将关注点集中于domain model, 从而使得domain的逻辑与UI分离。
我需要强调的一点是,mockist和classicsts都是一次集中于一个story。有些学校认为需要一层一层地构建应用,某一层不完成则另一层也不会开始。Classicists和mockists都是有敏捷开发的背景,并且更倾向于细粒度的迭代。因此,它们习惯于feature by feature的工作而不是layer by layer.
Fixture Setup
当你使用经典的TDD时,你需要创建的除了SUT之外还包括所有SUT需要的在测试中需要响应的collaborators。尽管上述案例中只有一对objects,在真实的test中经常包括大量的collaborators。通常每run一次tests,这些对象就会被创建和销毁。
然而,Mokist test,仅仅需要创建SUT和mock它最直接的neighbor。这样就避免了很多构建复杂fixtures的工作(至少在理论上。我也遇到过很多很复杂的mock setup,但那可能是由于没有很好的使用工具)。
在实际中,classic testers更倾向于尽可能地服用复杂的fixtures。最简单的方法就是在xUnit的setup方法中对fixture进行setup。更多更复杂的fixture需要被很多测试类使用,在这种情况下你会创建特别的fixture generation classes。我经常基于ThoughtWorks XP项目中的命名习惯把它们称为Object Mothers。使用mothers对于更大的classic测试时必要的,但是它们是多余的需要被维护的code,任何对它们的改变都会对测试有很大的连锁反应。
因此我经常听到这两种风格之间相互指责,Mockists认为创建fixture需要很大的effort,然而classicists说这是可以被复用的,mock却需要在每个test中被创建。
网友评论