Interaction Based Testing 源文档地址

入门介绍

交互驱动测试的理念发端在2000年左右的极限编程(Extreme Programming)社区,关注对象的行为而不是状态,也就是对象如何通过调用方法来和相关组件交互的。

举个例子,我们有一个 Publisher 对象:

  1. class Publisher {
  2. List<Subscriber> subscribers = []
  3. int messageCount = 0
  4. void send(String message){
  5. subscribers*.receive(message)
  6. messageCount++
  7. }
  8. }
  9. interface Subscriber {
  10. void receive(String message)
  11. }
  12. class PublisherSpec extends Specification {
  13. Publisher publisher = new Publisher()
  14. }

对于这个 Publisher 的模型,如果只是基于对象状态的测试,我们只能测试这个对象有几个 Subscriber。但问题是,我们如何确认 Pushlisher 已经把消息发给 Subscriber 了呢?要解决这个问题,我们就需要一个特殊的 Subscriber 类型的实现,来监听来自 Publisher的消息,这种方法就叫做 mock 对象。

如果我们真的去实例化一个对象,去维护它的代码,就增加了我们测试代码的复杂度。因此诞生了 mock 框架,它描述了被测试对象和其他组件的交互行为,而且能够 mock 这些相关组件的预期返回值。Spock 提供了一套自己的mock框架。

Mock 框架是通过JDK 动态代理, Byte Buddy 或者 CGLIB 在运行时生成代理实现的。

创建一个 Mock 对象

通过 MockingApi.Mock() 方法来创建 Mock 对象

  1. def subscriber = Mock(Subscriber)
  2. Subscriber subscriber2 = Mock()

Mock方式:

  1. 第一行,定义弱类型变量,那就需要传入类名作为参数
  2. 第二行,Java风格,根据等号左边声明的强类型变量,等号右边可以推断出实例的类型

一个Mock对象,其实是实现了接口,或者继承了类。

Mock 对象的默认返回值

在初始化完成以后,mock 对象是没有任何行为的。在这个时候调用 mock 对象的方法,会给出返回值类型的默认值,比如 false , 0, null

这种默认的行为是通过 stubbing 对象的方法实现,关于 stubbing 会在后文中介绍

Mock 对象的使用

