1. 概述

在本教程中,我们将深入探讨 Spock 框架中 StubMockSpy 三者之间的区别。并通过示例展示 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 Buddycglib 来模拟类。这些代理在运行时生成。

虽然 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 项目 中找到。


原始标题:Difference Between Stub, Mock, and Spy in Spock Framework