Spock框架中的Stub,Mock和Spy之间的区别

Difference Between Stub, Mock, and Spy in the Spock Framework

1.概述

在本教程中,我们将讨论Spock框架中Mock,Stub和Spy之间的区别。 我们将说明与基于交互的测试有关的框架。

Spock是Java和Groovy的测试框架,可帮助自动化软件应用程序的手动测试过程。 它引入了自己的模拟,存根和间谍,并具有内置功能,用于通常需要其他库的测试。

首先,我们将说明何时应该使用存根。 然后,我们将进行模拟。 最后,我们将介绍最近引入的Spy。

2. Maven依赖

在开始之前,让我们添加我们的Maven依赖项:

1
2
3
4
5
6
7
8
9
10
11
12
<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-groovy-2.5版本。 间谍将在Spock Framework的下一个稳定版本中引入。 目前,Spy在版本1.3的第一个候选版本中可用。

要回顾一下Spock测试的基本结构,请查看有关使用Groovy和Spock进行测试的介绍性文章。

3.基于交互的测试

基于交互的测试是一种技术,可以帮助我们测试对象的行为,特别是对象之间的交互方式。 为此,我们可以使用称为模拟和存根的虚拟实现。

当然,我们当然可以非常容易地编写自己的模拟和存根实现。 当我们的生产代码数量增加时,就会出现问题。 手工编写和维护此代码变得困难。 这就是为什么我们使用模拟框架,该框架提供了简要描述预期交互的简洁方法。 Spock具有对模拟,存根和间谍的内置支持。

与大多数Java库一样,Spock使用JDK动态代理来模拟接口,并使用Byte Buddy或cglib代理来模拟类。 它在运行时创建模拟实现。

Java已经有许多不同且成熟的库用于模拟类和接口。 尽管可以在Spock中使用它们中的每一个,但是仍有一个主要的原因为什么我们应该使用Spock模拟,存根和间谍。 通过将所有这些功能引入Spock,我们可以利用Groovy的所有功能来使我们的测试更具可读性,易于编写,并且绝对更加有趣!

4.存根方法调用

有时,在单元测试中,我们需要提供类的虚拟行为。 这可能是外部服务的客户端,或者是提供对数据库访问权限的类。 此技术称为存根。

存根是我们测试代码中现有类依赖关系的可控替代。 这对于进行以某种方式响应的方法调用很有用。 当使用存根时,我们不在乎将多少次调用一个方法。 相反,我们只想说:用此数据调用时返回此值。

让我们转到具有业务逻辑的示例代码。

4.1。 被测代码

让我们创建一个名为Item的模型类:

1
2
3
4
5
6
public class Item {
    private final String id;
    private final String name;

    // standard constructor, getters, equals
}

我们需要重写equals(Object other)方法以使声明生效。 当我们使用双等号(==)时,Spock将在声明期间使用等号:

1
new Item('1', 'name') == new Item('1', 'name')

现在,让我们使用一种方法创建一个接口ItemProvider:

1
2
3
public interface ItemProvider {
    List<Item> getItems(List<String> itemIds);
}

我们还需要一个将要测试的类。 我们将在ItemService中添加一个ItemProvider作为依赖项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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方法中包含的逻辑。

4.2。 在被测代码中使用存根对象

让我们使用Stub的ItemProvider依赖项在setup()方法中初始化ItemService对象:

1
2
3
4
5
6
7
ItemProvider itemProvider
ItemService itemService

def setup() {
    itemProvider = Stub(ItemProvider)
    itemService = new ItemService(itemProvider)
}

现在,让我们让itemProvider在每次调用时使用特定参数返回一个项目列表:

1
2
itemProvider.getItems(['offer-id', 'offer-id-2']) >>
  [new Item('offer-id-2', 'Zname'), new Item('offer-id', 'Aname')]

我们使用>>操作数对方法进行存根。 当使用['offer-id','offer-id-2'] list调用时,getItems方法将始终返回两个项目的列表。 []是用于创建列表的Groovy快捷方式。

这是整个测试方法:

1
2
3
4
5
6
7
8
9
10
11
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.模拟类方法

现在,让我们讨论一下Spock中的模拟类或接口。

有时,我们想知道是否使用指定的参数调用了依赖对象的某些方法。 我们要关注对象的行为,并通过查看方法调用来探索它们之间的交互。模拟是对测试类中对象之间强制交互的描述。

我们将在下面描述的示例代码中测试交互。

5.1。 交互代码

举一个简单的例子,我们将项目保存在数据库中。 成功之后,我们希望在消息代理上发布有关系统中新项目的事件。

