用 Kotlin 来测试 #2

513 阅读11分钟
原文链接: academy.realm.io

Spock 的维护者 Rob Fletcher 描述了当前 Kotlin 测试框架的现状,并和 Spek 做了比较,它是标准框架的一个 JetBrains’s 的变种。作为一个有经验的测试人员,Rob 强调了两个库的优点和缺点,同时也提到了 Spek 擅长的地方和对 Spek 的改进期望。利用 Kotlin,可以极大地简化一些反复的测试和打桩。


用 Kotlin 来测试 (0:00)

嗨,我的名字叫做 Rob Fletcher,我们来这的目的是为了比较我们在不同的测试框架中发现的一些有趣的用例,还有我们是如何在 Spek 里实现类似的事情的。

我是 Netflix 的一名开发工程师,我没有在日常的工作中使用 Kotlin,虽然我很想这样做。现在我在写一本关于 Spock 的书,这是一个为 Groovy 开发的测试框架。我将给大家展现的一些东西是 Spock 的一些真正酷的功能,然后我会展示我们是不是可以在 Spek 里实现类似的事情,或者是做的更好。

让我们开始吧。我会谈到三个重要的部分:迭代测试,打桩和 TCKs。

迭代测试 (1:05)

迭代 - Spock (1:23)

首先,我们会看一些迭代测试。它们是非常漂亮的嵌套 BDD 结构,你可以在 Spock 里使用这种结构。 让我们看看是如何处理这些带参数的测试的吧,像这样:

def "the diamond function rejects illegal characters"() {  
    when: 
    diamond.of(c) 
 
    then: 
    thrown IllegalArgumentException 
    
    where: 
    c << generator.chars() 
        .findAll { !('A'..'Z').contains(it) } 
        .take(100) 
}

这是一个典型的 Spock 结构,我们有一个叫做 where 的代码块,它里面有一个参数。它是用这种时髦的左缩进语法来获取迭代的数据源的。

将要发生的事情是,这个测试会在这个变量里取出得的每一个值上都运行一次,然后那个值在整个测试过程中都能被使用。对于这种基于属性的测试,能这样做真是太棒了;或者是那种你有一组数值,这组数值是边界用例需要用到的情况,这样做也非常有益。

迭代 - Spek (2:05)

现在我们来看看 Spek 里面的类似情况的一个例子:

describe("handling invalid characters") { 
    
  chars().filter { it !in 'A'..'Z' }  
         .take(100) 
         .forEach { c ->
    
    it("throws an exception") 
      assertFailsWith(IllegalArgumentException::class) { 
        diamond.of(c)  
      }
    }
}

我喜欢这种简洁的风格,因为这让我想起了 Jasmine。我在工作中做了许多前端的工作,我一直都偏爱 Jasmine 而且那之后我就再也没有在 JVM 上找到类似结构的东西了。 Spek 能够做到这点。

在我们的 describe 代码块间,我们使用了 for-each 迭代。我们可以有一个 describe,或者一个 ` context,或者在迭代里有任意多个。因此定义在 it` 代码块上的这个测试,会在出现的每一个值上都运行一次,而且你如果在 IDE 里面或者命令行里面观察它的话,你会看到每个值都有一个独立的测试结果。

标记测试名字 – Spock (3:07)

当你这样做的时候,很明显,你会每次都得到同样的 it,当然这不是最理想的。Spock 允许你能做的一个真的非常酷的事情是,给你的测试增加这个 Unroll 注解,然后你可以用这些测试的名字来代替它们的数值,这样当你的报表生成的时候,或者你的 IDE 测试运行器工作的时候,如果有某个单独的用例失败了,你就可以看得出来是哪一个了。

@Unroll
def "the diamond function rejects #c"() {  
  when: 
  diamond.of(c) 

  then: 
  thrown IllegalArgumentException 
  
  where: 
  c << generator.chars() 
                .findAll { !('A'..'Z').contains(it) } 
                .take(100) 
}

标注测试名字 和 嵌套 – Spek (3:27)

