返回介绍

2 语言特性

发布于 2025-01-04 00:44:53 字数 9797 浏览 0 评论 0 收藏 0

除了集成了对 JUnit 的支持外,Groovy 的一些原生功能已被证明非常适合测试驱动的开发。本节将进行相关介绍。

2.1 强力断言语句

编写测试意味着要指定假设,这就要使用断言。在 Java 中,这可以通过 assert 关键字(在 J2SE 1.4 中引入)来实现, assert 语句可以通过 JVM 参数 -ea (或 -enableassertions )和 -da (或 -disableassertions )来启用。断言语句在 Java 中默认是禁用的。

Groovy 的断言是 assert 的一种功能强大的变体,也被称为 强力断言语句power assertion statement)。Groovy 的强力断言语句与 Java 的 assert 的区别在于输出中的布尔表达式会验证为 false

def x = 1
assert x == 2

// Output:   1⃣️      
//
// Assertion failed:
// assert x == 2
//    | |
//    1 false  

1⃣️ 表示标准错误输出

如果断言无法成功验证,则抛出 java.lang.AssertionError ,包含一个原始异常消息的扩展版本。强力断言输出显示了从外在表达式到内在表达式的解析结果。

强力断言语句真正的能力可以体现在复杂的布尔语句中,以及与集合或其他可使用 toString 的类相关的语句中。

def x = [1,2,3,4,5]
assert (x << 6) == [6,7,8,9,10]

// Output:
//
// Assertion failed:
// assert (x << 6) == [6,7,8,9,10]
//     | |   |
//     | |   false
//     | [1, 2, 3, 4, 5, 6]
//     [1, 2, 3, 4, 5, 6]

另一个与 Java 所不同的是,Groovy 断言是 默认启用 的。出于语言设计的决策,去除了使断言无效的功能。或者,如同 Bertrand Meyer 所言: 如果真的下水,最好带着游泳圈

另外一个值得注意的是,布尔表达式中带有一定副作用的方法。内部错误消息构建机制只能存储目标的实例引用,所以在遇到涉及副作用方法的情况时,错误消息文本在呈现时间上会出现异常。

assert [[1,2,3,3,3,3,4]].first().unique() == [1,2,3]

// Output:
//
// Assertion failed:
// assert [[1,2,3,3,3,3,4]].first().unique() == [1,2,3]
//              |     |    |
//              |     |    false
//              |     [1, 2, 3, 4]
//              [1, 2, 3, 4]       1⃣️

1⃣️ 错误消息显示的是集合的实际状态,而不是应用了 unique 方法之前的状态。

如果提供自定义断言错误消息,可以使用 Java 的 assert expression1 : expression2 ,其中 expression1 是布尔表达式,而 expression2 是自定义错误消息。注意,这样做会禁用强力断言,完全回退到自定义错误消息上。

2.2 模拟与存根

对一些模拟与存根方案,Groovy 提供了强大的内建支持。使用 Java 时,动态模拟框架是常用的,其关键原因就在于利用 Java 手动创建模拟是一种很繁重的工作。这样的框架可以轻松地应用于 Groovy 中,但创建自定义模拟显然更为轻松。利用简单的映射或闭包来创建自定义模拟。

下面就来介绍如何只利用 Groovy 的语言特性来创建模拟与存根。

2.2.1 映射强制

使用 map 或 expando,可以轻松包含协作对象(collaborator)的预期行为:

class TranslationService {
  String convert(String key) {
    return "test"
  }
}

def service = [convert: { String key -> 'some text' }] as TranslationService
assert 'some text' == service.convert('key.text')

as 操作符强制将映射转换为特定类。给出的映射键被解析为方法名,而映射值, groovy.lang.Closure 块,则被解析为方法代码块。

注意,如果用 as 操作符来处理 java.util.Map 的后代类,映射强制会产生妨碍。映射强制机制只针对特定的集合类,并没有考虑到自定义类。

2.2.2. 闭包强制

as 操作符能以一种简洁的形式用于闭包,从而非常适合开发者在简单环境下进行测试。虽然该技术还没有强大到能不再使用动态模拟的程度,但至少足以应付简单环境。

持有单一方法的类或接口,包括 SAM(单一抽象方法)的类,可以用于强制闭包,使其成为一种指定类型的对象。为了实现这种机制,Groovy 内部会创建一个指定类的代理子对象。因此对象不是指定类的直接实例。这一点是非常重要的,比如生成的代理对象元类后续被改动。

