单元测试:优雅的Spock框架(下)

发表于:2022-7-07 09:45

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:骑象人    来源:今日头条

  4、Spock入门
  4.1、引入包
  <!-- 在 Spring boot 环境使用Spock 需要以下依赖-->
      <!-- Spring boot 自己的测试模块依赖-->
      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
      </dependency>
      <!-- 使用 Spock 必须的依赖:spock 的核心依赖-->
      <dependency>
        <groupId>org.spockframework</groupId>
        <artifactId>spock-core</artifactId>
        <version>1.2-groovy-2.4</version>
        <scope>test</scope>
      </dependency>
      <!-- 使用 Spock 必须的依赖:groovy 库-->
      <dependency>
        <groupId>org.codehaus.groovy</groupId>
        <artifactId>groovy-all</artifactId>
        <version>2.4.15</version>
      </dependency>
      <!-- spock 与 spring 环境的集成 -->
      <dependency>
        <groupId>org.spockframework</groupId>
        <artifactId>spock-spring</artifactId>
        <version>1.2-groovy-2.4</version>
        <scope>test</scope>
      </dependency>
  4.2、Specification(规范)
  使用Spock框架,每个测试类都需要直接或者间接继承自Specification
  /**
   * 使用Spock框架,每个测试类都需要直接或者间接继承自Specification。
   * Specification英文原意是:规格;规范;明细单;说明书
   * 可见Spock的测试类在命名语义上就显现出很强的规范约束。
   * 测试类中的内容,大体上是符合下面注释的结构(次序无关)
   * @author hudongkang
   * @date 2021-09-06 15:04
   */
  class MyFirstSpecification extends Specification {
    // 属性
    // 属性
    //...
    // 测试方法
    // 测试方法
    //...
    // 辅助方法
    // 辅助方法
    // ....
  }
  4.3、Fields(属性)
  和Java的全局属性的概念是一样的。对于测试场景,最好在声明属性时就进行初始化(对于没有加@Shared注解的属性或者不是静态属性,声明时进行初始化,从语义上讲,等同于在setup()方法中初始化它们。)默认时属性对象不会再各个测试方法之间共享,换句话说,每一个测试方法运行时,都引用着不同的实例(如果测试方法中有引用的话),这有助于将测试方法彼此隔离。
  但有的时候,需要在测试方法之间共享一个属性实例的话,可以在属性上添加@Shared注解,同样最好再属性声明时便初始化好(对于加有@Shared注解的属性,声明时初始化,从语义上来说,等同于在setupSpec()方法中初始化属性)
  4.4、Fixture Methods(固定方法)
  Fixture 方法的作用,是在测试方法执行前后,做一些数据准备和数据清理工作的。
  Fixture 方法在测试代码中不是必须的,而且有等效方案:
  ·对于没有加@Shared注解的属性或者不是静态属性,如果在声明时就进行初始化,从语义上讲,等同于在setup()方法中初始化它们。
  · 对于加有@Shared注解的属性或者是静态属性,如果在声明时就初始化,从语义上来说,等同于在setupSpec()方法中初始化属性
  但是如果初始化逻辑比较复杂,无法在声明属性时进行初始化,那么 Fixture 方法就显得很有用了。
  /**
       * 全局前置方法:在本类中所有测试方法执行之前执行
       * 只有加上 @Shared 或者静态属性可以在这个方法中引用
       * 如果存在子类覆盖父类的 setupSpec(),那么父类的先于子类的执行
       */
      def setupSpec() {
          //执行一些本类中所有测试方法共享的全局数据的初始化工作,比如共享属性数据或静态属性的初始化
          //执行本方法的时机:本类中所有测试方法执行之前,且一共执行一次
      }
      /**
       * 前置方法:在每一个测试方法之前都会执行
       * 如果存在子类覆盖父类的 setup(),那么父类的先于子类的执行
       *
       */
      def setup() {
          // 执行一些非共享数据的初始化工作,每个测试方法执行前都需要执行一次,
          // 执行的结果,提供给接下来即将执行的测试方法使用
      }
      /**
       * 后置方法:在每一个测试方法之后都会执行
       * 如果存在子类覆盖父类的 cleanup(),那么子类的先于父类的执行
       */
      def cleanup() {
          // 执行一些非共享数据的清理工作,每个测试方法执行过后都需要执行一次
          // 清理完必要的数据,防止污染到下一个测试方法,配合setup()方法,可以做到测试方法之间是完全相互隔离的
      }
      /**
       * 全局后置方法:在最后一个测试方法之后执行
       * 只有加上 @Shared 或者静态属性可以在这个方法中引用
       * 如果存在子类覆盖父类的 cleanupSpec(),那么子类的先于父类的执行
       */
      def cleanupSpec() {
          //执行一些本类中所有测试方法共享的全局数据的清理工作,比如共享属性数据或静态属性的初始化
          //执行本方法的时机:本类中所有测试方法执行完后,且一共执行一次
      }
  4.5、Feature Methods(特征方法)
  Spock官方命名直译过来,叫特性方法。其实就是我们平时写的测试方法(本文中统一叫测试方法)。
  /**
       * 测试方法的方法名可以是字符串,方便测试者为这个测试起一个更好理解的名字
       * 方法体中必须使用Spock提供的标签来约束一些代码行为,这也是Spock对测试代码规范化的提现
       */
      def "测试一个加法逻辑" (){
          given: "给定以下数据"
          def i = 5
          def j = 6
          when: "执行需要测试的逻辑"
          def s = studySpockService.sum(i,j)
          then: "预期结果"
          assert s == 11
      }
  测试方法是单元测试的核心,其他所有的特性都是为了能够写出更好的测试方法代码服务。从理论上讲,测试方法由四个阶段组成:
  1、环境准备:初始化测试数据和环境。
  2、调用执行:对要测试的某个单元进行调用触发。
  3、预期响应:描述系统预期的响应。
  4、清理重置:清理本次测试方法用到的数据和环境。
  第一阶段和最后一个阶段是可选的,但是中间两个阶段是始终存在的。(非隔离的具有交互的测试方法除外)
  4.6、Blocks(块)
  由于测试方法在Spock的理论设计中,被分为四个阶段,这也是符合单元测试的普遍逻辑的,但是如何约束所有的程序员来遵循这种规范呢?针对这个问题,Spock提供了内置的标签支持。每一个标签约束了一个所谓的代码块。对于测试方法开头到第一个标签之间,如果存在代码,则这部分代码属于一个隐式块。测试方法中至少要有一个显示标签(Spock如何识别一个方法是否是测试方法或者是别的什么方法,依据就是方法中是否存在Spock内置的标签)。每个标签可以添加一个字符串描述信息,方便对代码块的阅读。
  4.6.1、Given 标签
  given: "给定以下数据"
  def i = 5
  def j = 6
  这个块中可以做一些局部变量的设置工作,或者可以写其他任何代码,Given块并不会约束允许写什么不允许写什么,只是从规范的角度触发建议写“局部数据的初始化”工作。这个标签可以省略不写,但是一旦写了,Given 块之前不能有其他显式标签块(可以有隐式块),Given 块也不可以重复出现。在需要的时候一般是建议写上Given标签,这样会让代码显得更加易读。
  4.6.2、When 和 Then 标签
  when: "执行需要测试的逻辑"
  def s = studySpockService.sum(i,j)
  then: "预期结果"
  assert s == 11
  when: "执行需要测试的逻辑"
  def s = studySpockService.sum(i,j)
  then: "预期结果"
  assert s == 11
  When 和 Then 标签总是成对出现的,不可以只用When而不用Then或者只用Then而不用When;他们本别对应代表了测试方法中四阶段的中间两个阶段:调用执行和预期响应。
  一个测试方法中可以包含多对 When 和 Then 标签。
  When标签代码块可以编写任意代码,然而Then标签代码块只允许写:“Conditions(条件)”、“Exception Conditions(异常条件)”、“Interactions(交互)”和变量定义。
  ·Conditions(条件)
  条件就是一个可以产生布尔值的表达式,条件是描述预期的状态,很想JUnit中的断言,不同的是这里的条件不需要断言关键字。when: "执行一行代码" stack.push(elem) then: "预期执行后会发生什么,即条件(布尔表达式)" !stack.empty stack.size() == 1 stack.peek() == elem //上面可以写多个条件,单最好是5个以下,如果比较多的话,要考虑代码是否可以优化 //如果有多个条件的话,所有条件均为True时,测试才会认为通过
  · Exception Conditions(异常条件)
  异常条件用于处理调用执行环节的被测试方法返回是void类型的情况。这时可以在Then标签代码块中预期被测试方法是否会抛出异常,以及异常的种类,异常内部的消息等。
  when: "空栈做弹出测试"
  stack.pop()
  then: "预期会抛出异常,且栈为空"
  thrown(EmptyStackException)
  stack.empty
  when: "空栈做弹出测试"
  stack.pop()
  then: "预期会抛出EmptyStackException异常,且异常中的属性cause == null"
  def e = thrown(EmptyStackException)
  e.cause == null
  when: "空栈做弹出测试"
  stack.pop()
  then: "预期会抛出EmptyStackException异常,且异常中的属性cause == null"
  EmptyStackException e = thrown()//和上面的是两种写法,作用相同
  e.cause == null 
  如果预期不会抛出任何异常呢,比如往Map中放一个Key为null的数据
  def "HashMap accepts null key"() {
    given: "给定一个Map"
    def map = new HashMap()
    when: "当执行往Map中放一个Key为null的数据时"
    map.put(null, "elem")
    then: "预期结果不会抛出NullPointerException异常"
    notThrown(NullPointerException)
  ·Interactions(交互)
  有这样一种场景:假如系统中有一个以观察者模式写的代码,想要测试:事件多播器是否成功的广播事件,以及监听者是否接收到事件以及接收事件到的次数。
  def "events are published to all subscribers"() {
    given: "初始化消费者和生产者"
    def listener1 = Mock(Listener)
    def listener2 = Mock(Listener)
    def multicaster = new EventMulticaster()
    multicaster.add(listener1)
    multicaster.add(listener2)
    when:
    multicaster.fire("event")
    then:
    1 * listener1.receive("event")
    1 * listener2.receive("event")
  }
  4.6.3、Expect 标签
  有些情况下,When 和 Then 标签会显得比较啰嗦,比如:
  when: "执行取最大逻辑"
  def x = Math.max(1, 2)
  then: "预期最大值为2"
  x == 2
  对于上面的情况,Expect 标签会显得更加自然:
  expect: "期望1和2的最大值为2"
  Math.max(1, 2) == 2
  大部分情况,Expect 标签是简化后的When 和 Then 标签,且Expect 标签适合简单的场景,When 和 Then 标签更适合相对复杂的场景。
  特例:Expect 标签块中不允许有交互条件。有交互条件的时候,只能用When 和 Then 标签。
  4.6.4、Cleanup 标签块
  ·cleanup标签不可以再一个测试方法中重复出现
  · cleanup标签的后面如果有其他标签,只允许跟where标签块
  · cleanup标签中的代码一定会执行,类似finally代码块的作用。主要用来主动释放一些测试方法中用到的资源
  总结助手:
  cleanup标签和given标签,只是解决测试方法内部的数据资源问题,不影响其他测试方法;
  cleanup() 方法和setup() 方法,是解决所有测试方法共同使用到的数据资源问题,对于所有测试方法或者大多数测试方法会用到的数据初始化和清理工作,可以放在cleanup() 方法和setup() 方法中,提升代码复用率。
  4.6.5、Where 标签块
  where标签块总是在一个测试方法的最后一个出现,并且不允许重复出现。where标签块用于编写数据驱动的测试方法,举例来说:
  def "在两个数之间比较出最大的数"() {
    expect: "执行逻辑,并期望条件成立"
    Math.max(a, b) == c
    where: "分两次测试,第一次a=5,b=1,c=5;第二次a=3,b=9,c=9"
    a << [5, 3]
    b << [1, 9]
    c << [5, 9]
  }
  //where块中的形式也可以是下面这样更加易读的格式
  def "在两个数之间比较出最大的数"() {
    expect: "执行逻辑,并期望条件成立"
    Math.max(a, b) == c
    where: "分两次测试,第一次a=5,b=1,c=5;第二次a=3,b=9,c=9"
    a | b | c
    5 | 1 | 5
    3 | 9 | 9
  }
  上面的例子中,where标签块的作用,是将同一个测试分成不同测试用例版本,依次使用不同的输入输出数据进行测试。
  4.7、辅助方法
  辅助方法就是一个普通的方法,可以用来分解比较复杂的测试代码。当测试代码很多而且复杂时,可以抽离出一些代码封装到辅助方法中,然后在测试方法中调用辅助方法。
  辅助方法的用途之一是封装 "期望Conditions(条件)" ,可以用一个例子来说明:
  def "检验一个电脑的配置"() {
    when: "执行购买电脑方法,返回一个电脑实例"
    def pc = shop.buyPc()
    then: "期望条件"
    pc.vendor == "Sunny"
    pc.clockRate >= 2333
    pc.ram >= 4096
    pc.os == "Linux"
  }
  这个例子的代码是没有问题的,但是在期望部分,对PC实例的多个属性进行了条件断言,辅助方法可以将这些期望统一封装到一个地方:
  def "检验一个电脑的配置"() {
    when: "执行购买电脑方法,返回一个电脑实例"
    def pc = shop.buyPc()
    then: "期望条件"
    matchesPreferredConfiguration(pc)
  }
  /**
   * 将多个并列的断言统一放进一个“辅助方法”中
   * Groovy中方法中的return语句是可以省略的
   * 可以看到,辅助方法中多个期望改写成了一个 大的布尔表达式,并将最终的布尔结果返回
   */
  def matchesPreferredConfiguration(pc) {
    pc.vendor == "Sunny"
    && pc.clockRate >= 2333
    && pc.ram >= 4096
    && pc.os == "Linux"
  }
  //执行测试后,可能的运行结果:
  matchesPreferredConfiguration(pc)
  |                             |
  false                         ...
  //上面的测试结果,虽然没有符合预期,但是具体却不知道哪里(哪一条具体的期望条件)出了问题。
  //其实辅助方法在用于封装条件断言时,可以改进一下:
  //1、将辅助方法的返回类型改为void
  //2、将隐式条件,改为显示条件
  //(加上assert关键字的条件就是显示条件,默认不加assert关键字的条件是隐式条件)
  void matchesPreferredConfiguration(pc) {
    assert pc.vendor == "Sunny"
    assert pc.clockRate >= 2333
    assert pc.ram >= 4096
    assert pc.os == "Linux"
  }
  //在辅助方法中,将隐式条件改为显示条件的好处,是可以更清晰具体的反应测试结果:
  assert pc.clockRate >= 2333
         |  |         |
         |  1666      false
         ...
  4.8、使用 with() 方法来封装期望
  上面提到可以使用辅助方法来封装期望,期望Spock也内置了期望的封装方法:with()
  还是上面的例子,可以写成:
  def "检验一个电脑的配置"() {
    when: "执行购买电脑方法,返回一个电脑实例"
    def pc = shop.buyPc()
    
    then: "期望条件"
    with(pc) {
      vendor == "Sunny"
      clockRate >= 2333
      ram >= 406
      os == "Linux"
    }
  }
  with()方法中的第一个条件不成立时,整个测试认为不通过,并结束后面条件的检验,话句话说,测试结果中,只罗列第一个失败的条件信息,那么如果有多个条件失败,想一次性知道全部失败的条件的信息怎么办,Spock提供了一种软断言的方式。
  4.9、软断言:verifyAll()
  还是上面的例子,使用软断言:
  def "检验一个电脑的配置"() {
    when: "执行购买电脑方法,返回一个电脑实例"
    def pc = shop.buyPc()
    then: "期望条件"
    verifyAll(pc) {
      vendor == "Sunny"
      clockRate >= 2333
      ram >= 406
      os == "Linux"
    }
  }
  这样测试结果中回显示出所有失败的条件信息。
  4.10、关于数据驱动
  上面将where标签块的时候,提到过一次数据驱动。
  数据驱动测试,是通过where标签块中维护一个表格形式的数据块,官方成为Data Tables(数据表格),迭代这个数据表格,依次重复的执行该测试方法,来达到测试不同用例的目的。
  def "测试示例"() {
          expect:
          Math.max(a, b) == c
          where:
          a | b | c
          1 | 3 | 3
          7 | 4 | 6
          0 | 0 | 0
  }
  数据表格中的第一行是表头,表头的每一个元素相当于一个数据变量的声明,可以在测试方法中进行引用。这里的a、b、c就是三个变量声明。从第二行开始的每一行数据对应这测试方法的一次执行迭代,直到迭代完所有的行,最终测试结果中会显示所有迭代失败的结果信息。
  每一次迭代之间的数据是相互隔离的,除非测试方法中引用了静态属性或者@Shared标记的属性。
  where块中,只能访问静态属性或者@Shared标记的属性,一旦有这种引用,也意味着where块中引入了被其他测试方法共享的属性数据。目前Spock还做不到:只在一个测试方法的迭代之间共享数据而不和其他测试方法共享数据。换句话说,多次迭代的同一个测试方法,可以看成是一些列代码相同但数据不同的独立测试方法。
  关于数据表格的表现形式,至少要有两个列,如果只用到一个列的话,可以这样写:
  where:
  a | _
  1 | _
  7 | _
  0 | _
  还有时候,为了方便的分辨出哪一列是输入,哪一列是期望的输出,还可以将表格这样写:
  def "测试示例"() {
          expect:
          Math.max(a, b) == c
          where: "两个|线右边的列,是期望的输出"
          a | b || c
          1 | 3 || 3
          7 | 4 || 6
          0 | 0 || 0
  }
  4.11、Reporting of Failures (故障报告)
  def "测试示例"() {
          expect:
          Math.max(a, b) == c
          where:
          a | b || c
          1 | 3 || 3
          7 | 4 || 6
          0 | 0 || 0
      }
  //一下是这次测试结果的故障报告:
  Condition not satisfied:
  Math.max(a, b) == c
           |        |   |    |    |
           7      7  4    |    6
                           false
  4.12、Mock(模拟)
  单元测试的含义在于测试一个单元,测试仅限于某个方法内部的代码。如果这写代码中依赖了外部的服务,或者其他人还未写好的接口,我们不能保证外部服务的可用性,也同样不能等另一个人写好接口后再测试,这是不现实的。Mock就是来解决这类问题的,相当于模拟被调用的外部对象的行为和数据,从而方便单元测试的顺利进行。
  //Mock模拟一个库存接口实例,两种写法
  def stockService = Mock(StockService)
  StockService stockService = Mock()
  //匹配调用,并模拟返回数据
  stockService.getStockNumb("abc") >> 100
  //如果被测试的方法中,stockService.getStockNumb("abc")被调用了多次,可以这样写
  //这表示,第一次调用会返回100,第二次调用会返回95
  stockService.getStockNumb("abc") >> 100 >> 95
  //或者这样写
  stockService.getStockNumb("abc") >>> [100, 95]
  //如果有第三次调用,且模拟第三次调用时抛出了异常。可以这样写,
  stockService.getStockNumb("abc") >>> [100, 95] >> { throw new Exception() }
  //有的时候,模拟对象的方法没有返回值,只关心被调用的次数
  //那么可以用作期望的条件
  then: "期望stockService.updateStock(sku,95)被调用了一次"
  1 * stockService.updateStock(sku,95)
  //有时候需要模拟当外部依赖发生异常时,被测试单元的反应是否符合预期,
  //需要在调用外部依赖时故意制造一些异常
  stockService.updateStock(sku,95) >> { throw new Exception() }
  4.13、测试 Controller 中的 http api
  /**
   * 描述:Spock 在 Spring Boog 环境下的测试
   *
   * @author hudongkang
   * @date 2021-09-07 18:28
   */
  //指定要测试的Controller
  //如果不写controllers属性,会尝试将所有扫描到的Controller都加载到Ioc容器中
  @WebMvcTest(controllers = OrderController.class)
  //激活测试配置
  @ActiveProfiles("mvc")
  //指定启动类,只在单元测试中使用的SpringBoot启动类
  @ContextConfiguration(classes = MvcUnitTestApplication.class)
  class OrderControllerTest extends Specification {
      /**
       * Spring官方提供
       * 用于测试 Contoller API
       * 无需手动注册Bean,会自动注入进来
       */
      @Autowired
      MockMvc mvc
      /**
       * 这是一个模拟对象实例的引用
       */
      @Autowired
      OrderService orderService
      
      def "测试一个简单的 Http API" () {
          expect: "controller是否可用"
          MvcResult mvcResult = mvc.perform(
                  //构造请求
                  MockMvcRequestBuilders.get("/spock/hello")
                  //添加参数
                  .param("name","张三")
          )
                  //期望HTTP调用结果状态为200
                  .andExpect(MockMvcResultMatchers.status().isOk())
                  //打印格式化的结果报告,方便控制台查看
                  .andDo(MockMvcResultHandlers.print())
                  .andReturn()
          //打印返回的数据
          println(mvcResult.response.getContentAsString())
      }
      
      def "测试一个 Http API" () {
          given: ""
          orderService.createOrder(_,_) >> 95
          expect: "controller是否可用"
          MvcResult mvcResult = mvc.perform(
                  //构造请求
                  MockMvcRequestBuilders.get("/spock/getOrderNumb")
                          //添加参数
                          .param("sku","abc")
                          .param("goodsNumb", "5")
          )
                  //期望HTTP调用结果状态为200
                  .andExpect(MockMvcResultMatchers.status().isOk())
                  //打印格式化的结果报告,方便控制台查看
                  .andDo(MockMvcResultHandlers.print())
                  .andReturn()
          //打印返回的数据
          println(mvcResult.response.getContentAsString())
          "{\"code\":\"ok\",\"msg\":null,\"result\":95}" == mvcResult.response.getContentAsString()
      }
      
      /**
       * 提供测试环境所需的配置
       */
      @TestConfiguration
      static class MockConfig {
          /**
           * 该工厂允许在Specification之外创建模拟,例如在 Spring 配置中。
           */
          def detachedMockFactory = new DetachedMockFactory()
          @Bean
          OrderService OrderService() {
              return detachedMockFactory.Mock(OrderService)
          }
      }
  }
  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系51Testing小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号