1. 概述
在本教程中,我们将深入探讨 Spock 框架中 Stub、Mock 和 Spy 三者之间的区别。并通过示例展示 Spock 在交互式测试(Interaction-based Testing)方面的强大能力。
Spock 是一个用于 Java 和 Groovy 的测试框架,能够有效替代传统手工测试流程。它内置了对 Mock、Stub 和 Spy 的支持,无需额外依赖其他库即可实现复杂的测试逻辑。
首先我们会介绍 Stub 的使用场景,然后讲解 Mock 的作用,最后介绍 Spock 中新引入的 Spy 功能。
2. Maven 依赖
开始之前,先添加如下 Maven 依赖:
<dependency>
<groupId>org.spockframework</groupId>
<artifactId>spock-core</artifactId>
<version>1.3-RC1-groovy-2.5</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>2.4.7</version>
<scope>test</scope>
</dependency>
⚠️ 注意:我们使用的是 Spock 1.3 版本的候选版(RC1),因为 Spy 功能目前仅在此版本中可用。正式稳定版尚未发布。
如需回顾 Spock 测试的基本结构,可以参考我们的 Groovy 与 Spock 入门文章。
3. 基于交互的测试(Interaction-Based Testing)
基于交互的测试是一种验证对象行为的技术 —— 特别是它们之间如何进行交互。为此,我们可以使用模拟对象(mocks)和桩对象(stubs)来代替真实依赖。
当然,我们完全可以手动编写自己的 mock 和 stub 实现。但随着项目规模扩大,这种方式会变得难以维护。这就是为什么我们需要像 Spock 这样的 mocking 框架:它提供了简洁的方式快速定义预期交互。
✅ Spock 内置支持 Mock、Stub 和 Spy。
像大多数 Java 库一样,Spock 使用 JDK 动态代理 来模拟接口,使用 Byte Buddy 或 cglib 来模拟类。这些代理在运行时生成。
虽然 Java 社区已有多种成熟的 mocking 工具(如 Mockito、EasyMock),但我们仍推荐使用 Spock 自带的 mock/stub/spy 实现,原因在于:
✅ Spock 能充分利用 Groovy 的语言特性,使测试代码更可读、更易写、也更有趣!
4. 方法桩(Stubbing Method Calls)
有时在单元测试中,我们需要为某个类提供“假”的行为 —— 比如外部服务客户端或数据库访问类。这种技术称为 stubbing。
Stub 是被测代码中已有依赖的一个可控替代品。它用于让方法以特定方式响应调用。当我们使用 Stub 时,并不关心该方法被调用了多少次,只希望它在特定参数下返回我们预设的结果。
下面通过业务代码来演示。
4.1. 被测代码
创建一个简单的模型类 Item
:
public class Item {
private final String id;
private final String name;
// 标准构造函数、getter、equals 等
}
我们需要重写 equals(Object other)
方法以便在断言中使用:
new Item('1', 'name') == new Item('1', 'name')
再创建一个接口 ItemProvider
:
public interface ItemProvider {
List<Item> getItems(List<String> itemIds);
}
然后是一个被测试的服务类 ItemService
,它依赖于 ItemProvider
:
public class ItemService {
private final ItemProvider itemProvider;
public ItemService(ItemProvider itemProvider) {
this.itemProvider = itemProvider;
}
List<Item> getAllItemsSortedByName(List<String> itemIds) {
List<Item> items = itemProvider.getItems(itemIds);
return items.stream()
.sorted(Comparator.comparing(Item::getName))
.collect(Collectors.toList());
}
}
✅ 我们遵循“面向接口编程”原则,这样可以灵活替换不同实现(比如从文件读取、调用 HTTP 接口、访问数据库等)。
在这个例子中,我们只需测试 getAllItemsSortedByName()
方法的逻辑,因此需要 stub 掉 ItemProvider
的依赖。
4.2. 在测试中使用 Stub 对象
在 setup()
方法中初始化 ItemService
并注入 Stub:
ItemProvider itemProvider
ItemService itemService
def setup() {
itemProvider = Stub(ItemProvider)
itemService = new ItemService(itemProvider)
}
接着设置 Stub 行为,让它在特定参数下调用时返回固定结果:
itemProvider.getItems(['offer-id', 'offer-id-2']) >>
[new Item('offer-id-2', 'Zname'), new Item('offer-id', 'Aname')]
这里使用 >>
操作符来 stub 方法。当 getItems()
方法接收到指定参数时,就会返回预设的列表。
完整的测试方法如下:
def 'should return items sorted by name'() {
given:
def ids = ['offer-id', 'offer-id-2']
itemProvider.getItems(ids) >> [new Item('offer-id-2', 'Zname'), new Item('offer-id', 'Aname')]
when:
List<Item> items = itemService.getAllItemsSortedByName(ids)
then:
items.collect { it.name } == ['Aname', 'Zname']
}
除了上面的基础用法,还可以:
- 使用参数匹配约束
- 设置多个返回值序列
- 根据条件定义不同行为
- 链式调用方法响应
5. Mock 类的方法调用
现在我们聊聊 Mock。
有时候我们想确认某个依赖对象的方法是否被调用了、以及调用时传入了什么参数。这就是 Mock 的主要用途。
Mock 是一种描述对象间强制交互的方式。我们关注的是行为而不是状态。
我们继续沿用前面的例子。
5.1. 包含交互的代码
假设我们要保存一些商品信息到数据库,成功后向消息队列(如 RabbitMQ 或 Kafka)发送事件通知。
定义一个简单的事件发布接口:
public interface EventPublisher {
void publish(String addedOfferId);
}
然后在 ItemService
中添加相关逻辑:
void saveItems(List<String> itemIds) {
List<String> notEmptyOfferIds = itemIds.stream()
.filter(itemId -> !itemId.isEmpty())
.collect(Collectors.toList());
// save in database
notEmptyOfferIds.forEach(eventPublisher::publish);
}
5.2. 验证 Mock 对象的交互
在测试中,我们使用 Mock(EventPublisher)
创建一个 mock 实例:
class ItemServiceTest extends Specification {
ItemProvider itemProvider
ItemService itemService
EventPublisher eventPublisher
def setup() {
itemProvider = Stub(ItemProvider)
eventPublisher = Mock(EventPublisher)
itemService = new ItemService(itemProvider, eventPublisher)
}
}
接着编写测试方法,验证 publish()
是否被正确调用:
def 'should publish events about new non-empty saved offers'() {
given:
def offerIds = ['', 'a', 'b']
when:
itemService.saveItems(offerIds)
then:
1 * eventPublisher.publish('a')
1 * eventPublisher.publish('b')
}
上面的断言语句含义为:
✅ eventPublisher.publish('a')
被调用一次
✅ eventPublisher.publish('b')
被调用一次
也可以使用参数约束:
2 * eventPublisher.publish({ it != null && !it.isEmpty() })
表示:publish 方法被调用了两次,且每次传入的参数都不为空。
5.3. 组合使用 Mock 和 Stub
在 Spock 中,Mock 也可以像 Stub 一样返回数据。
比如我们可以这样组合使用:
given:
itemProvider = Mock(ItemProvider)
itemProvider.getItems(['item-id']) >> [new Item('item-id', 'name')]
itemService = new ItemService(itemProvider, eventPublisher)
when:
def items = itemService.getAllItemsSortedByName(['item-id'])
then:
items == [new Item('item-id', 'name')]
或者将 stub 和 mock 合并成一行:
1 * itemProvider.getItems(['item-id']) >> [new Item('item-id', 'name')]
这句的意思是:getItems()
方法会被调用一次,并返回指定列表。
✅ Mock 支持所有 Stub 的功能,包括参数匹配、多返回值、副作用处理等。
6. 使用 Spy 包装类
Spy 提供了一种包装已有对象的能力。它允许我们监听调用过程,同时保留原始对象的行为。
简单来说,Spy 会把方法调用委托给原始对象。
⚠️ 与 Mock 和 Stub 不同,Spy 不能用于接口,只能用于具体类。而且必须提供构造函数参数(除非有默认构造函数)。
6.1. 被测代码
实现一个简单的 EventPublisher
子类:
public class LoggingEventPublisher implements EventPublisher {
@Override
public void publish(String addedOfferId) {
System.out.println("I've published: " + addedOfferId);
}
}
6.2. 使用 Spy 进行测试
创建 Spy 的方式和 Mock/Stub 类似:
eventPublisher = Spy(LoggingEventPublisher)
然后编写测试:
given:
eventPublisher = Spy(LoggingEventPublisher)
itemService = new ItemService(itemProvider, eventPublisher)
when:
itemService.saveItems(['item-id'])
then:
1 * eventPublisher.publish('item-id')
✅ 测试会验证 publish()
方法确实被调用了一次,并且实际执行了原始方法(输出打印):
I've published: item-id
⚠️ 如果你对 Spy 的某个方法做了 stub,那么它就不会调用原始方法。
❌ 建议尽量避免使用 Spy。如果真的需要用到,说明你的设计可能存在问题,应该考虑重构。
7. 编写高质量的单元测试
合理使用 Mock、Stub 和 Spy 可以显著提升测试质量:
✅ 可控性更强:构建确定性的测试套件
✅ 无副作用:避免外部系统干扰
✅ 快速执行:无需连接数据库、网络等
✅ 关注点分离:聚焦单个类的逻辑
✅ 环境无关:不依赖具体部署环境
8. 总结
本文详细介绍了 Spock 框架中的 Stub、Mock 和 Spy 的区别及使用方式。掌握这些技巧有助于写出更高效、更可靠的测试代码。
所有示例代码可在 GitHub 项目 中找到。