在 Spock 中使用 mocking 对流利的 API 进行单元测试

Unit Testing a fluent API with mocking in Spock

Spock 对 Stub 和 Mock 做了明确的区分。当要更改的内容从 class 返回时使用存根,您的 class 正在测试使用,以便您可以测试 if 语句的另一个分支。使用模拟,当你不关心你的 class 被测返回什么时,只需调用另一个 class 的另一个方法,你想确保你调用了那个。它非常整洁。但是,假设您有一个能使人流利 API 的建筑工人。您想测试调用此生成器的方法。

Person myMethod(int age) {
     ...
     // do stuff
     ...
     Person tony = 
            builder.withAge(age).withHair("brown").withName("tony").build();
     return tony; 
}

所以最初,我想只是模拟构建器,然后 myMethod() 的单元测试应该使用正确的参数检查 withAge()、withHair()。

很酷。

然而 -- 模拟方法 return 无效。这意味着你不能使用流利的 API.

你可以的。

Person myMethod(int age) {
     ...
     // do stuff
     ...

     builder.withAge(age);
     builder.withHair("brown");
     builder.withName("tony");
     builder.build();
     return tony; 
}

有效。您的测试会起作用,但它违背了使用流利 API.

的目的

那么,如果您使用的是流利的 APIs,您是存根还是模拟还是什么?

管理总结

如果您不需要像 1 * myMock.doSomething("foo") 那样验证交互,您可以使用 Stub 而不是 Mock,因为 mocks 总是 return nullfalse0,存根 return 更复杂的默认响应,例如空对象而不是 null 和 - 最重要的 - 存根本身用于具有与存根类型匹配的 return 类型的方法。即,使用存根测试流畅的 APIs 很容易。

但是,如果您还想验证交互,则不能使用 Stub,而必须改用 Mock。但是默认响应是 null,即您需要为流畅的 API 方法重写它。这在 Spock 1.x 和 2.x 中都很容易。特别是在2.x中,它有一些语法糖,使代码更小。

类 正在测试

快速而肮脏的实施,仅供说明:

package de.scrum_master.Whosebug.q57298557

import groovy.transform.ToString

@ToString(includePackage = false)
class Person {
  String name
  int age
  String hair
}
package de.scrum_master.Whosebug.q57298557

class PersonBuilder {
  Person person = new Person()

  PersonBuilder withAge(int age) {
    person.age = age
    this
  }

  PersonBuilder withName(String name) {
    person.name = name
    this
  }

  PersonBuilder withHair(String hair) {
    person.hair = hair
    this
  }

  Person build() {
    person
  }
}

测试代码

测试原始 class,没有模拟

package de.scrum_master.Whosebug.q57298557

import spock.lang.Specification

class PersonBuilderTest extends Specification {
  def "create person with real builder"() {
    given:
    def personBuilder = new PersonBuilder()

    when:
    def person = personBuilder
      .withHair("blonde")
      .withAge(22)
      .withName("Alice")
      .build()

    then:
    person.age == 22
    person.hair == "blonde"
    person.name == "Alice"
  }
}

没有交互测试的简单存根

这是一个简单的案例,适用于 Spock 1.x 和 2.x。将此特征方法添加到您的 Spock 规范中:

  def "create person with stub builder, no interactions"() {
    given:
    PersonBuilder personBuilder = Stub()
    personBuilder.build() >> new Person(name: "John Doe", age: 99, hair: "black")

    when:
    def person = personBuilder
      .withHair("blonde")
      .withAge(22)
      .withName("Alice")
      .build()

    then:
    person.age == 99
    person.hair == "black"
    person.name == "John Doe"
  }

模拟自定义默认响应

只需告诉 Spock 为您的模拟使用类似存根的默认响应即可:

import org.spockframework.mock.EmptyOrDummyResponse

// ...

  def "create person with mock builder, use interactions"() {
    given:
    PersonBuilder personBuilder = Mock(defaultResponse: EmptyOrDummyResponse.INSTANCE)

    when:
    def person = personBuilder
      .withHair("blonde")
      .withAge(22)
      .withName("Alice")
      .build()

    then:
    3 * personBuilder./with.*/(_)
    1 * personBuilder.build() >> new Person(name: "John Doe", age: 99, hair: "black")
    person.age == 99
    person.hair == "black"
    person.name == "John Doe"
  }

上面的语法适用于 bork Spock 1.x 和 2.x。从 2.0-M3 版本开始,Spock 用户可以使用句法糖语法 >> _ 将他们的 mocks/spies 告诉 return a stub-like default response,例如在最简单的情况下

Mock() {
  _ >> _
}

感谢 Spock 维护者 Leonard Brünings 分享这个巧妙的小技巧。

然后在 then:expect: 块中,您仍然可以定义其他交互和存根响应,覆盖默认值。在您的情况下,它可能看起来像这样:

import spock.lang.Requires
import org.spockframework.util.SpockReleaseInfo

//...

  @Requires({ SpockReleaseInfo.version.major >= 2})
  def "create person with mock builder, use interactions, Spock 2.x"() {
    given:
    PersonBuilder personBuilder = Mock()

    when:
    def person = personBuilder
      .withHair("blonde")
      .withAge(22)
      .withName("Alice")
      .build()

    then:
    3 * personBuilder./with.*/(_) >> _
    1 * personBuilder.build() >> new Person(name: "John Doe", age: 99, hair: "black")
    person.age == 99
    person.hair == "black"
    person.name == "John Doe"
  }

原回答

在我意识到 Spock 自己的 EmptyOrDummyResponse 之前,默认情况下由存根使用,实际上 return 是与 return 匹配的方法的模拟实例匹配 mocked/stubbed class 的类型,我认为它只是 return 一个空对象,就像其他 return 类型的方法一样,即空字符串、集合等。因此,我发明了我自己的 ThisResponse 类型。尽管这里没有必要,但我保留了旧的解决方案,因为它教会用户如何实现和使用自定义默认响应。

如果您想要构建器 classes 的通用解决方案,您可以使用 à la carte mocks,如 Spock 手册中所述。一点警告:手册在创建模拟时指定了自定义 IDefaultResponse 类型参数,但您需要指定该类型的实例。

这里我们有自定义 IDefaultResponse,它使模拟调用的默认响应不是 null、零或空对象,而是模拟实例本身。这是模拟具有流畅界面的构建器 classes 的理想选择。您只需要确保将 build() 方法存根到实际 return 要构建的对象,而不是模拟对象。例如,PersonBuilder.build() 不应该 return 默认的 PersonBuilder 模拟,而是 Person.

package de.scrum_master.Whosebug.q57298557

import org.spockframework.mock.IDefaultResponse
import org.spockframework.mock.IMockInvocation

class ThisResponse implements IDefaultResponse {
  public static final ThisResponse INSTANCE = new ThisResponse()

  private ThisResponse() {}

  @Override
  Object respond(IMockInvocation invocation) {
    invocation.mockObject.instance
  }
}

现在您可以在模拟中使用 ThisResponse,如下所示:

  def "create person with a la carte mock builder, use interactions"() {
    given:
    PersonBuilder personBuilder = Mock(defaultResponse: ThisResponse.INSTANCE) {
      3 * /with.*/(_)
      1 * build() >> new Person(name: "John Doe", age: 99, hair: "black")
    }

    when:
    def person = personBuilder
      .withHair("blonde")
      .withAge(22)
      .withName("Alice")
      .build()

    then:
    person.age == 99
    person.hair == "black"
    person.name == "John Doe"
  }