1. 引言

编写测试时,我们经常需要断言对象的多个属性。Spock框架提供了一些实用的语言特性,帮助我们消除对象比较时的重复代码。

本教程将学习如何使用Spock的辅助方法with()verifyAll()重构测试代码,使测试更易读。

2. 准备工作

首先创建一个Account类,包含账户名、当前余额和透支限额:

public class Account {
    private String accountName;
    private BigDecimal currentBalance;
    private long overdraftLimit;

    // getters and setters
}

3. 基础测试

创建AccountTest规范类,测试账户属性的getter/setter。使用@Subject注解标记测试对象,每个测试前创建新账户:

class AccountTest extends Specification {
    @Subject
    Account account = new Account()

    def "设置账户属性后,获取值应与设置值一致"() {
        when: "设置账户属性"
        account.setAccountName("My Account")
        account.setCurrentBalance(BigDecimal.TEN)
        account.setOverdraftLimit(0)

        then: "获取的值应与设置值一致"
        account.getAccountName() == "My Account"
        account.getCurrentBalance() == BigDecimal.TEN
        account.getOverdraftLimit() == 0
    }
}

这里在断言部分重复引用了account对象。属性越多,重复代码越多。

4. 重构方案

让我们重构代码以减少重复。

4.1. 断言陷阱

初步尝试可能是提取getter比较到单独方法:

void verifyAccount(Account accountToVerify) {
    accountToVerify.getAccountName() == "My Account"
    accountToVerify.getCurrentBalance() == BigDecimal.TEN
    accountToVerify.getOverdraftLimit() == 0
}

但这里有个坑!创建一个使用他人账户值的验证方法:

void verifyAccountRefactoringTrap(Account accountToVerify) {
    accountToVerify.getAccountName() == "Someone else's account"
    accountToVerify.getCurrentBalance() == BigDecimal.ZERO
    accountToVerify.getOverdraftLimit() == 9999
}

then块中调用该方法:

then: "获取的值应与设置值一致"
verifyAccountRefactoringTrap(account)

运行测试,即使值不匹配也会通过!为什么?

Spock的隐式断言只在测试方法中生效,不会在调用方法中自动执行

如何解决?

4.2. 方法内显式断言

将比较逻辑移到单独方法时,必须手动添加assert关键字

创建显式断言的验证方法:

void verifyAccountAsserted(Account accountToVerify) {
    assert accountToVerify.getAccountName() == "My Account"
    assert accountToVerify.getCurrentBalance() == BigDecimal.TEN
    assert accountToVerify.getOverdraftLimit() == 0
}

then块中调用:

then: "获取的值应与设置值一致"
verifyAccountAsserted(account)

测试通过,修改断言值会正确失败。

4.3. 返回布尔值

另一种方法是返回布尔结果。使用Groovy的&&操作符组合断言:

boolean matchesAccount(Account accountToVerify) {
    accountToVerify.getAccountName() == "My Account"
      && accountToVerify.getCurrentBalance() == BigDecimal.TEN
      && accountToVerify.getOverdraftLimit() == 0
}

测试在所有条件匹配时通过,但有个缺点:失败时无法知道是哪个条件不满足。

虽然这些方法可行,但Spock的辅助方法提供了更优雅的解决方案。

5. 辅助方法

Spock提供了withverifyAll两个辅助方法,能更优雅地解决问题!两者工作方式类似,先学习with

5.1. with()辅助方法

with()接收一个对象和该对象的闭包。传递对象给with()后,对象的属性和方法会被添加到上下文中,在闭包内无需前缀对象名

重构方法使用with

void verifyAccountWith(Account accountToVerify) {
    with(accountToVerify) {
        getAccountName() == "My Account"
        getCurrentBalance() == BigDecimal.TEN
        getOverdraftLimit() == 0
    }
}

**注意:Spock辅助方法内自动应用power assertions,即使在不同方法中也不需要assert**。

**通常不需要单独方法,直接在测试断言中使用with()**:

then: "获取的值应与设置值一致"
with(account) {
    getAccountName() == "My Account"
    getCurrentBalance() == BigDecimal.TEN
    getOverdraftLimit() == 0
}

调用方法方式:

then: "获取的值应与设置值一致"
verifyAccountWith(account)

with()方法遇到第一个不匹配的比较就会失败测试

5.2. 用于Mock对象

with也可用于断言Mock交互。创建Mock Account并调用setter:

given: '一个mock账户'
Account mockAccount = Mock()

when: "调用setter方法"
mockAccount.setAccountName("A Name")
mockAccount.setOverdraftLimit(0)

验证时,在with作用域内可省略mockAccount前缀。

5.3. verifyAll()辅助方法

有时希望测试失败时看到所有断言结果。这时可用verifyAll(),用法与with相同:

verifyAll(accountToVerify) {
    getAccountName() == "My Account"
    getCurrentBalance() == BigDecimal.TEN
    getOverdraftLimit() == 0
}

verifyAll()遇到失败时会继续执行,报告作用域内所有失败的比较

5.4. 嵌套辅助方法

当对象包含嵌套对象时,可嵌套使用辅助方法。

先创建Address类并添加到Account

public class Address {
    String street;
    String city;

    // getters and setters
}
public class Account {
    private Address address;

    // getter and setter and rest of class
}

在测试中创建地址对象:

given: "一个地址"
Address myAddress = new Address()
def myStreet = "1, The Place"
def myCity = "My City"
myAddress.setStreet(myStreet)
myAddress.setCity(myCity)

添加到账户:

when: "设置账户属性"
account.setAddress(myAddress)
account.setAccountName("My Account")

比较地址时,基础方式:

account.getAddress().getStreet() == myStreet
account.getAddress().getCity() == myCity

可提取变量改进:

def address = account.getAddress()
address.getCity() == myCity
address.getStreet() == myStreet

但更优方案是使用with

with(account.getAddress()) {
    getStreet() == myStreet
    getCity() == myCity
}

嵌套到账户比较中。with(account)将账户加入作用域,可省略前缀:

then: "获取的值应与设置值一致"
with(account) {
    getAccountName() == "My Account"
    with(getAddress()) {
        getStreet() == myStreet
        getCity() == myCity
    }
}

Groovy支持属性直接访问,可用属性名替代getter

with(account) {
    accountName == "My Account"
    with(address) {
        street == myStreet
        city == myCity
    }
}

6. 工作原理

了解了with()verifyAll()如何提升可读性,但它们如何工作?

查看with()方法签名:

with(Object, Closure)

可传递两个参数使用:

with(account, (acct) -> {
    acct.getAccountName() == "My Account"
    acct.getOverdraftLimit() == 0
})

Groovy对最后一个参数是Closure的方法有特殊语法支持,允许将闭包声明在括号外

with(account) {
    getAccountName() == "My Account"
    getOverdraftLimit() == 0
}

注意测试中只传递了一个参数account,第二个参数是紧随with的花括号内的代码块,该闭包接收第一个参数account作为操作对象。

7. 总结

本教程学习了如何使用Spock的with()verifyAll()辅助方法减少对象比较时的样板代码。掌握了:

  • ✅ 简单对象和复杂嵌套对象的使用方式
  • ✅ Groovy属性记法使断言更简洁
  • ✅ 与Mock对象的配合使用
  • ✅ 利用Groovy闭包语法提升可读性

完整代码示例可在GitHub仓库查看。


原始标题:Reducing Duplication With Spock’s Helper Methods | Baeldung