消息代理示例是RabbitMQ或Kafka,因此通常,我们仅描述我们的合同:

1
2
3
public interface EventPublisher {
    void publish(String addedOfferId);
}

我们的测试方法将非空项目保存在数据库中,然后发布事件。 在我们的示例中,将项目保存到数据库中是无关紧要的,因此,我们将只发表一条评论:

1
2
3
4
5
6
7
8
9
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。 验证与模拟对象的交互

现在,让我们测试代码中的交互。

首先,我们需要在setup()方法中模拟EventPublisher。 因此,基本上,我们创建了一个新的实例字段,并使用Mock(Class)函数对其进行了模拟:

1
2
3
4
5
6
7
8
9
10
11
class ItemServiceTest extends Specification {

    ItemProvider itemProvider
    ItemService itemService
    EventPublisher eventPublisher

    def setup() {
        itemProvider = Stub(ItemProvider)
        eventPublisher = Mock(EventPublisher)
        itemService = new ItemService(itemProvider, eventPublisher)
}

现在,我们可以编写测试方法了。 我们将传递3个字符串:",'a','b',并且我们希望eventPublisher将发布两个带有'a'和'b'字符串的事件:

1
2
3
4
5
6
7
8
9
10
11
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')
}

让我们在最后的最后一节中仔细研究一下我们的断言:

1
1 * eventPublisher.publish('a')

我们期望itemService将调用一个以'a'作为参数的eventPublisher.publish(String)。

在存根中,我们讨论了参数约束。 相同的规则适用于模拟。 我们可以验证是否使用任何非null和非空参数两次调用eventPublisher.publish(String):

1
2 * eventPublisher.publish({ it != null && !it.isEmpty() })

5.3。 模拟和存根相结合

在Spock中,Mock的行为可能与Stub相同。 因此,我们可以对模拟对象说,对于给定的方法调用,它应该返回给定的数据。

让我们使用Mock(Class)覆盖ItemProvider并创建一个新的ItemService:

1
2
3
4
5
6
7
8
9
10
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')]

我们可以重写给定部分中的存根:

1
1 * itemProvider.getItems(['item-id']) >> [new Item('item-id', 'name')]

因此,通常来说,这一行说:itemProvider.getItems将使用['item-'id']参数调用一次,并返回给定的数组。

我们已经知道,模拟的行为与存根相同。 有关参数约束,返回多个值和副作用的所有规则也适用于Mock。

6. Spock的间谍班

间谍提供了包装现有对象的功能。 这意味着我们可以侦听调用者和真实对象之间的对话,但保留原始对象的行为。 基本上,Spydelegates方法调用原始对象。

与Mockand Stub相比,我们无法创建Spyon接口。 它包装了一个实际的对象,因此,此外,我们将需要为构造函数传递参数。 否则,将调用该类型的默认构造函数。

6.1。 被测代码

让我们为EventPublisher创建一个简单的实现。 LoggingEventPublisher将在控制台中打印每个已添加项目的ID。 这是接口方法的实现:

1
2
3
4
@Override
public void publish(String addedOfferId) {
    System.out.println("I've published:" + addedOfferId);
}

6.2。 用间谍测试

通过使用Spy(Class)方法,我们创建了类似于模拟和存根的间谍。 LoggingEventPublisher没有其他任何类依赖关系,因此我们不必传递构造函数args:

1
eventPublisher = Spy(LoggingEventPublisher)

现在,让我们测试一下我们的间谍。 我们需要带有我们的监视对象的ItemService的新实例:

1
2
3
4
5
6
7
8
9
given:
eventPublisher = Spy(LoggingEventPublisher)
itemService = new ItemService(itemProvider, eventPublisher)

when:
itemService.saveItems(['item-id'])

then:
1 * eventPublisher.publish('item-id')

我们验证了eventPublisher.publish方法仅被调用一次。 此外,该方法调用已传递给实际对象,因此我们将在控制台中看到println的输出:

1
I've published: item-id

请注意,当我们在Spy方法上使用存根时,它将不会调用真实对象方法。 通常,我们应该避免使用间谍。 如果必须这样做,也许我们应该按照规范重新排列代码,

7.好的单元测试

让我们最后简要概述一下使用模拟对象如何改善我们的测试:

  • 我们创建确定性测试套件

  • 我们不会有任何副作用

  • 我们的单元测试将非常快

  • 我们可以专注于单个Java类中包含的逻辑

  • 我们的测试与环境无关

  • 8.结论

    在本文中,我们彻底描述了Groovy中的间谍,模拟和存根。 有关此主题的知识将使我们的测试更快,更可靠并且更易于阅读。

    我们所有示例的实现都可以在Github项目中找到。