Spock 初识

4,871 阅读12分钟

单测存在的痛点

  1. 运行一个 case 耗时过长(测试上下文中有太多依赖|设计中的耦合性太高)
  2. 一个方法中有太多 test case(数据对象情况复杂,被测试的方法做了太多事情)
  3. 太多的 setup/teardown(表示被测试类的耦合性太高)
  4. 数据在数据库中,有操作权限的成员太多极易被修改,极易造成混乱(H2 数据库)
  5. 改变一个地方,多处测试受影响,也许是测试的设计问题,也许是实现代码中有过多依赖

Spock 测试框架简介

Spock

Spock 是一个测试框架,它的核心特性有以下几个:

  • 可以应用于 java 或 groovy 应用的单元测试框架。
  • 测试代码使用基于 groovy 语言扩展而成的规范说明语言(specification language)。
  • 通过 junit runner 调用测试,兼容绝大部分junit的运行场景(ide,构建工具,持续集成等)。
  • 框架的设计思路参考了 JUnit,jMock,RSpec,Groovy,Scala,Vulcans……

Spock in 5 minutes

所需依赖

<dependency>
    <groupId>junit</groupId>
    <artifactId>junit</artifactId>
    <version>4.12</version>
    <scope>test</scope>
</dependency>

<!-- Mandatory dependencies for using Spock -->
<dependency>
    <groupId>org.spockframework</groupId>
    <artifactId>spock-core</artifactId>
    <version>1.1-groovy-2.4</version>
    <scope>test</scope>
</dependency>

<dependency>
    <groupId>org.spockframework</groupId>
    <artifactId>spock-spring</artifactId>
    <version>1.1-groovy-2.4</version>
    <scope>test</scope>
</dependency>

<!-- enables mocking of classes (in addition to interfaces) -->
<dependency>
    <groupId>net.bytebuddy</groupId>
    <artifactId>byte-buddy</artifactId>
    <version>1.9.3</version>
    <scope>test</scope>
</dependency>

<!-- enables mocking of classes without default constructor -->
<dependency>
    <groupId>org.objenesis</groupId>
    <artifactId>objenesis</artifactId>
    <version>2.6</version>
    <scope>test</scope>
</dependency>

基本结构

Spock 的测试类均派生自 Specification 类,命名遵循 Java 规范。每个测试方法可以直接用文本作为方法名,方法内部由 given-when-then 的三段式块(block)组成。除此以外,还有 and、where、expect 等几种不同的块。

@Title("测试的标题")
@Narrative("""关于测试的大段文本描述""")
// 标明被测试的类是Adder
@Subject(TestedClassName)  
// 当测试方法间存在依赖关系时
// 标明测试方法将严格按照其在源代码中声明的顺序执行
@Stepwise  
class TestCaseClass extends Specification {  
 
  @Shared //在测试方法之间共享的数据
  SomeClass sharedObj
 
  def setupSpec() {
    //TODO: 设置每个测试类的环境
  }
 
  def setup() {
    //TODO: 设置每个测试方法的环境,每个测试方法执行一次
  }
 
  @Ignore("忽略这个测试方法")
  @Issue(["问题#23","问题#34"])
  def "测试方法1" () {
    given: "给定一个前置条件"
    //TODO: code here
    and: "其他前置条件"
 
    expect: "随处可用的断言"
    // TODO: code here
    when: "当发生一个特定的事件"
    // TODO: code here
    and: "其他的触发条件"
 
    then: "产生的后置结果"
    // TODO: code here
    and: "同时产生的其他结果"
 
    where: "不是必需的测试数据"
    input1 | input2 || output
     ...   |   ...  ||   ...   
  }
 
  // 只测试这个方法,而忽略所有其他方法
  @IgnoreRest 
  // 设置测试方法的超时时间,默认单位为秒
  @Timeout(value = 50, unit = TimeUnit.MILLISECONDS)  
  def "测试方法2"() {
    // TODO: code here
  }
 
  def cleanup() {
    // TODO: 清理每个测试方法的环境,每个测试方法执行一次
  }
 
  def cleanupSepc() {
    // TODO: 清理每个测试类的环境
  }
}