在 Spek 里面,这更加容易,因为你可以在你的 describe 或者 it 代码块中使用字符串插值。非常容易:

describe("handling invalid characters") { 
    
  chars().filter { it !in 'A'..'Z' }  
         .take(100) 
         .forEach { c ->
    
    it("throws an exception for '$c'") 
      assertFailsWith(IllegalArgumentException::class) { 
        diamond.of(c)  
      }
    }
}

用 Spek 能做但是 Spock 不能做的一件非常酷的事情就是这个无效的小 diamond 切割器,这也是我绞尽脑汁想完成的事情。不知是否有人熟悉这个谜题,实现这种嵌套的迭代:你有一组测试需要在某个层级的迭代上运行,而其他的测试需要在迭代内运行。

('B'..'Z').forEach { c ->
  describe("the diamond of '$c'") { 
    val result = diamond.of(c) 
    
    it("is square") { 
      assert(result.all { it.length == rows.size }) 
    }
 
    ('A'..c).forEach { rowChar -> 
      val row = rowChar - 'A'
      val col = c - rowChar 

      it("has a '$rowChar' at row $row column $col") {  
        assertEquals(rowChar, result[row][col])
      }
    }
  }
}

这里我们想测试不同的字符的产生,但是在它的里面我们想验证是否默写其他的字符会出现一种模式。所以你需要嵌套一个迭代,这个迭代是我刚给你演示的 Spock 结构所不能实现的,因为你需要得到一个层级的参数。

表格式的迭代 - Spock (4:25)

一个 Spock 做的挺好的,而 Spek 不能实现的是表格式数据。除了我们看到的左缩进操作符以外,Spock 能让你在列头处定义多个变量,在变量下可以提供数据表。

@Unroll
def "the diamond of #c has #height rows"() {
  expect:
  diamond.of(c).size == height

  where:
  c | height
  'A' | 1
  'B' | 3
  'C' | 5 
}

还有一个非常酷的功能是,IntelliJ IDEA 会帮助你正确对齐这些表格。然后你就可以给你的测试输入多个参数了。现在 Spek 不能支持这种方式,但是我知道这在它们的代办清单上。

现在你能做的最好的事情就是如下:

for ((c, height) in mapOf('A' to 1, 'B' to 2, 'C' to 3)) {  
  describe("the diamond of '$c") { 
    val result = diamond(c)
    it("has $height rows") { 
      assertEquals(height, result.size)
    }  
  }
}

这里我们能绕开的原因是因为我们的数据只有两个维度,我们可以在一个 map 上迭代,然后在里面组成我们的 describeit 代码块。如果你有三维或者更多维的数据,这就会变得更加麻烦,虽然你可以定义一些数据类,然后遍历这些类的集合。可以这样做,但是不如你在 Spock 里面实现的那么干净。

打桩 - Mockito 遇到 Kotlin (5:45)

Mocks 对于 Kotlin 来说是一个有意思的话题,由于一些原因,对于 Spek 来说会更加有意思。所以让我们来看看如何使用 Mockito 来实现 Spek 类。

describe("publishing events") { 
  val eventBus = EventBus() 
  val subscriber = mock(Subscriber::class.java) as Subscriber<ExampleEvent> // can't infer generic type
  beforeEach { 
    eventBus.post(ExampleEvent("test running"))
  } 
 
  it("should send the event to the subscriber") { 
    verify(subscriber)  
      .onEvent(argThat {  // returns null so fails at runtime
        (it as ExampleEvent).description == "test running" // can't infer generic matcher argument
      }) 
  }
}

这里我们有一些困难的事情。首先我们这样做很笨重,类 Java 的转换,因为我们只有这些期望打桩类型的泛型类型,而且 Kotlin 是不知道运行时的类型的,因为这些类型被删除了。所以我们需要在我们的断言里面重新转换一遍,我们在 Mockito 打桩的对象上重新验证。

我们没有类型推断的支持,所以我们失去了所有泛型类型的类型信息,这不太好。