强制闭包成为指定类型对象的范例如下:

def service = { String key -> 'some text' } as TranslationService
assert 'some text' == service.convert('key.text')

Groovy 支持一种叫做隐式 SAM 强制的功能。这意味着 as 操作符并不一定会用在运行时能够推断目标 SAM 类型的情况下。这种强制非常适用于模拟整个 SAM 类。

abstract class BaseService {
  abstract void doSomething()
}

BaseService service = { -> println 'doing something' }
service.doSomething()

2.2.3. MockFor 和 StubFor

Groovy 的模拟及存根类位于 groovy.mock.interceptor 包中。

MockFor 类支持独立地对类进行测试(通常是单元测试),这是通过定义一种 严格有序 的协作对象行为来实现的。典型的测试情境通常会包含待测类及一个或多个协作对象。通常希望只对待测类的业务逻辑进行测试。为此,实现策略之一是通过简化的模拟对象来代替协作对象,以便隔离出测试目标内的逻辑。 MockFor 类可以利用元编程来创建这样的模拟。协作对象的期望行为被定义为一种行为规范。行为会被自动强制执行并检查。

假设目标类如下所示:

class Person {
  String first, last
}

class Family {
  Person father, mother
  def nameOfMother() { "$mother.first $mother.last" }
}

利用 MockFor ,模拟期望常常是序列相关的,自动会在结尾处调用 verify

def mock = new MockFor(Person)  1⃣️  
mock.demand.getFirst{ 'dummy' }
mock.demand.getLast{ 'name' }
mock.use {             2⃣️ 
  def mary = new Person(first:'Mary', last:'Smith')
  def f = new Family(mother:mary)
  assert f.nameOfMother() == 'dummy name'
}
mock.expect.verify()        3⃣️  

1⃣️ 通过 MockFor 的一个新实例创建一个新模拟
2⃣️ Closure 被传入 use ,启用模拟功能
3⃣️ 调用 verify 查看是否序列和方法调用数正如预期

StubFor 类支持独立地对类进行测试(通常是单元测试),允许定义的协作对象的期望 次序松散loosely-ordered)。通常测试情境包括一个受测类以及一个或多个协作对象。这样的情境通常只希望测试 CUT 的业务逻辑。为此可以实施这样一种策略:利用简化的存根对象来代替协作实例,以便将目标类中的逻辑抽取出来。 StubFor 允许使用元编程来创建这样的存根。协作对象的预期行为被定义为一种行为规范。

MockFor 不同的是,利用 verify 检查的存根期望是序列无关的,使用是可选的:

def stub = new StubFor(Person)     1⃣️  
stub.demand.with {          2⃣️
  getLast{ 'name' }
  getFirst{ 'dummy' }
}
stub.use {              3⃣️
  def john = new Person(first:'John', last:'Smith')
  def f = new Family(father:john)
  assert f.father.first == 'dummy'
  assert f.father.last == 'name'
}
stub.expect.verify()        4⃣️  

1⃣️ 通过 StubFor 新实例创建的一个新存根。
2⃣️ 使用 with 方法将所有闭包中的调用委托给 StubFor 实例。
3⃣️ Closure 传入 use ,启用存根功能。
4⃣️ 调用 verify (可选的)检查是否调用数目符合预期。

MockForStubFor 无法应用于静态编译类,比如使用 @CompileStatic 的 Java 类或 Groovy 类。要想存根或模拟这些类,可以使用 Spock 或一种 Java 模拟库。

2.2.4 Expando 元类 (EMC)

Groovy 包含了一种特殊的 MetaClass :EMC(Expando 元类, ExpandoMetaClass )。允许使用简洁的闭包格式来动态添加方法、构造函数、属性、静态方法。

每个 java.lang.Class 都带有一个特殊的 metaclass 属性,该属性引用了一个 ExpandoMetaClass 实例。expando 元类并不局限于自定义类,它也可以用于 JDK 类,比如 java.lang.String

String.metaClass.swapCase = {->
  def sb = new StringBuffer()
  delegate.each {
    sb << (Character.isUpperCase(it as char) ? Character.toLowerCase(it as char) :
      Character.toUpperCase(it as char))
  }
  sb.toString()
}

def s = "heLLo, worLD!"
assert s.swapCase() == 'HEllO, WORld!'

ExpandoMetaClass 是相当好的一种用于模拟功能的备选方案,可以实现一些更先进的事务,比如模拟静态方法。