断言

  • 在 then 块里,不需要 assertEquals("断言提示", left, right) 这样的方式,直接写 left == right 这样的逻辑表达式即可。
  • 借助 Groovy 的语法,Spock 使用 N * method() 来判定该方法是否被调用了 N 次。而 N * method() >> true 则表示方法 method 被调用 N 次,且每次该方法的返回值均为 true。

参数化测试

Spock 使用 where 块,为测试方法提供表格化的测试数据。其中表头为测试方法中要用在断言中的变量名称或者表达式,用|分隔输入参数,用||分隔输入与输出。这些参数,可以用 #参数名 的方式在 @Unroll 描述或者测试方法名里定义,或者在测试方法的参数列表里定义,然后在 where 块中使用。

// where 块中的每行参数都转换为一个独立的测试用例
@Unroll("test #para0, #para1") 
def "测试方法3"(int first, int second) {
  // ... ...
  where: "parameterized sample"
  para0 | para1 | para2 || para3 | first | second
    10  |   2   |   3   ||   7   |   2   |   5
}

简单实例

被测试类(src/main/java/SumUtils.java)

public class SumUtils {
    public static int sum(int a, int b) {
        return a + b;
    }
}

测试类(src/test/groovy/SumUtilsTest.groovy

@Title("测试加法工具类")
@Subject(SumUtils)
class SumUtilsTest extends Specification {
    @Unroll
    def "test Sum"() {
        expect:
        res == SumUtils.sum(a , b)

        where:
        a | b | res
        1 | 1 | 2
        0 | 0 | 0
        -1 | -1 | -2
        0  | -1 | 0
    }
}

常用 Spock 语法

Mocking

Mocking 是描述规范下的对象与其协作者之间(强制)交互的行为。

1 * subscriber.receive("hello")
|   |          |       |
|   |          |       argument constraint
|   |          method constraint
|   target constraint
cardinality

创建 Mock 对象

def subscriber = Mock(Subscriber)
def subscriber2 = Mock(Subscriber)
    
Subscriber subscriber = Mock()
Subscriber subscriber2 = Mock()    

注入 Mock 对象

class PublisherSpec extends Specification {
  Publisher publisher = new Publisher()
  Subscriber subscriber = Mock()
  Subscriber subscriber2 = Mock()

  def setup() {
    publisher.subscribers << subscriber // << is a Groovy shorthand for List.add()
    publisher.subscribers << subscriber2
  }

调用频率约束(cardinality)

1 * subscriber.receive("hello")      // exactly one call
0 * subscriber.receive("hello")      // zero calls
(1..3) * subscriber.receive("hello") // between one and three calls (inclusive)
(1.._) * subscriber.receive("hello") // at least one call
(_..3) * subscriber.receive("hello") // at most three calls
_ * subscriber.receive("hello")      // any number of calls, including zero
                                     // (rarely needed; see 'Strict Mocking')

目标约束(target constraint)

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

方法约束(method constraint)

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

参数约束(argument constraint)

1 * subscriber.receive("hello")        // an argument that is equal to the String "hello"
1 * subscriber.receive(!"hello")       // an argument that is unequal to the String "hello"
1 * subscriber.receive()               // the empty argument list (would never match in our example)
1 * subscriber.receive(_)              // any single argument (including null)
1 * subscriber.receive(*_)             // any argument list (including the empty argument list)
1 * subscriber.receive(!null)          // any non-null argument
1 * subscriber.receive(_ as String)    // any non-null argument that is-a String
1 * subscriber.receive(endsWith("lo")) // any non-null argument that is-a String
1 * subscriber.receive({ it.size() > 3 && it.contains('a') })
// an argument that satisfies the given predicate, meaning that
// code argument constraints need to return true of false
// depending on whether they match or not
// (here: message length is greater than 3 and contains the character a)

Stubing

Stubbing 是让协作者以某种方式响应方法调用的行为。在对方法进行存根化时,不关心该方法的调用次数,只是希望它在被调用时返回一些值,或者执行一些副作用。

subscriber.receive(_) >> "ok"
|          |       |     |
|          |       |     response generator
|          |       argument constraint
|          method constraint
target constraint

如:subscriber.receive(_) >> "ok" 意味,不管什么实例,什么参数,调用 receive 方法皆返回字符串 ok

返回固定值

使用 >> 操作符,返回固定值

subscriber.receive(_) >> "ok"

返回值序列

返回一个序列,迭代且依次返回指定值。如下所示,第一次调用返回 ok,第二次调用返回 error,以此类推

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

动态计算返回值

subscriber.receive(_) >> { args -> args[0].size() > 3 ? "ok" : "fail" }
subscriber.receive(_) >> { String message -> message.size() > 3 ? "ok" : "fail" }

产生副作用

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

链式响应

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

完整单测实例

@Title("招行业务类测试")
@Subject(CmbPayLocalServiceImpl)
class CmbPayLocalServiceImplTest extends Specification {

    CmbPayLocalServiceImpl cmbPayLocalService = new CmbPayLocalServiceImpl()
    ConfigBaseService configBaseService = Mock()
    WeChatPayHelper helper = Mock()
    CodeUrlMapLocalService codeUrlMapLocalService = Mock()

    void setup() {
        cmbPayLocalService.configBaseService = configBaseService
        cmbPayLocalService.weChatPayHelper = helper
        cmbPayLocalService.codeUrlMapLocalService = codeUrlMapLocalService
    }

    def "GetCodeUrl"() {
        given: "定义一些变量"
        def enterpriseNum = "AC310008"
        def mchId = "308999170120019"
        def fgTradeNo = System.currentTimeMillis().toString()
        def start = "20190813130000"
        def end = "20190815130000"

        def param = new WeChatQrCodePayParam(
                enterpriseNum: enterpriseNum,
                mchId: mchId,
                outTradeNo: System.currentTimeMillis().toString(),
                body: "Groovy 测试",
                totalFee: "1",
                notifyUrl: ""
        )

        def record = new TransRecord<>(
                enterpriseNum: enterpriseNum,
                mchId: mchId,
                fgTradeNo: fgTradeNo,
                state: "1"
        )

        configBaseService.getConfig(_) >> ""
        codeUrlMapLocalService.saveCodeUrlMapping(*_) >> true

        when:
        Map<String, Object> result = cmbPayLocalService.getCodeUrl(param, record)

        then:
        def url = result.get("url")
    }

    @Unroll
    def "GetCodeUrlWithWheretime"() {
        when:
        def enterpriseNum = "AC310008"
        def mchId = "308999170120019"
        def fgTradeNo = System.currentTimeMillis().toString()

        def param = new WeChatQrCodePayParam(
                enterpriseNum: enterpriseNum,
                mchId: mchId,
                outTradeNo: System.currentTimeMillis().toString(),
                body: "Groovy 测试",
                totalFee: "1",
                notifyUrl: ""
        )

        def record = new TransRecord<>(
                enterpriseNum: enterpriseNum,
                mchId: mchId,
                fgTradeNo: fgTradeNo,
                state: "1"
        )

        configBaseService.getConfig(_) >> "308999170120019"
        codeUrlMapLocalService.saveCodeUrlMapping(*_) >> true

        then:
        StringUtils.isNotBlank((String)cmbPayLocalService.getCodeUrl(param, record).get("CodeUrl"))

        where:
        start | end | url
        "20190813130000"  | "20190815130000"  | true
        "20190813130000"  | "20191015130000"  | true
        "20190813130000"  | "20191015130000"  | true
        "20190813130000"  | "20191015130000"  | true
    }
}

组件:Groovy

Groovy 是 Java 平台上设计的面向对象编程语言。这门动态语言拥有类似 Python、Ruby 和 Smalltalk 中的一些特性,可以作为Java平台的脚本语言使用。

Groovy 的语法与 Java 非常相似,以至于多数的 Java 代码也是正确的 Groovy 代码。Groovy 代码动态的被编译器转换成 Java 字节码。由于其运行在 JVM 上的特性,Groovy 可以使用其他 Java 语言编写的库。

  • 语句结束用的分号; 是可选的
  • 通过 def 关键字定义变量和方法
  • 通常用一对双引号表示一个字符串常量,"这是一个字符串常量",使用 \ 作为转义符
  • Groovy 将 0、null、空的数组或空字符串视为 false,非 0 值、有效的引用、非空的数组和非空的字符串则均视为 true
  • 使用关键字 def 表示动态类型,类似 JavaScript 中的 var
  • 类的访问修饰默认都是 public,而 field 默认是 private(但是可以用类实例来直接访问)
  • 使用 作为字符串插入符,必要时使用一对大括号包围插入值,比如 `return "name, ${age > 18}"`
  • 对象初始化方式类似 JSON,整个表达式用圆括号包围,field 与值以冒号间隔成对出现,数组或序列用中括号包围,数组索引从 0 开始,用[:]表示一个空的 Map
  • 使用 _作为参数占位符。它既可以用来指代参数、方法,也可以指代返回值或者 where 块中的测试参数
// 定义字符串
String str = ""
// 定义列表
List list = [1, 2, 3, 4, 5]
// 定义 map
Map map = [key1:val1, key2:val2]

融合付规范

必须遵守 AIR 原则

说明:单元测试在线上运行时,感觉像空气(AIR)一样并不存在,但在测试质量的保障上,却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。

- A:Automatic(自动化)

单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中不准使用 System.out 来进行人肉验证,必须使用 assert 来验证。

- I:Independent(独立性)

保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。 反例:method2需要依赖method1的执行,将执行结果作为method2的输入。

- R:Repeatable(可重复)

单元测试是可以重复执行的,不能受到外界环境的影响。 说明:单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。 正例:为了不受外界环境影响,要求设计代码时就把SUT的依赖改成注入,在测试时用spring 这样的DI框架注入一个本地(内存)实现或者Mock实现。

保证测试粒度足够小

对于单元测试,要保证测试粒度足够小,有助于精确定位问题。单测粒度至多是类级别,一般是方法级别。

说明:只有测试粒度小才能在出错时尽快定位到出错位置。单测不负责检查跨类或者跨系统的交互逻辑,那是集成测试的领域。

也有按照场景写单元测试用例的,一个方法对应多个场景。

参照:blog.csdn.net/flysqrlboy/…

核心业务、核心应用、核心模块的增量代码确保单测通过

说明:新增代码及时补充单元测试,如果新增代码影响了原有单元测试,请及时修正。

单测的基本目标

语句覆盖率达到 70%;核心模块的语句覆盖率和分支覆盖率都要达到 100%;

说明:在工程规约的应用分层中提到的DAO层,Manager层,可重用度高的Service,都应该进行单元测试。

单测代码遵守 BCDE 原则

以保证被测试模块的交付质量

- B:Border,边界值测试,包括循环、 特殊取,边界值测试包括循环、 特殊取特殊时间点、数据顺序等。

- C:Correct,正确的输入,并得到预期结果。 ,正确的输入并得到预期结果。

- D:Design,与设计文档相结合,来编写单元测试。 ,与设计文档相结合来编写单元测试。

- E:Error,强制错误信息输入(如:非法数据、异常流程业务允许等),并得 ,强制错误信息输入(如:非法数据、异常流程业务允许等),并得到预期结果。

与数据库交互规范

对于数据库相关的查询,更新,删除等操作,不能假设数据库里的数据是存在的,或者直接操作数据库把数据插入进去,请使用程序插入或者导入数据的方式来准备数据。

反例:删除某一行数据的单元测试,在数据库中,先直接手动增加一行作为删除目标,但是这一行新增数据并不符合业务插入规则,导致测试结果异常。

可以使用 h2 内存数据库保证单测不污染测试数据库

不可测的代码建议做必要的重构

对于不可测的代码建议做必要的重构,使代码变得可测,避免为了达到测试要求而书写不规范测试代码。

确定单测范围

开发人员需要和测试人员一起确定单元测试范围,单元测试最好覆盖所有测试用例(UC)。

心态转变

- 那是测试同学干的事情。
- 单元测试代码是多余的。 汽车的整体功能与各单元部件的测试正常否是强相关。
- 单元测试代码不需要维护。 一年半载后,那么几乎处于废弃状态。
- 单元测试与线上故障没有辩证关系。好的单元测试能最大限度规避线上故障。

参考资料