因为 Mockito’s 匹配都返回 null, 所有的顶层都不工作,而且运行时也会失败,因为 Kotlin 会严格检查 null。

我所知道的是 Kotlin 没有打桩的框架,当然肯定有一些 Mockito 是专门支持 Kotlin 的,所以如果你在你的 Gradle 编译里面增加这个库,你会得到相对应的 Mockito 结构的 Kotlin 版本,现在这是个非常简洁的组合体。

repositories { 
  jcenter()  
} 
 
dependencies { 
  compile "com.nhaarman:mockito-kotlin:0.4.1"  
}
describe("publishing events") { 
  val eventBus = EventBus() 
  val subscriber: Subscriber<Event> = mock()  // reified generics allow type inference
 
  beforeEach { 
    eventBus.post(Event("test running"))  
  } 
 
  it("should send the event to the subscriber") { 
    verify(subscriber).onEvent(argThat { // returns dummy value
      description == "test running" // type inference on receiver
    })
  }
}

我们现在在桩上有类型推断,所以变量 subscriber 被声明成一个泛型,Kotlin 的具体化泛型功能使得我们能弄清在桩工厂里面的类型是什么;你不需要制定类,你不会丢掉任何泛型的类型信息,你不需要转换任何东西。

在验证代码块里面我们有一个定义好的接收者,这样我们就不需要 it. 了。在传入匹配器的参数中,我们又做一次类型推断,所以我们不需要转换这个参数。

这真的是好太多了,匹配器返回一个无效的值,所以我们不再有那个 null 的问题了。这是一个 非常小的库,它简化了在 Kotlin 和 Spek 里使用 Mockito 的方法。如果你在使用任何种类的双倍测试的话,我强烈建议你看看这个库。

TCKs - 一个测试全集 (8:13)

TCKs 是技术兼容性测试包。它后面的设计理念是同样事情的多种不同实现需要遵循一个特定的标准集合。

一个简单的例子就是 Java 的列表。我们有 ArrayList,也有 LinkedList,而且还有好多不一样的实现。但是它们都需要遵循一组特定的规则,比如他们返回值需要符合插入顺序,有些允许 nulls,有些不允许,但是它们都能作比较而且 hash 值需要一致。有一组一致的规则需要遵守。

这样,这些规则的测试用例只需要编写一遍,然后在不允许修改测试的前提下,你的实现需要测过同样的测试集合。

TCK - Spock (9:01)

让我们来看看 Spock 是如何处理这个事情的,你当然会定义一个抽象的测试类,有一些抽象工厂方法来产生测试需要的类,编写一组能够访问这些对象的测试用例,然后你可以用特定的类来扩展这些测试接口类,这些特定的类会重载测试工厂方法。

abstract class PubSubSpec extends Specification {
  @Subject abstract EventBus factory() 
  // ... tests appear here
}

class SyncPubSubSpec extends PubSubSpec {  
  @Override EventBus factory() { 
    new EventBus()  
  } 
}   

class AsyncPubSubSpec extends PubSubSpec {  
  @Override EventBus factory() { 
    new AsyncEventBus(newSingleThreadExecutor())  
  } 
}

如果对某个实现有扩展功能的话,他们也可以增加自己的测试用例。无论如何,当你执行你的测试集合的时候,所有这些在抽象基类里定义的测试,都会需要它的子类或者子类的子类或者其他的东西。

你可以很快地通过实现接口,构建出了一个验收测试集。

TCK - Spek (10:19)

现在 Spek 里面一个有趣的事情是,这些测试本身是在一个 Java 类的静态初始化函数里面定义的。你没有任何可以重载的函数来做类似的事情。

abstract class PubSubTck(val eventBus: EventBus) : Spek ({ 
  describe("publishing events") { 
    // ... tests appear here  
  } 
}) 
 
class SyncPubSubSpec  
: PubSubTck(EventBus()) 
 
class AsyncPubSubSpec  
: PubSubTck(AsyncEventBus(newSingleThreadExecutor()))