注入 mock 对象

  1. class PublisherSpec extends Specification {
  2. Publisher publisher = new Publisher()
  3. Subscriber subscriber = Mock()
  4. Subscriber subscriber2 = Mock()
  5. def setup() {
  6. publisher.subscribers << subscriber // << is a Groovy shorthand for List.add()
  7. publisher.subscribers << subscriber2
  8. }

<< 相当于 List.add() 方法

注意这里只能add到new出来的对象中,而不能操作Mock对象的属性

Mocking 描述对象的交互

前文介绍了 Mock 对象,这一节介绍 Mocking 这个动作:Mocking 就是描述了对象的交互,说人话就是:对Mock 对象的某个方法指定了返回值。

举个例子:

  1. def "should send messages to all subscribers"() {
  2. when:
  3. publisher.send("hello")
  4. then:
  5. 1 * subscriber.receive("hello")
  6. 1 * subscriber2.receive("hello")
  7. }

上述的这个特征方法是可读性很强的:

当发布者发送了一条”Hello”,那么两个订阅者都能收到这条消息,且分别只收到一次

当特性方法开始运行后,在 when 代码块中执行指定的方法后,Mock 对象的调用情况都会去和 then 代码块中的描述作比对,如果有不匹配则会抛出异常

Interactions 对象的预期交互

Interaction 可以理解为预期的交互行为。其实就是描述预期的方法是否被调用,被调用几次。后文中笔者都会把 interaction 翻译为预期交互。

  1. 1 * subscriber.receive("hello")
  2. | | | |
  3. | | | argument constraint
  4. | | method constraint
  5. | target constraint
  6. cardinality

then 代码块中,对一次交互的描述包括四个部分:

  1. 方法被调用的次数
  2. 调用方法的对象
  3. 预期被调用的方法
  4. 方法的入参

Cardinality

Cardinality:方法调用的次数

  1. 1 * subscriber.receive("hello") // exactly one call 一次调用
  2. 0 * subscriber.receive("hello") // zero calls 零次调用
  3. (1..3) * subscriber.receive("hello") // between one and three calls (inclusive) 一到三次调用
  4. (1.._) * subscriber.receive("hello") // at least one call 至少一次调用
  5. (_..3) * subscriber.receive("hello") // at most three calls 最多三次调用
  6. _ * subscriber.receive("hello") // any number of calls, including zero 任意次数的调用,包括0
  7. // (rarely needed; see 'Strict Mocking')

Target Constraint

调用方法的Mock对象的描述:

  1. 1 * subscriber.receive("hello") // a call to 'subscriber'
  2. 1 * _.receive("hello") // a call to any mock object

Method Constraint

描述预期被调用的方法,可以用正则表达式

  1. 1 * subscriber.receive("hello") // a method named 'receive'
  2. 1 * subscriber./r.*e/("hello") // a method whose name matches the given regular expression
  3. // (here: method name starts with 'r' and ends in 'e')

如果预期调用一个 getter 方法,那么可以用调用属性来代替

  1. 1 * subscriber.status // 等同于 1 * subscriber.getStatus()

注意只能用于表示 getter 方法,而不能表示 setter 方法

Argument Constraints

描述预期被调用的方法的入参。可以控制入参的值,数量,类型,甚至可以传入一个 Predicate闭包

  1. 1 * subscriber.receive("hello") // an argument that is equal to the String "hello" 给定的单个入参
  2. 1 * subscriber.receive(!"hello") // an argument that is unequal to the String "hello" 除此之外的另一个入参
  3. 1 * subscriber.receive() // the empty argument list (would never match in our example) 没有参数的方法
  4. 1 * subscriber.receive(_) // any single argument (including null) 任意的单个入参
  5. 1 * subscriber.receive(*_) // any argument list (including the empty argument list) 任意数量的入参
  6. 1 * subscriber.receive(!null) // any non-null argument 单个非空入参
  7. 1 * subscriber.receive(_ as String) // any non-null argument that is-a String 单个 String类型的入参
  8. 1 * subscriber.receive(endsWith("lo")) // any non-null argument that is-a String 以“lo” 结尾的入参
  9. 1 * subscriber.receive({ it.size() > 3 && it.contains('a') })
  10. // an argument that satisfies the given predicate, meaning that
  11. // code argument constraints need to return true of false
  12. // depending on whether they match or not
  13. // (here: message length is greater than 3 and contains the character a)

也可以分别对多个参数进行限制:

  1. 1 * process.invoke("ls", "-a", _, !null, { ["abcdefghiklmnopqrstuwx1"].contains(it) })

指定了入参为

  1. “is”
  2. “-a”
  3. 任意参数
  4. 非null参数
  5. 在给定字符串list中的某个字符串

上述的入参匹配规则可以总结为以为的类型

  1. 判断相等 1 * subscriber.receive("hello")
  2. Hamcrest 匹配器
  3. WildCard 通配符 1 * subscriber.receive(*_)
  4. 代码匹配
  5. 否定匹配 1 * subscriber.receive(!null)
  6. 类型匹配 1 * subscriber.receive({ it.contains('foo')} as String)

代码匹配可以用来校验对象入参 with/verifyAll,注意这里的 verifyAll只在 Spock 的1.3版本中支持

  1. 1 * list.add({
  2. verifyAll(it, Person) {
  3. firstname == 'William'
  4. lastname == 'Kirk'
  5. age == 45
  6. }
  7. })

如何匹配任意方法

Mocking 中表示匹配任何方法

  1. 1 * subscriber._(*_) // any method on subscriber, with any argument list
  2. 1 * subscriber._ // shortcut for and preferred over the above
  3. 1 * _._ // any method call on any mock object
  4. 1 * _ // shortcut for and preferred over the above

Strict Mocking

那么什么时候会用到上述的匹配任意方法呢?答案是在 Strict Mocking 的测试风格中

什么是 Strict Mocking 风格呢?就是所有的方法调用都会显式的列在 then 代码块中,然后用匹配任意方法的 Mocking 去限制其他方法的调用

  1. when:
  2. publisher.publish("hello")
  3. then:
  4. 1 * subscriber.receive("hello") // demand one 'receive' call on 'subscriber'
  5. _ * auditing._ // allow any interaction with 'auditing'
  6. 0 * _ // don't allow any other interaction

上述代码的含义就是,当 publish 方法被调用时

  1. 允许 subscriber 对象的 receive 方法被调用一次
  2. 允许 audit 对象的任一方法调用。
  3. 除此之外的所有调用都

PS:当 mock 对象执行时,在比对预期行为的时候,是从上到下匹配的,所以,可以达到上述的效果。这在下一节中介绍。

声明预期交互的代码块

在上述的例子里面,所有的预期交互都是在 then 代码块中定义的,这样保持了 spock Specification 的可读性。不过其实,也可以在 when 代码块之前来定义预期交互,其实也就是在 setup() 方法中。

如果 Mock 对象的一个方法被调用了,那么就会按照对应的预期交互的定义先后顺序去匹配。如果某个方法被调用了多次,那么会去找第一个匹配上,且调用次数没满的预期交互定义。还有一个规则就是:在 then 代码快中定义的预期交互的优先级高于在 setup() 方法中定义的预期交互,也就是说可以在 then 代码快中 override 同一个方法的预期交互。

注意!只有和 * 或者 >> >>> 符号一起出现的调用才会被spock解析成对方法的预期交互的定义,否则就是对方法的一次普通调用。

在创建Mock对象时声明预期交互

在初始化 Mock 的时候定于预期交互

  1. Subscriber subscriber = Mock {
  2. 1 * receive("hello")
  3. 1 * receive("goodbye")
  4. }

可以理解为,定义了一个 subscriber 对象,这个对象预期执行一次 receive("hello") 和 一次 1 * receive("goodbye")。在定义预期对象的时候,不需要再指定调用对象了,因为上下文很清楚地体现了对象是刚 create 的 subscriber

针对同一个Mock的预期交互放在一起

我可以通过 with 语法将同一个对象的预期行为放在一起:

  1. with(subscriber) {
  2. 1 * receive("hello")
  3. 1 * receive("goodbye")
  4. }

显式的 interaction 代码块

Spock 在when代码块中,执行 Mock 对象的方法的时候,需要掌握所有的预期交互信息,才能判断这次调用是否合理。但是代码中,预期交互都定义在 then 代码块中。事实上,Spock 把 then 代码块中的预期交互的定义都转移到对应的 when 代码块之前了。在大多数情况下,这并没有什么问题,但是在下面这个场景下就会报错

先来看一段代码:

  1. when:
  2. publisher.send("hello")
  3. then:
  4. def message = "hello"
  5. 1 * subscriber.receive(message)

原因是 1 * subscriber.receive(message) 这句代码被转移了,但是 message 变量仍然定义在 then 代码块之中。Spock 没有机智到可以判断 def message = "hello" 这句变量申明是和下一句预期行为的定义绑定在一起的。所以我们就需要显式的把这两句代码放在 interaction 代码块中,Spock 就知道他们需要被一起转移了

  1. then:groovy
  2. interaction {
  3. def message = "hello"
  4. 1 * subscriber.receive(message)
  5. }

预期交互的作用域

Scope of Interactions 即预期交互的作用域

在 then 代码块中定义的预期交互的作用域就是,与这个then对应的那个 when 代码块(当前 then 之前最近的 when 代码块)

  1. when:
  2. publisher.send("message1")
  3. then:
  4. 1 * subscriber.receive("message1") // 这个interaction的作用域就是前面 message1 的那个when代码块
  5. when:
  6. publisher.send("message2")
  7. then:
  8. 1 * subscriber.receive("message2") // 这个interaction的作用域就是前面 message2 的那个when代码块

在 then 代码块之外定义的预期交互的作用域则是整个特征方法 feature method。

注意!!
预期交互的作用域最大只能是所在的特征方法,因此不能被声明在静态方法中,setupSpec method, or cleanupSpec method 中。类似的,mock 对象不能被放在静态属性或者 @Shared 属性中!

验证预期交互的正确性。

验证预期交互是否达到预期

对预期交互的验证失败有两种可能,对方法的调用次数

  1. 多余预期最多的次数
  2. 少于预期最少的次数

举个栗子,当实际调用次数过多时:

  1. Too many invocations for:
  2. 2 * subscriber.receive(_) (3 invocations)

匹配预期交互的顺序

在同一个 then 代码块中,预期交互是没有顺序上的控制的。

在对方法调用有先后顺序限制的场景,可以这样写:

  1. then:
  2. 2 * subscriber.receive("hello")
  3. then:
  4. 1 * subscriber.receive("goodbye")

在这样的定义下,就要求先调用 2 * subscriber.receive("hello") 两次,才能调用 1 * subscriber.receive("goodbye")

需要注意的是,and: 是没有顺序语义的,它的作用仅仅是为了代码看起来比较清晰Orz


Stubbing 类型的预期交互

Stubbing 定义的另外一种预期交互(interaction):给定方法的返回值。在使用 stubbing 的时候,你不关心方法被调用的次数,只是简单地给定方法的返回值。

PS:区别于 mocking 定义的预期交互:mocking 还要校验方法的调用次数

为了说明 Stubbing的作用,我们把 Subscriber 的 receive 方法定义成有返回值

  1. interface Subscriber {
  2. String receive(String message)
  3. }

下面是一个 stubbing 的例子

  1. subscriber.receive(_) >> "ok"
  2. | | | |
  3. | | | response generator
  4. | | argument constraint
  5. | method constraint
  6. target constraint

上面的声明可以理解为:

在任何场景下,只要调用了 subscriber 的 receive() 方法,都会返回 “ok”

相较于mock的预期交互的定义,stub的预期交互没有指定方法的调用次数,但是在 >> 右边增加了返回值

按调用顺序返回不同的值

按照调用的先后顺序,返回不同的值,需要用到 >>>符号

  1. subscriber.receive(_) >>> ["ok", "error", "error", "ok"]

第一次 ok
第二,第三次 error
第四次开始之后,都是 ok

通过入参计算得到返回值

>> 右侧也可以是一个闭包 ,可以通过方法的入参来计算出返回值

  1. subscriber.receive(_) >> { String message -> message.size() > 3 ? "ok" : "fail" }

意思就是,根据入参 message 的大小来决定返回值

调用方法时发生的异常

除了返回值意外,还能定义这个方法造成的”副作用”,例如抛出异常

  1. subscriber.receive(_) >> { throw new InternalError("ouch") }

链式返回值 stubbing

  1. subscriber.receive(_) >>> ["ok", "fail", "ok"] >> { throw new InternalError() } >> "ok"

前三次分别返回 ok fail ok,第四次抛异常,第五次及之后,都是返回 ok


Mocking 和 Stubbing 的组合使用

Mocking 和 Stubbing 两种类型预期行为可以连在一起用

  1. 1 * subscriber.receive("message1") >> "ok"

意思就是,当调用到 subscriber.receive("message1") 时,返回值是 ‘OK’,而且入参是 “message1” 的调用只会出现一次。

需要注意的是,mocking 和 stubbing 是针对同一个交互行为时,是不能拆开写的:

  1. given:
  2. subscriber.receive("message1") >> "ok"
  3. when:
  4. publisher.send("message1")
  5. then:
  6. 1 * subscriber.receive("message1")

注意!!!
还记得前面提到过的先后顺序嘛,到方法调用发生时,先去匹配 then 代码块中的预期交互,再去匹配 given 代码块中的预期交互。在 when 代码块中,subscriber 执行 receive("message1") 方法时,先去匹配 then 代码块中定义的 1 * subscriber.receive("message1") 这一句预期交互,这个时候 receive 方法是没有指定返回值的,所以此时返回的是默认值 null。而在 given 代码块中定义的 stubbing 交互是永远不会被匹配到的!!


其他类型的 Mock 对象

上文中创建的 Mock 对象都是通过 MockingApi.Mock方法创建的。除此之外,MockingApi 还提供了其他创建 Mock 对象的工厂方法,来创建特殊类型的 mock 对象。

Stubs

Stub 类型的 Mock 对象是通过 MockingApi.Stub 这个工厂方法创建的:

  1. Subscriber subscriber = Stub()

Mock 对象可以同时用来 Mocking(预测对象的调用次数) 和 Stubbing(给定方法预期返回)
Stub 对象只能用来 Stubbing,所以其实所有的 Stub 对象都可以被 Mock 对象所代替

注意: 如果那一个 Stub 对象去匹配调用次数的预期交互 1 * foo.bar() 就会抛出 InvalidSpecException

另外在 Stub 对象对于没有预设方法返回的方法调用时的返回值甚至比 Mock 对象还要激进:

  1. 对于基本类型,就返回基本类型的默认值
  2. 对于非基本类型的数字类型,例如 BigDecimal,返回零值
  3. 对于非基本类型的其他类型,则返回该类型的空值,例空字符串,空列表,或者是默认构造器构造的对象(反正不是null)。

通常在创建 stub 对象时,就会同时定义好它的预期交互:

  1. Subscriber subscriber = Stub {
  2. receive("message1") >> "ok"
  3. receive("message2") >> "fail"
  4. }

Spies

Spy 对象是通过 MockingApi.Spy 这个工厂方法创建的:

  1. SubscriberImpl subscriber = Spy(constructorArgs: ["Fred"])

一个 Spy 对象,其实是一个真实的对象实例,因此它不能基于一个接口的类型,而需要一个有构造器的类,并在调用 Spy 工厂方法的时候传入参数,如果不传参数,那么会调用这个类的默认构造器。

你也可以基于一个已经实例化的对象来创建一个 Spy 对象。当你对这个实例没有完整控制的时候,Spy 就很有用了。

在创建好一个 Spy 对象之后,你就可以监听在调用方和Spy 底层的实例对象的交互了(所以它叫 Spy):

  1. 1 * subscriber.receive(_)

上面这个预期交互只会确认 receive 方法只被调用一次,除此之外不会改变调用方和底层的 Subscriber 实例

Spy 对象也能实现 Stubbing 预期交互:

  1. subscriber.receive(_) >> "ok"

这样的话, Subscriber 实例的 receive 方法就不会被调用到,而是直接返回 “ok”

如果你即希望方法被实际执行,也希望方法返回一个 mock 的值,可以这样:

  1. subscriber.receive(_) >> { String message -> callRealMethod(); message.size() > 3 ? "ok" : "fail"}

在上面的代码中,通过代理方法callRealMethod()来调用实际的对象。注意我们调用这个代理方法时,不需要传参数,Spock 会自动处理这个事儿。

Partial Mocks

部分 Mock 的意思是,这个类确实是一个实际的类,但是它的行为都是通过 mock 完成的

  1. // this is now the object under specification, not a collaborator
  2. MessagePersister persister = Spy {
  3. // stub a call on the same object
  4. isPersistable(_) >> true
  5. }
  6. when:
  7. persister.receive("msg")
  8. then:
  9. // demand a call on the same object
  10. 1 * persister.persist("msg")

更多特性

A la Carte Mocks 按需Mock

org.spockframework.mock.IMockConfiguration 接口提供很多关于 Mock 对象的更细致的操作,例如

  1. def person = Mock(name: "Fred", type: Person, defaultResponse: ZeroOrNullResponse, verified: false)

上面的代码,创建了一个 Mock 对象,同时设定了 getName() 方法的预期返回,但它的调用次数不会被校验,就相当于 Stub 对象。

判断某个对象是不是 Mock 对象

  1. MockUtil mockUtil = new MockUtil()
  2. List list1 = []
  3. List list2 = Mock()
  4. expect:
  5. !mockUtil.isMock(list1)
  6. mockUtil.isMock(list2)

写在后面的话:
这一章节的内容较长,也很细节。很多点都是自己的理解,也可能有待商榷。如有疑问,可以多多交流!