class Book {
  String title
}

Book.metaClass.static.create << { String title -> new Book(title:title) }

def b = Book.create("The Stand")
assert b.title == 'The Stand'

或甚至构造函数:

Book.metaClass.constructor << { String title -> new Book(title:title) }

def b = new Book("The Stand")
assert b.title == 'The Stand'

模拟构造函数可能似乎是一种讨巧的方法,最好甚至不用考虑,但有效的用例也还是存在的,在 Grails 上就能找到一些范例:在运行时中,借助 ExpandoMetaClass 添加域类构造函数。域对象在 Spring 应用上下文中自我注册,并实现了由依赖项注入容器所控制的服务或 Bean 的注入。

如果希望改变每个测试方法级别上的 metaClass 属性,需要清除作用于元类上的更改,否则这些更改将持续作用于测试方法调用的整个过程之中。 GroovyMetaClassRegistry 中替代元类就能去掉更改。

GroovySystem.metaClassRegistry.setMetaClass(java.lang.String, null)

注册 MetaClassRegistryChangeEventListener ,跟踪改变的类,并清除选定的测试运行时的 cleanup 方法中的更改。可以在 Grails Web 开发框架 中找到比较好的范例。

除了使用类级别的 ExpandoMetaClass ,也支持使用元类或对象级别。

def b = new Book(title: "The Stand")
b.metaClass.getTitle {-> 'My Title' }

assert b.title == 'My Title'

在该例中,元类更改只与实例有关,根据测试情境,这可能要比全局元类更改适应性更好。

2.3 GDK 方法

下面将概述可以在测试用例情境(比如对于数据生成的测试)中使用的 GDK 方法。

2.3.1 Iterable##combinations

利用 java.lang.Iterable 兼容类中添加的 combinations 方法,可以从一个包含两个或更多子列表的列表中获得一个组合列表:

void testCombinations() {
  def combinations = [[2, 3],[4, 5, 6]].combinations()
  assert combinations == [[2, 4], [3, 4], [2, 5], [3, 5], [2, 6], [3, 6]]
}

该方法可以在测试用例情境下,针对特定的方法调用,生成所有可能的参数组合。

2.3.2 Iterable##eachCombination

利用添加到 java.lang.Iterable 兼容类中的 eachCombination 方法,如果组合是由 combinations 方法所构建的,那么它可以在每一个组合上应用一个函数(或者如同在该例中这样采用 groovy.lang.Closure )。

eachCombination 是一种添加到所有符合 java.lang.Iterable 接口的类上的 GDK 方法。它会在输入列表的每一个组合上应用一个函数:

void testEachCombination() {
  [[2, 3],[4, 5, 6]].eachCombination { println it[0] + it[1] }
}

该方法还可以用在测试上下文中,利用每一个生成的组合来调用方法。

2.4 工具支持

2.4.1 测试代码覆盖率

代码覆盖率是关于(单元)测试有效性的一种重要衡量标准。具有较高代码覆盖率的程序要比代码覆盖率较低的程序更安全,留存严重 bug 的几率要低得多。要想提示代码覆盖率,生成的字节码通常需要在执行前进行检测。 Cobertura 就是受 Groovy 支持的用于此目的一款工具。

很多框架及构建工具都集成有 Cobertura。Grails 中有基于 Cobertura 的 code coverage plugin ;Gradle 中则有 gradle-cobertura plugin 。当然,它们仅仅是众多插件中的两个而已。

下例展示了如何在一个 Groovy 项目的 Gradle 构建脚本中启用 Cobertura 代码覆盖报告:

def pluginVersion = '<plugin version>'
def groovyVersion = '<groovy version>'
def junitVersion = '<junit version>'

buildscript {
  repositories {
    mavenCentral()
  }
  dependencies {
    classpath 'com.eriwen:gradle-cobertura-plugin:${pluginVersion}'
  }
}

apply plugin: 'groovy'
apply plugin: 'cobertura'

repositories {
  mavenCentral()
}

dependencies {
  compile "org.codehaus.groovy:groovy-all:${groovyVersion}"
  testCompile "junit:junit:${junitVersion}"
}

cobertura {
  format = 'html'
  includes = ['**/*.java', '**/*.groovy']
  excludes = ['com/thirdparty/**/*.*']
}

Cobertura 代码覆盖报告和测试代码覆盖报告可以添加到持续集成构建任务中,可以为这些报告选择一些输出格式。

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文