如上,你可以在 Spek 类里面定义类级别的方法,但是这些测试看不到这些方法,它只能看到同伴对象里面的东西。显然,像 Java 静态方法这样的同伴对象是不可以继承的。

一个解决方案是你可以用一个抽象类来扩展 Spek 的基本类,但是定义一个你的 eventBus 的属性。

你可以定义一个属性,然后它的实现需要提供构造函数,之后正常编写你的所有的测试 DSL。这样你就有了这些实现的特定扩展,而且可以简单地给它们的构造函数传值,构造函数可以在接下来的测试里面访问到。

这很好,只要你能把你测试类的构造过程以足够简洁的形式表示出来,就像在构造函数里做的那样。如果你做不到,你可能就需要使用某种工厂方法,lambda 也是你可以采用的一种显而易见的解决方案,再一次你有一个抽象的 Spek 类,它有一个工厂方法来给你的测试类传入空参数,然后如果你愿意的话,每一个测试都能创建一个新的实例并且都有一个值,在调用工厂方法时会传入那个值,然后那些扩展类的特定测试实现会提供工厂方法。

abstract class PubSubTck(val factory: () -> EventBus) : Spek ({ 
  val eventBus = factory() 
  describe("publishing events") { 
    // ... tests appear here  
  } 
}) 
 
class SyncPubSubFactorySpec  
: PubSubTck(::EventBus) 
 
class AsyncPubSubFactorySpec  
: PubSubTck({ AsyncEventBus(newSingleThreadExecutor()) })

这里我们可以使用 Kotlin 的时髦的方法引用语法来引用标准 EventBus 的构造方法。如果是零参数的构造函数,你可以这样做。

这里我们用了一个 lambda closure 因为它有更复杂的构造函数,而且也有些更复杂的设置。这工作的很好。这使你绕过了你不能在 Spek 类里使用继承和虚函数的事实,但是你可以给构造函数传递工厂和属性。

图解断言 (12:27)

这是我对 Spek 的最大的期望:图解断言。 这是 Spock 引入的一个很酷的功能,而且被 Groovy 语言在它的 assert 关键字里全盘采纳了。

在 Spock 的一个期望代码块中,任何事情都会被认为是 boolean 而且被作为断言,所以这是一个断言。



expect:
order.user.firstName == "Rob"


如果这个断言失败了,Spock 会给我们真正的美妙的输出,像这样:



order.user.firstName == "Rob" 
|     |    |         |
|     |    rob       false
|     |              1 difference (66% similarity)
|     |              (r)ob
|     |              (R)ob
|     [firstName:rob, lastName:fletcher]
Order<#3243627>


表达式的二进制操作符两边的每一个步骤都被分解开来,而且你能看到每一个独立的对象。你可以由此弄清楚发生了什么事情。这样,你就可以挠挠头,看一看,想一想 “好吧,为什么这个名字会错?是不是错误的用户,顺序错了吗?”

如果没有这个很棒的图表分解来帮你澄清一些事情的话,你是很难找到原因的,当然你仍然需要一个优秀的作用在这些对象上的 toString() 的实现。

关于这个,Spock 的实现更加整齐,如果 `toString() 的实现是一样的话,在评估相等的时候也能指出问题。如果你有一个串是数字一,然后你和整型一相比较,虽然它们打印一样,但是比较时是不相等的,它会指出失败的确切原因是因为类型不同。

这个强大的断言功能被 Groovy 语言采纳后,Groovy 的断言关键字默认就有这个功能了。ScalaTest 也有一个特征你能用来实现类似的功能集合,一个类似的功能。

这也是我希望 Spek 能加上的头号功能,因为我写了好几年的 Spock 测试了,没有这个功能会是我最艰难的事情之一。所以,我很热爱 Spek,对我而言,这是Speck当前缺少的头号功能。

这篇文章涉及了一些有趣的,有用的两个测试框架的测试用例,这都是我特别想强调的地方。谢谢大家。