【翻译】Mock不是Stub

在测试中,“mock对象”常被用来描述模仿真实对象的特殊对象。现在,大部分语言环境提供了便于创建mock对象的框架。然而常被忽视的是,mock对象不只是一种用来测试的特殊对象,它还代表了一种不同的测试风格。在本文中,我将解释mock是如何工作、它们如何进行基于行为验证的测试,以及相关社区如何开发出一种不同的测试风格。

一年前,在极限编程(XP)社区里我第一次看到“mock”这个词。从此我越来越多的看到关于mock的东西。部分原因是,mock对象的许多主要开发者变成了我在ThoughtWorks的同事;另一个原因是,我在XP测试相关的文献里看到越来越多这方面的内容。

然而,我时常看到对mock的定义是很不充分的。尤其是我发现它经常与stub搞混——一种测试环境下常用的工具。我十分理解这种误解——有一段时间我也觉得它们很相似,但是与mock开发者交流之后,我对mock的理解逐渐明晰。

它们主要有两点不同。一是测试结果验证方式的不同:状态验证与行为验证的区别;二是测试方式与设计整个测试理念的不同,我把它看作测试驱动开发(TDD)中一般的和mock式的两种测试风格。

普通测试

首先,我用一个简单的例子说明这两种风格。(例子是用Java写的,但其中的原理对所有面向对象语言都适用。)我们想产生一个订单对象,然后通过一个仓库对象填写它。订单非常简单,只有产品和数量。仓库持有不同产品的清单。当我们想填写一个订单时,有两种可能的结果。如果仓库中有足够的产品,订单就正常填写,并且仓库中该产品的数量将相应减少。如果仓库中没有足够的产品,那么订单无法填写,仓库也没有任何改变。

这两种行为引出一组测试,这些代码就跟传统的JUnit测试一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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));
}

xUnit测试遵循典型的4个阶段:setup、exercise、verify、teardown。在这个例子中,setup阶段部分是在setUp方法里完成(设置仓库),部分在测试方法中(设置订单)。order.fill的调用是在exercise阶段,这就是对象被触发去做我们想要测试的东西的地方。接着断言语句就是验证阶段,检查被调用方法是否正确执行了它的任务。在这个例子中没有明确的teardown阶段,垃圾回收器会帮我们做完。

在setup中,我们把两种对象放在一起。Order是我们要测试的类,但是为了能够调用Order.fill,我们还需要Warehouse的实例。这种情况下,Order是我们专注测试的对象,测试人员倾向于使用object-under-test或system-under-test来命名这类东西。这两个词都很拗口,但它们都被广泛接受,那我就勉为其难用一下。为了追随Meszaros,我就用System Under Test,或者缩写SUT。

所以这个测试中我需要SUT(Order)和一个合作者(warehouse)。我需要warehouse有两个原因:一是为了让被测试行为能工作(因为Order.fill调用了warehouse的方法),二是要用它来验证(因为Order.fill的结果之一是潜在改变了warehouse的状态)。随着我们对这个话题的探索,你将看到我们发现在SUT和合作者之间的许多区别。(在这篇文章的早些版本中,我把SUT称作”主要对象“,把合作者称为”次要对象“)

这类测试使用了状态验证:我们通过在方法调用后,检查SUT和它的合作者的状态,来判断被测试方法是否正确的执行了。我们将看到,通过mock对象能够使用不同的验证方法。

使用mock测试

现在,我们将做相同的工作,并且使用mock对象。在代码中,我用的是jMock库来定义mock。jMock是一个java mock对象库。还有其他的mock对象库,但这是一个与时俱进的库,它由这项技术的发起人编写,所以还是不错的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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阶段就不太一样。它被分为两部分:数据和期望。数据部分建立了我们关注的对象,它与传统的setup相似。不同之处在于所创建的对象。SUT是一样的——一个订单。但合作者不是一个仓库对象,而是一个mock仓库——准确来说是一个Mock类的实例。

setup的第二部分创建了针对mock对象的期望。期望指出了当SUT被操作时,mock应该被调用的方法。

一旦所有的期望都就绪了,就操作SUT。操作之后进行验证,验证包含两个方面。针对SUT执行断言——跟之前一样。但我还验证了mock——检查它是否符合期望被调用了。

这里最关键的不同之处在于,我们是如何验证订单在与仓库交互中是行为正确的。在状态验证中我们通过断言仓库的状态。而mock使用行为验证,我们需要检查订单是否正确调用了仓库。我们通过在setup阶段设置mock的期望,并让mock在验证阶段验证自身。只有订单用断言来检查,并且如果被测试方法没有改变订单状态,那就不会有断言。

在第二个测试中我作了一些改变。首先我用不同的方式创建了mock,没有用构造方法,而用了MockObjectTestCasemock方法。它是jMock库中一个很方便的方法,这样我就不需要在之后显式调用验证方法,用这种方法构造的mock都会在测试的最后自动进行验证。在第一个测试中也可以这么用,但我想更清楚地展示用mock测试时验证的方法。

第二个测试中另一个不同点是我用withAnyArguments松化了期望的约束。因为第一个测试检查了传给仓库的数量参数,所以第二个测试不用重复这样做。如果之后订单的逻辑改变了,那么只有一个测试会不通过,这样便于测试代码的维护。由于withAnyArguments是缺省的方式,我也可以把它们都省略掉。

使用EasyMock

