[转载] Mock 不是 Stub
原文出处: https://martinfowler.com/articles/mocksArentStubs.html
作者: Martin Fowler
原文发布日期: 2007-01-02
许可协议: 原文遵循其网站声明,本文为其中文翻译,所有权利归原文作者所有。
我最早接触到「mock object(模拟对象)」这个词,是几年前在极限编程(XP)圈子里。自那以后,我碰到 mock object 的次数越来越多。一部分原因,是很多搞 mock object 的核心开发者,都曾在不同时期和我同在 ThoughtWorks 共事;另一部分原因,是我在受 XP 影响的测试文献里见得越来越多。
但我常常看到 mock object 被描述得很糟糕,尤其是经常看到它和 stub 混为一谈——stub 是测试环境里常见的一种辅助手段。这种混淆我理解:有段时间我也觉得它们差不多,不过跟那些搞 mock 的开发者聊多了,这点关于 mock 的理解总算慢慢钻进了我这块榆木脑袋。
这里的差别,其实是两件不同的事捏在一起。一方面,是测试结果的验证方式不同:状态验证(state verification)和行为验证(behavior verification)之分。另一方面,是测试和设计如何配合的整套不同理念,我在这里称之为 TDD 的经典派(classical)和 mockist 派。
常规测试
我先用一个简单的例子,把这两种风格演示一遍。(例子用 Java 写,但原理适用于任何面向对象语言。)我们要做的,是拿一个 order(订单)对象,从 warehouse(仓库)对象里把它填满。order 很简单,只有一个产品和一个数量;warehouse 存着各种产品的库存。当我们让一个 order 从 warehouse 里补货时,有两种可能的结果:如果 warehouse 里的产品够填满这个 order,order 就变成已填充状态,warehouse 里该产品的数量相应减少;如果 warehouse 里产品不够,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(); // 协作者(collaborator):真实的仓库对象
protected void setUp() throws Exception { // setup 阶段:准备协作者的初始库存
warehouse.add(TALISKER, 50);
warehouse.add(HIGHLAND_PARK, 25);
}
public void testOrderIsFilledIfEnoughInWarehouse() {
Order order = new Order(TALISKER, 50); // setup 阶段:准备 SUT(被测对象)
order.fill(warehouse); // exercise 阶段:触发被测行为
assertTrue(order.isFilled()); // verify 阶段(状态验证):查 SUT 的状态
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 测试遵循典型的四阶段流程:setup(准备)、exercise(执行)、verify(验证)、teardown(清理)。这个例子里,setup 阶段一部分在 setUp 方法里完成(准备 warehouse),一部分在测试方法里完成(准备 order)。调用 order.fill 就是 exercise 阶段——这一步是去戳一下对象,让它做我们想测的那件事。assert 语句就是 verification 阶段,检查被执行的方法有没有正确完成它的任务。这里没有显式的 teardown 阶段,垃圾回收器替我们隐式做了。
setup 阶段,我们凑在一起的对象有两类。Order 是我们要测的类,但要让 Order.fill 跑起来,还需要一个 Warehouse 的实例。这种情况下,Order 就是我们聚焦要测的对象。搞测试的人喜欢用 object-under-test 或 system-under-test 这类词来称呼它。这两个词念起来都拗口得很,但既然是大家广泛接受的术语,我也就捏着鼻子用。跟着 Meszaros,我用 System Under Test,或者更常用的缩写 SUT。
所以这个测试里,我需要 SUT(Order)和一个协作者(collaborator,即 warehouse)。我需要 warehouse 有两个原因:一是为了让被测的行为能跑起来(因为 Order.fill 会调用 warehouse 的方法),二是为了做验证(因为 Order.fill 的结果之一,是可能改变 warehouse 的状态)。往下深入这个话题你会看到,我们会反复用到 SUT 和协作者之间的区分。(在这篇文章的早期版本里,我把 SUT 称作「主对象」,把协作者称作「次对象」。)
这种测试方式用的是状态验证:也就是通过查看方法执行之后 SUT 及其协作者的状态,来判断被执行的方法有没有正确工作。后面会看到,mock object 启用了一种截然不同的验证方式。
带 Mock Object 的测试
现在我把同样的行为,用 mock object 来实现。这段代码我用 jMock 这个库来定义 mock。jMock 是一个 Java 的 mock object 库,市面上还有别的 mock 库,但这个库由这套技术的发明者编写、也保持着更新,适合作为入门。
public class OrderInteractionTester extends MockObjectTestCase {
private static String TALISKER = "Talisker";
public void testFillingRemovesInventoryIfInStock() {
// setup - data:准备 SUT,以及 mock 掉的协作者(不再是真实仓库)
Order order = new Order(TALISKER, 50);
Mock warehouseMock = new Mock(Warehouse.class);
// setup - expectations:声明 SUT 被执行时,mock 上应发生哪些调用
warehouseMock.expects(once()).method("hasInventory") // 期望被调用恰好一次
.with(eq(TALISKER),eq(50)) // 参数为 (TALISKER, 50)
.will(returnValue(true)); // 并返回 true
warehouseMock.expects(once()).method("remove")
.with(eq(TALISKER), eq(50))
.after("hasInventory"); // 且在 hasInventory 之后
// exercise:触发被测行为(传入的是 mock 的代理,SUT 浑然不觉)
order.fill((Warehouse) warehouseMock.proxy());
// verify:行为验证——检查 mock 是否如期被调用;再对 SUT 做状态断言
warehouseMock.verify();
assertTrue(order.isFilled());
}
public void testFillingDoesNotRemoveIfNotEnoughInStock() {
Order order = new Order(TALISKER, 51);
Mock warehouse = mock(Warehouse.class); // 用便利方法创建,测试结束自动 verify
warehouse.expects(once()).method("hasInventory")
.withAnyArguments() // 放宽参数约束:本测试不关心数量
.will(returnValue(false));
order.fill((Warehouse) warehouse.proxy());
assertFalse(order.isFilled());
}
先看 testFillingRemovesInventoryIfInStock,后面那个测试我省了几步。
首先,setup 阶段就很不一样。它被拆成了两部分:data(数据)和 expectations(预期)。data 部分准备好我们要操作的对象,这一点跟传统的 setup 类似,区别在于创建出来的对象。SUT 还是一样——一个 order。但协作者不再是 warehouse 对象,而是一个 mock warehouse——说技术点,是 Mock 类的一个实例。
setup 的第二部分,是在 mock object 上创建 expectations。这些 expectations 指明了 SUT 被执行时,该有哪些方法被调用到 mock 上。
把所有 expectations 摆好之后,我就去执行 SUT。执行完之后做验证,验证有两方面:一是对 SUT 跑 assert——跟之前差不多;但我还要验证 mock——检查它们是不是按 expectations 被调用了。
这里的关键区别在于:我们怎么验证 order 在和 warehouse 的交互中做对了。用状态验证,我们是对 warehouse 的状态做 assert。mock 用的是行为验证,我们反过来检查 order 有没有对 warehouse 发起正确的调用。做这个检查的方式,是在 setup 时告诉 mock 该期待什么,在验证时让 mock 自行验证自己。只有 order 是用 assert 检查的;如果这个方法不改变 order 的状态,那就一个 assert 都没有。
第二个测试里,我做了几处不同的处理。一是创建 mock 的方式不同,用的是 MockObjectTestCase 里的 mock 方法,而不是构造器。这是 jMock 库里的一个便利方法,意味着我后面不用显式调 verify——用这个便利方法创建的任何 mock,都会在测试结束时自动验证。第一个测试我本来也能这么写,但我想把验证这步显式地展示出来,好让你看清用 mock 测试是怎么回事。
第二个测试里另一处不同,是我用 withAnyArguments 放宽了 expectation 上的约束。原因是第一个测试已经检查了数量会被传给 warehouse,第二个测试就不用重复这一项。这样一来,以后 order 的逻辑要改,也只有一个测试会挂,改测试的工作量就小了。其实我完全可以把 withAnyArguments 整个省掉,因为它本来就是默认值。
用 EasyMock
mock object 库有不少。我常碰到的一个是 EasyMock,Java 和 .NET 版本都有。EasyMock 也支持行为验证,但在风格上跟 jMock 有几处差别,值得聊聊。同样的测试,用 EasyMock 写是这样:
public class OrderEasyTester extends TestCase {
private static String TALISKER = "Talisker";
private MockControl warehouseControl; // control:驱动 mock 的录制/回放/验证
private Warehouse warehouseMock; // mock:满足 Warehouse 接口,供 SUT 调用
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(录制阶段):在 mock 上“预演”期望收到的调用
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 的对象,你都创建一个 control 和一个 mock object:mock 满足协作者的接口,control 则给你额外的功能。要指明一个 expectation,你就在 mock 上调用那个方法,并传入你期待的参数;如果需要返回值,就接着调一下 control。设完 expectations 后,你调 control 的 replay——到这一步,mock 就结束录制,准备好去响应 SUT 了。测试跑完,调 control 的 verify。
看起来,人们乍一接触 record/replay 这套隐喻常常会懵,但很快就习惯了。相比 jMock 的约束式写法,它有个好处:你对 mock 做的是真正的方法调用,而不是用字符串去指定方法名。这意味着你能在 IDE 里用代码补全,而且重构方法名时测试会自动跟着更新。代价是你没法用那种更宽松的约束。jMock 的开发者正在做一个新版本,会用别的技术让你也能做真正的方法调用。
Mock 和 Stub 的区别
mock object 刚问世时,很多人很容易把它和测试里常用的 stub 概念搞混。自那以后,大家似乎对两者的区别理解得更清楚了(希望这篇文章的早期版本也起了点作用)。但要真正理解人们怎么用 mock,搞清楚 mock 和其他种类的测试替身(test double)很重要。(「替身」?这词没听过别担心,往下读几段就清楚了。)
做这种测试时,你一次只聚焦软件的一个局部——所以叫单元测试(unit testing)。问题在于,要让单个单元跑起来,你往往需要别的单元——所以我们这个例子里才需要某种 warehouse。上面两种测试风格里,第一种用了真实的 warehouse 对象,第二种用了 mock warehouse,后者当然不是真实的 warehouse 对象。用 mock 是「在测试里不用真实 warehouse」的一种办法,但这类测试里还会用到别的形式的「非真实对象」。
谈论这些东西的词汇很快就乱了——什么 stub、mock、fake、dummy 一堆词都在用。这篇文章里,我采用 Gerard Meszaros 那本书里的词汇。不是所有人都用这套,但我觉得它挺好,而且既然这是我的文章,用什么词我说了算。
Meszaros 用 Test Double(测试替身) 作为通用术语,指任何为了测试而代替真实对象的「假对象」。这个名字来自电影里的替身演员(Stunt Double)。(他的目标之一,就是避开任何已经被广泛使用的名字。)然后 Meszaros 定义了五种具体的替身:
- Dummy 对象到处被传来传去,但从来不会被真正用到。通常只是拿来填参数列表。
- Fake 对象有真正能用的实现,但通常会走某种捷径,所以不适合用在生产环境(内存数据库就是个好例子)。
- Stubs 对测试中的调用给出预设好的现成(canned)回答,对预设之外的任何东西通常一律不理。
- Spies 是那种还会记录一些「自己被怎么调用过」信息的 stub。一种形式比如是:一个邮件服务,记录下自己被发了多少封信。
- Mocks 就是我们这里在谈的:预先编好 expectations 的对象,这些 expectations 构成了一份「它预期会收到哪些调用」的规格说明。
这些替身里,只有 mock 坚持用行为验证,其他替身可以用、通常也确实在用状态验证。在 exercise 阶段,mock 的行为其实跟其他替身一样,因为它得让 SUT 相信自己在跟真实的协作者对话——但 mock 的不同,在 setup 和 verification 阶段才体现出来。
要把测试替身再展开讲讲,我们得把例子扩一下。很多人只有在真实对象不好搞的时候,才用测试替身。更常见的用替身的场景是:我们说,如果订单填不上,就发一封邮件。问题在于,测试时我们不想真给客户发邮件。于是我们为邮件系统造一个测试替身,一个我们能控制和摆弄的。
到这里,我们就能开始看出 mock 和 stub 的区别了。如果给这个发邮件的行为写测试,我们可能会写一个像这样的简单 stub:
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); // stub:记录调用,但不真的发邮件
}
public int numberSent() { // 为状态验证额外加的测试专用方法
return messages.size();
}
}
然后我们就可以对这个 stub 做状态验证,像这样:
OrderStateTester 里:
public void testOrderSendsMailIfUnfilled() {
Order order = new Order(TALISKER, 51);
MailServiceStub mailer = new MailServiceStub(); // stub 替身,代替真实邮件服务
order.setMailer(mailer);
order.fill(warehouse);
assertEquals(1, mailer.numberSent()); // 状态验证:查 stub 记录下来的状态
}
当然这是个很简单的测试——只测了「有没有发消息」。我们没测它是不是发给了对的人、内容对不对,但用来演示要点够了。
用 mock 写,这个测试看起来会很不一样:
OrderInteractionTester 里:
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"); // 行为验证:期望 mailer.send 被调用一次(不关心参数/返回)
warehouse.expects(once()).method("hasInventory")
.withAnyArguments()
.will(returnValue(false));
order.fill((Warehouse) warehouse.proxy());
}
两种情况下,我都是用测试替身代替了真实的 mail service。区别在于:stub 用的是状态验证,而 mock 用的是行为验证。为了能在 stub 上做状态验证,我得在 stub 上加一些额外的方法来辅助验证。结果就是,这个 stub 实现了 MailService,但又加了些测试专用方法。mock object 永远用行为验证,而 stub 两种都行。Meszaros 把「用行为验证的 stub」称作 Test Spy。区别在于这个替身具体怎么运行和验证,这个就留给你自己去探索了。
经典派与 Mockist 派测试
现在我可以聊第二组对立了:经典派 TDD 和 mockist 派 TDD。这里的核心问题是:什么时候该用 mock(或其他替身)。
经典派 TDD 的风格是:能用真实对象就用真实对象,只有在真实对象不好搞时才用替身。所以一个经典派 TDD 者,会用真实的 warehouse,而给 mail service 用替身。具体用哪种替身,其实不那么重要。
而 mockist 派 TDD 的实践者,只要对象有「有意思的行为」,就一律用 mock。在这个例子里,warehouse 和 mail service 都会用。虽说各种 mock 框架是为 mockist 测试设计的,但很多经典派也觉得它们拿来造替身挺好用。
mockist 风格一个重要的分支,是行为驱动开发(BDD)。BDD 最早是我的同事 Daniel Terhorst-North 发展出来的,作为一种帮助人们更好学 TDD 的技术——重点是讲清 TDD 作为一种设计方法是怎么运作的。由此引出了「把测试改名叫行为」的做法,好更清楚地探讨 TDD 在「思考一个对象该做什么」上能帮上什么忙。BDD 采纳的是 mockist 路线,但在这之上又有扩展,无论是命名风格,还是它想把分析整合进这套技术里的意图。这点我不多展开,因为它跟本文的唯一关系就是:BDD 是 TDD 的另一个变种,倾向于用 mockist 测试,更多可参考 Dan North 的《Introducing BDD》。
你有时会看到人们用「Detroit 风格」指代「经典派」,用「London」指代「mockist 派」。这是因为 XP 最早是在底特律的 C3 项目里发展起来的,而 mockist 风格是伦敦那批早期 XP 采用者搞出来的。我也得提一句,很多 mockist 派 TDD 者不喜欢这个说法,也不喜欢任何暗示经典派和 mockist 派「风格不同」的术语——他们不认为这两种风格之间有什么有意义的区分可言。
如何在这些差别之间做选择
这篇文章我讲了两对差别:状态验证还是行为验证/经典派还是 mockist 派 TDD。在它们之间做选择时,要考虑哪些因素?我先从状态验证还是行为验证说起。
首先要考虑的是上下文:我们面对的是一个「好搞」的协作(比如 order 和 warehouse),还是一个「别扭」的协作(比如 order 和 mail service)?
如果是好搞的协作,选择就很简单。我是经典派,就不用 mock、stub 或任何替身,用真实对象和状态验证;我是 mockist 派,就用 mock 和行为验证。完全不用纠结。
如果是别扭的协作,那对 mockist 派来说也没得选——就是用 mock 和行为验证。对经典派来说倒是有得选,但用哪种也不是什么大事。通常经典派会具体情况具体看,哪种最省事就用哪种。
所以你看,状态验证还是行为验证,基本上不是什么大决策。真正的问题,在经典派和 mockist 派 TDD 之间。而状态验证和行为验证的特点,确实会影响这场讨论,这也正是我接下来要花大部分篇幅的地方。不过在那之前,我先抛一个边缘情况:偶尔你确实会碰到一些东西,即使协作本身不别扭,也很难对它们做状态验证。一个绝佳的例子是缓存(cache)。缓存的全部意义就在于,你没法从它的状态看出是命中还是未命中——这种情况下,连最铁杆的经典派 TDD 者,也会明智地选择行为验证。我相信两个方向上都还有别的例外。
深入经典派/mockist 派这个选择时,要考虑的因素很多,我把它们大致分了几组。
驱动 TDD
mock object 出自 XP 圈子,而 XP 的一个核心特征就是强调 TDD——通过写测试来驱动,在迭代中演进系统设计。所以 mockist 派特别爱谈 mockist 测试对设计的影响,一点也不奇怪。他们尤其推崇一种叫「需求驱动开发(need-driven development)」的风格。这种风格下,你开发一个用户故事,是从给系统的外部写第一个测试开始的,把某个接口对象当作 SUT。通过琢磨协作者身上的 expectations,你探索 SUT 和它的邻居之间的交互——实际上就是在设计 SUT 的对外接口。
第一个测试跑起来之后,mock 上的 expectations 就为下一步提供了一份规格说明,也成为后续测试的起点。你把每个 expectation 变成对某个协作者的测试,然后重复这个过程,一次一个 SUT 地向系统内部推进。这种风格也叫 outside-in(由外而内),名字本身就很说明问题。它跟分层系统配合得很好:你先用 mock 掉的底层来写 UI,然后给下一层写测试,一层一层地逐步推进整个系统。这是一种很有条理、很受控的方法,很多人觉得它对引导 OO 和 TDD 的新手很有帮助。
经典派 TDD 提供不了这么明确的引导。你也可以做类似的逐层推进,用 stub 掉的方法代替 mock:每当需要从协作者那里拿点什么,你就直接把「让 SUT 跑起来所需的那个返回值」硬编码进去,等测试变绿了,再换成正经的实现。
但经典派 TDD 还能做别的事。一种常见风格是 middle-out(中间向外):你拿一个功能,先想清楚要让这个功能跑起来,领域层(domain)需要些什么;你让领域对象做你需要的事,等它们跑通了,再把 UI 叠加上去。这么做你也许从头到尾都不用 fake 任何东西。很多人喜欢这样,因为它把注意力先放在领域模型上,有助于防止领域逻辑漏进 UI。
我得强调,mockist 派和经典派都是一次一个故事地推进。有一种流派是一层一层地构建应用,一层没建完不开始下一层;而经典派和 mockist 派大多有敏捷背景,偏好细粒度的迭代,所以他们是按功能推进,而不是按层推进。
夹具(Fixture)的搭建
经典派 TDD,你不光要造 SUT,还得造 SUT 在这个测试里需要的所有协作者。虽然这个例子只有几个对象,真实的测试往往牵涉大量次要对象,这些对象通常每次跑测试都要创建、再销毁。
而 mockist 派的测试,只需要造 SUT 和它直接邻居的 mock。这能省掉一些搭建复杂 fixture 的繁琐工作(至少理论上如此——我也听说过相当复杂的 mock 搭建,但那可能是工具没用好)。
实践中,经典派测试者会尽可能复用复杂的 fixture。最简单的办法,是把 fixture 的搭建代码放进 xUnit 的 setup 方法。更复杂的 fixture 要被多个测试类用到,这时你就建专门的「生成 fixture 的类」。我通常管它们叫 Object Mother。在较大规模的经典派测试里,用 mother 是必须的,但 mother 本身也是需要维护的额外代码,而且对 mother 的任何改动都会在测试里引起不小的连锁反应。搭建 fixture 还可能有性能开销——不过我没听说做得规范的话这会成为严重问题:大多数 fixture 对象创建起来很便宜,不便宜的那些通常会被替换成替身。
结果就是,我听到两边都指责对方「太费事」:mockist 派说搭 fixture 太费劲,经典派说 fixture 是可以复用的,而你们每个测试都得建 mock。
测试隔离
在 mockist 派测试的系统里引入一个 bug,通常只会让「SUT 包含这个 bug」的那些测试失败。而经典派的做法,任何客户端对象的测试也可能失败——只要那个有 bug 的对象在别的对象测试里被当协作者用,就会引发失败。结果就是,一个被大量使用的对象一出问题,会在整个系统里引发一连串测试失败。
mockist 派测试者认为这是个主要问题:它会导致大量调试,才能找到错误的根源并修复。但经典派不觉得这是个问题源。通常看一眼哪些测试挂了,就能比较容易地认出罪魁祸首,开发者也能判断其他失败都是由这个根源问题派生出来的。再说,如果你经常测试(本该如此),你就知道这次破坏是你最后改的那下造成的,找出故障并不难。
这里可能很关键的一个因素,是测试的粒度。因为经典派测试会驱动多个真实对象,你常常会发现:一个测试成了一整簇对象(a cluster of objects)的主要测试,而不只是一个对象。如果这一簇跨了很多对象,要找到 bug 的真正来源就会难得多——这里发生的事是测试太粗粒度了。mockist 派的测试很可能不那么容易有这个问题,因为约定俗成是把主对象之外的所有对象都 mock 掉,这也就明确了协作者需要更细粒度的测试。话虽如此,使用过粗粒度的测试,并不一定是经典派测试这种技术本身的失败,而更可能是「没把经典派测试做对」。一条好的经验法则是:确保为每个类都单独写细粒度的测试。簇有时候是合理的,但应该限制在很少的对象——不超过半打。另外,如果你因为测试太粗粒度而陷入调试困境,你应该用测试驱动的方式去调试,边调边建更细粒度的测试。
本质上,经典派的 xUnit 测试不只是单元测试,还是小型的集成测试(mini-integration tests)。因此很多人喜欢这一点:客户端测试可能抓住「对象自身的主要测试漏掉的」错误,尤其是能探测类与类交互的地方。mockist 派测试丢掉了这个品质。此外你还有个风险:mockist 派测试上的 expectations 可能本身就是错的,导致单元测试跑绿了,却掩盖了内在的错误。
讲到这里我得强调:无论用哪种风格,你都必须配合更粗粒度、横跨整个系统的验收测试(acceptance tests)。我经常碰到一些项目很晚才开始用验收测试,事后都后悔了。
测试与实现耦合
写 mockist 派测试时,你测的是 SUT 的对外调用,确保它和它的供应方沟通无误。经典派测试只关心最终状态——不关心这个状态是怎么来的。所以 mockist 派测试和方法的实现耦合得更紧,改变对协作者的调用方式,通常会让 mockist 派测试挂掉。
这种耦合有几个让人担心的地方。最重要的是对 TDD 的影响:用 mockist 派测试,写测试时你就会去想这个行为的实现——mockist 派测试者恰恰把这看作一个优点;而经典派认为,重要的是只去想「从外部接口看会发生什么」,把一切关于实现的考虑都留到写完测试之后。与实现耦合还会妨碍重构,因为相比经典派测试,实现上的改动更容易让测试挂掉。
这一点可能被 mock 工具包的特性加剧。mock 工具常常要求非常具体的方法调用和参数匹配,哪怕这些跟当前这个测试根本无关。jMock 工具包的一个目标,就是在指定 expectations 时更灵活,允许在无关紧要的地方把 expectation 放宽——代价是用字符串,这可能让重构更棘手。
设计风格
这些测试风格让我最着迷的一点之一,是它们如何影响设计决策。跟两类测试者聊下来,我察觉到这两种风格所鼓励的设计之间有几处差异,但我肯定才摸到点皮毛。
我已经提过在处理分层上的一个差异:mockist 派测试支持 outside-in 路线,而偏好「领域模型优先」的开发者往往更爱经典派测试。
在更小的层面上,我注意到 mockist 派测试者倾向于少用「返回值的方法」,转而用「作用于某个收集对象」的方法。拿「从一组对象收集信息、拼出一个报告字符串」这个行为举例:一种常见做法是,报告方法去调各个对象上「返回字符串」的方法,然后把结果在一个临时变量里拼起来;而 mockist 派测试者更可能把一个 string buffer 传进各个对象,让它们把各自的字符串加到这个 buffer 里——把这个 string buffer 当作一个 收集参数(Collecting Parameter)。
mockist 派测试者也确实更多地在谈要避免「火车残骸(train wrecks)」——也就是 getThis().getThat().getTheOther() 这种长方法链。避免方法链,也就是遵循迪米特法则(Law of Demeter)。方法链是一种坏味道(smell),但反过来的问题——塞满转发方法的「中间人(Middle Man)」对象——同样是一种坏味道。(我一直觉得,要是它叫「迪米特建议(Suggestion of Demeter)」,我心里会舒坦得多。)
OO 设计里最难让人理解的东西之一,是「Tell Don’t Ask(告诉,别问)」原则:它鼓励你「让对象去做某件事」,而不是「把数据从对象里扒出来、在客户端代码里自己做」。mockist 派说,用 mockist 派测试有助于推广这个原则,避免如今太多代码里泛滥的「getter 满天飞(getter confetti)」。经典派则反驳:做到这点的办法多得是。
状态验证有一个公认的问题:它可能导致你专门为了支持验证而创建查询方法(query method)。纯粹为了测试而给对象的 API 加方法,这从来都不舒服;用行为验证就能避开这个问题。对此的反驳是:实践中这种改动通常都很小。
mockist 派偏爱角色接口(role interface),并声称用这种测试风格会催生更多角色接口——因为每一次协作都被单独 mock,也就更可能被抽象成一个角色接口。所以在上面那个「用 string buffer 生成报告」的例子里,mockist 派更可能造出一个在那个领域里说得通的特定角色,而这个角色也许恰好由 string buffer 来实现。
要记住,这种设计风格上的差异,是大多数 mockist 派的核心动机。TDD 起源于一种愿望:想得到强有力的、支持演进式设计(evolutionary design)的自动化回归测试(regression tests);一路走来,实践者们发现「先写测试」对设计过程有显著改善。mockist 派对「什么样的设计才是好设计」有很明确的想法,他们开发 mock 库,主要就是为了帮人们发展出这种设计风格。
那我到底该当经典派还是 mockist 派?
这个问题,我很难自信地给出答案。就我个人而言,我一直是个老派的经典派 TDD 者,到目前为止也看不出有什么理由要改。我没看到 mockist 派 TDD 有什么压倒性的好处,反而担心测试与实现耦合带来的后果。
这一点在我观察 mockist 派程序员时尤为触动我。我很喜欢(经典派里)写测试时你关注的是行为的结果,而不是它怎么做;而 mockist 派为了写 expectations,得不停地想 SUT 接下来要怎么实现。这让我感觉很不自然。
我还有个不利之处:除了玩具级的项目,我没认真试过 mockist 派 TDD。正如我从 TDD 本身学到的:不认真试一试,往往很难评判一种技术。我确实认识不少很优秀的开发者,他们用起 mockist 派那套乐在其中、立场也很笃定。所以,尽管我依然是个坚定的经典派,我宁愿尽可能公允地把两边的论据都摆出来,让你自己判断。
所以如果 mockist 派测试听起来对你有吸引力,我建议你试试。尤其当你正在 mockist 派 TDD 想改善的那些方面遇到麻烦时,更值得试。我看到主要有两个方面:一是测试失败时不能干脆地指出问题出在哪,导致你花大量时间调试(这个你也可以通过在更细粒度的簇上用经典派 TDD 来改善);二是如果你的对象行为太少,mockist 派测试可能促使团队造出行为更丰富的对象。
最后的思考
随着单元测试、xUnit 框架和 TDD 越来越受关注,越来越多的人碰上了 mock object。很多时候,人们对 mock object 框架学了一点,却没真正理解它背后那套 mockist/经典派的分野。无论你偏向哪一边,我觉得理解这种观点差异都是有用的。你不必非得当 mockist 派才能觉得 mock 框架好用,但理解那套指导了很多软件设计决策的思路,是有益的。
这篇文章的目的,过去是、现在也是:指出这些差异,并把它们之间的取舍摊开来。mockist 派的那套想法,比我这里能讲到的要多得多,尤其是它对设计风格的影响。我希望未来几年能看到更多这方面的文字,让我们对「先写测试」那迷人的后果,理解得更深。
译者补充:C 与 C++ 视角
Fowler 用 Java + jMock/EasyMock 做演示。这里给出同一个 order/warehouse 场景在 C 和 C++ 里的等价写法,都分成「状态验证」和「行为验证」两段对照,看看两种语言各自怎么做替身与 mock。
C 用 Unity,C++ 用 GoogleTest + GoogleMock。C 没有面向对象,协作者接口靠函数指针表模拟,行为验证用的 fake 得手写(生产里可用 CMock 自动生成这类 fake);C++ 有虚函数,GoogleMock 能自动生成 mock 桩。
/* ===== SUT:被测单元与协作者接口 ===== */
#include <stdbool.h>
#include <string.h>
/* 协作者接口:仓库(C 里用函数指针表模拟"可替换的接口")*/
typedef struct {
bool (*has_inventory)(const char *product, int qty);
void (*remove) (const char *product, int qty);
} Warehouse;
/* SUT:订单 */
typedef struct { const char *product; int quantity; bool filled; } Order;
void order_fill(Order *o, const Warehouse *wh) {
if (wh->has_inventory(o->product, o->quantity)) {
wh->remove(o->product, o->quantity);
o->filled = true;
}
}
/* ===== 测试:Unity 框架 ===== */
#include "unity.h"
void setUp(void) {}
void tearDown(void) {}
/* —— 真实仓库(状态验证用)—— */
typedef struct { const char *product; int qty; } Stock;
static Stock stock[8];
static int stock_n;
static bool real_has(const char *p, int q) {
for (int i = 0; i < stock_n; i++)
if (strcmp(stock[i].product, p) == 0) return stock[i].qty >= q;
return false;
}
static void real_remove(const char *p, int q) {
for (int i = 0; i < stock_n; i++)
if (strcmp(stock[i].product, p) == 0) stock[i].qty -= q;
}
/* —— fake 仓库(行为验证用,C 里手写的 "mock")—— */
static bool fake_return; /* 测试预设的返回值 */
static int remove_called; /* 记录 remove 被调次数 */
static bool fake_has(const char *p, int q) { (void)p;(void)q; return fake_return; }
static void fake_remove(const char *p, int q) { (void)p;(void)q; remove_called++; }
/* 状态验证:库存够 → 订单填满,且库存被扣减 */
void test_state_FilledWhenEnoughStock(void) {
stock_n = 1; stock[0] = (Stock){"Talisker", 50}; /* setup:真实协作者 */
Warehouse w = { real_has, real_remove };
Order order = {"Talisker", 50, false};
order_fill(&order, &w); /* exercise */
TEST_ASSERT_TRUE(order.filled); /* verify:SUT 状态 */
TEST_ASSERT_EQUAL(0, stock[0].qty); /* verify:协作者状态 */
}
/* 行为验证:库存不够 → 不该调用 remove */
void test_behavior_NoRemoveWhenOutOfStock(void) {
remove_called = 0; fake_return = false; /* 预设:仓库没货 */
Warehouse w = { fake_has, fake_remove };
Order order = {"Talisker", 51, false};
order_fill(&order, &w); /* exercise */
TEST_ASSERT_FALSE(order.filled);
TEST_ASSERT_EQUAL(0, remove_called); /* 行为验证:remove 未被调用 */
}
int main(void) {
UNITY_BEGIN();
RUN_TEST(test_state_FilledWhenEnoughStock);
RUN_TEST(test_behavior_NoRemoveWhenOutOfStock);
return UNITY_END();
}// ===== SUT:被测单元与协作者接口 =====
#include <map>
#include <string>
class Warehouse { // 抽象接口,测试时可替换
public:
virtual ~Warehouse() = default;
virtual bool hasInventory(const std::string& product, int qty) const = 0;
virtual void remove (const std::string& product, int qty) = 0;
};
class Order {
public:
Order(std::string product, int quantity)
: product_(std::move(product)), quantity_(quantity) {}
bool isFilled() const { return filled_; }
void fill(Warehouse& wh); // 依赖抽象基类,便于注入 mock
private:
std::string product_;
int quantity_;
bool filled_ = false;
};
void Order::fill(Warehouse& wh) {
if (wh.hasInventory(product_, quantity_)) {
wh.remove(product_, quantity_);
filled_ = true;
}
}
// ===== 测试:GoogleTest + GoogleMock =====
#include <gtest/gtest.h>
#include <gmock/gmock.h>
using ::testing::Return;
using ::testing::StrictMock;
/* 真实仓库(状态验证用):最简的内存实现 */
class InMemoryWarehouse : public Warehouse {
public:
bool hasInventory(const std::string& p, int q) const override {
return stock_.count(p) && stock_.at(p) >= q;
}
void remove(const std::string& p, int q) override { stock_[p] -= q; }
void add (const std::string& p, int q) { stock_[p] += q; }
int stock(const std::string& p) const { return stock_.count(p) ? stock_.at(p) : 0; }
private:
std::map<std::string,int> stock_;
};
/* mock 仓库(行为验证用):GoogleMock 自动生成桩 */
class MockWarehouse : public Warehouse {
public:
MOCK_METHOD(bool, hasInventory, (const std::string& product, int qty), (const, override));
MOCK_METHOD(void, remove, (const std::string& product, int qty), (override));
};
/* 状态验证:库存够 → 订单填满,且库存被扣减 */
TEST(OrderTest, State_FilledWhenEnoughStock) {
InMemoryWarehouse wh; wh.add("Talisker", 50); // setup:真实协作者
Order order("Talisker", 50);
order.fill(wh); // exercise
EXPECT_TRUE(order.isFilled()); // verify:SUT 状态
EXPECT_EQ(0, wh.stock("Talisker")); // verify:协作者状态
}
/* 行为验证:库存不够 → 不该调用 remove */
TEST(OrderTest, Behavior_NoRemoveWhenOutOfStock) {
StrictMock<MockWarehouse> wh; // 严格 mock:未预期的调用即失败
EXPECT_CALL(wh, hasInventory("Talisker", 51))
.WillOnce(Return(false)); // 只对 hasInventory 设了期望;
// 未设 remove 的期望 → 期望它不被调用
Order order("Talisker", 51);
order.fill(wh); // exercise
} // wh 析构时自动验证调用是否符合期望