在众多mock对象库中,我用的比较多的是EasyMock,它有java和.NET版本。EasyMock同样支持行为验证,但和jMock有一些差别。下面是一些熟悉的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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使用类似录制/回放的方式来设置期望。对于每个想要进行mock的对象,分别创建一个control对象和mock对象。mock实现次要对象的接口,control则提供附加的功能。通过调用mock的方法,指明正确的参数,来说明期望。想要返回值的话,可以调用control的方法。当你设置完期望后,就可以调用control的回放——这时mock完成了录制,可以与主要对象交互了。都完成之后就可以调用control的验证方法。

似乎人们一开始常对录制/回放感到困扰,但事实上他们能很快习惯。与jMock的约束相比,它有一个优点在于,你可以调用真实的方法,而不是用字符串书写方法名。这样你就可以使用IDE的自动补全功能,而且修改方法名时也可以自动更新测试。缺点是你不能用宽松的约束。

jMock的开发者正在开发一个新版本,它将使用另一种技术来调用真实的方法。

Mock与Stub的不同

当它们第一次面世时,许多人将mock与使用stub的一般测试概念搞混。之后似乎人们开始理解了它们之间的不同(我希望是本文早些版本的功劳)。但是为了全面理解使用mock的方式,学习mock和其他测试doubles是很关键的。(”doubles“?不懂也不用担心,再看几段就明白了)

当你像这样进行测试时,某一时刻你只关注软件中的一个元素——就是常说的单元测试。问题是,为了完成一个单一的功能,常常还需要其他单元——在我们的例子中就需要仓库。

在上面两种我展示的测试类型中,第一个例子使用了真实的仓库对象,而第二个例子用了一个仓库的mock,它不是一个真实的仓库对象。在这个测试中,使用mock是避免用真实仓库的一种方式,也可以用其他形式的非真实对象。

要讨论的词汇有点混乱了——用到了各种词:stub、mock、fake、dummy。在本文中,我会依据Gerard Meszaros的书的词汇表。虽然不是很常用,但我觉得还不错,而且这是我的文章,我来选择用词。

Meszaros使用测试Double这个术语来描述测试中代替真实对象存在的所有虚假的对象。这个名字来源于电影中的特技替身演员的概念。(他的目的之一是避免使用其他被广泛使用的名字)。Meszaros定义了4类double:

  • Dummy对象用来传递但从不被使用,通常它们用来填补参数列表。
  • Fake对象是实现功能的,但通常它们有一些缺陷,不适合在最终产品中。(内存数据库是一个不错的例子)
  • Stub针对测试中的调用提供了固定的回复,通常不回应测试之外的调用。stub也可能记录调用的信息,例如email网关的stub会记录它所”发送“的消息,或者是它”发送“消息的数量。
  • Mock就是我们正在讨论的东西:针对对象预先指定期望,它规定了方法应当如何被调用。

在这些类型的double中,只有mock基于行为验证。其他double能够,且通常使用状态验证。mock在exercise阶段确实与其他double很相似,因为它们需要让SUT认为正在与真实的合作者交互——但mock在setup阶段和验证阶段是与其他double不同的。

为了进一步探究double,我们需要扩展我们的例子。许多人只有在真实对象很难用时才会用double。一个针对double更常用的例子是,如果订单填写失败,就需要发送一封email。问题在于在测试中我们并不想真的发送一封email给客户。所以我们创建一个email系统的double,我们可以更好的控制它。

在这里我们可以看到mock和stub的不同。如果我们在写一个针对邮寄的测试,我们可能像这样写一个简单的stub。

1
2
3
4
5
6
7
8
9
10
11
12
public interface MailService {
public void send (Message msg);
}
public class MailServiceStub implements MailService {
private List<Message> messages = new ArrayList<Message>();
public void send (Message msg) {
messages.add(msg);
}
public int numberSent() {
return messages.size();
}
}

然后我们可以这样对stub进行状态验证。

class OrderStateTester…

1
2
3
4
5
6
7
public void testOrderSendsMailIfUnfilled() {
Order order = new Order(TALISKER, 51);
MailServiceStub mailer = new MailServiceStub();
order.setMailer(mailer);
order.fill(warehouse);
assertEquals(1, mailer.numberSent());
}

当然这是一个非常简单的测试——只有一个消息被发送了。我们并没有测试它是否发送给了正确的人,或内容正确,但这可以说明要点了。

使用mock的话,这个测试就完全不一样了。

class OrderStateTester…

1
2
3
4
5
6
7
8
9
10
11
12
13
public void testOrderSendsMailIfUnfilled() {
Order order = new Order(TALISKER, 51);
Mock warehouse = mock(Warehouse.class);
Mock mailer = mock(MailService.class);
order.setMailer((MailService) mailer.proxy());
mailer.expects(once()).method("send");
warehouse.expects(once()).method("hasInventory")
.withAnyArguments()
.will(returnValue(false));
order.fill((Warehouse) warehouse.proxy());
}

在两个例子中,我都使用了double,而没用真实对象。一个不同是stub使用状态验证而mock使用了行为验证。

为了对stub使用状态验证,我需要给stub写额外的方法来完成验证。结果stub实现了MailSerivce,却也添加了额外的测试方法。

mock常使用行为验证,stub两种方法都可以。Meszaros将使用行为验证的stub称作测试间谍(Test Spy)。不同在于double如何运作和验证,我将把它留给你们自己来解释。


之前一直不能完全理解stub和mock的不同,后来发现了这篇据说是权威解释的文章。于是把它的前半部分翻译了一下,帮助加深理解。文章后半部分中,作者针对传统测试与mock式测试进行比较与思考。

原文地址:Mocks Aren’t Stubs