1. 引言

本文将介绍 Spock 框架——一个基于 Groovy 的测试框架。Spock 的核心目标是利用 Groovy 的语言特性,成为传统 JUnit 测试栈的强力替代方案。

Groovy 是运行在 JVM 上的语言,能与 Java 无缝集成。除了互操作性,它还提供了动态类型、可选类型和元编程等额外语言特性。

通过 Groovy 的加持,Spock 为 Java 应用测试引入了全新的表达方式,这些在普通 Java 代码中难以实现。本文将通过实际案例探索 Spock 的核心概念。

2. Maven 依赖

开始前,先添加 Maven 依赖

<properties>
    <spock-core.version>2.4-M1-groovy-4.0</spock-core.version>
    <groovy-all.version>4.0.16</groovy-all.version>
    <gmavenplus-plugin.version>3.0.2</gmavenplus-plugin.version>
</properties>

<dependencies>
    <dependency>
        <groupId>org.spockframework</groupId>
        <artifactId>spock-core</artifactId>
        <version>${spock-core.version}</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.apache.groovy</groupId>
        <artifactId>groovy-all</artifactId>
        <version>${groovy-all.version}</version>
        <type>pom</type>
    </dependency>
</dependencies>

⚠️ 注意:我们仅将 Spock 和 Groovy 用于测试,因此依赖范围设为 test
✅ 需要额外配置 gmavenplus 插件编译 Groovy 代码,并调整 surefire 插件识别 Groovy 测试文件:

<build>
    <plugins>
        <plugin>
            <groupId>org.codehaus.gmavenplus</groupId>
            <artifactId>gmavenplus-plugin</artifactId>
            <version>${gmavenplus-plugin.version}</version>
            <executions>
                <execution>
                    <goals>
                        <goal>compile</goal>
                        <goal>compileTests</goal>
                    </goals>
                </execution>
            </executions>
            <configuration>
                <testSources>
                    <testSource>
                        <directory>${project.basedir}/src/test/groovy</directory>
                        <includes>
                            <include>**/*.groovy</include>
                        </includes>
                    </testSource>
                </testSources>
            </configuration>
        </plugin>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.2.2</version>
            <configuration>
                <testSourceDirectory>src/test/groovy</testSourceDirectory>
                <includes>
                    <include>**/*Specification.groovy</include>
                    <include>**/*Test.groovy</include>
                </includes>
            </configuration>
        </plugin>
    </plugins>
</build>

现在可以编写第一个 Spock 测试了(Groovy 代码)。记住:测试文件需放在 src/test/groovy 目录而非 src/test/java

3. Spock 测试结构

3.1 规约(Specifications)与特性(Features)

src/test/groovy 目录创建第一个测试文件 FirstSpecification.groovy

class FirstSpecification extends Specification {

}

✅ 所有 Spock 测试类必须继承 Specification 接口。现在实现第一个特性(即测试方法):

def "one plus one should equal two"() {
  expect:
  1 + 1 == 2
}

关键点解析:

  1. 方法名:直接用字符串描述(比 JUnit 的驼峰命名更直观)
  2. 代码块:测试逻辑放在 expect 块中(后续详解)
  3. 隐式断言:无需显式调用断言方法,表达式结果为 true 即通过

⚠️ 在 Spock 中,特性(feature) 等同于 JUnit 的测试用例(test case)。

3.2 代码块(Blocks)

JUnit 中常通过注释划分测试阶段(如 given/when/then),而 Spock 提供原生支持:

@Test
public void givenTwoAndTwo_whenAdding_thenResultIsFour() {
   // Given
   int first = 2;
   int second = 2;

   // When
   int result = first + second;

   // Then
   assertTrue(result == 4)
}

Spock 的代码块标签: | 块名 | 作用 | |------------|----------------------------------------------------------------------| | setup/given | 初始化测试环境(隐式块,未指定块时默认加入此块) | | when | 触发被测方法(提供刺激) | | then | 验证结果(断言) | | expect | 合并 whenthen(适用于简单场景) | | cleanup | 清理资源(如删除临时文件/数据库数据) |

用代码块重构测试:

def "two plus two should equal four"() {
    given:
        int left = 2
        int right = 2

    when:
        int result = left + right

    then:
        result == 4
}

✅ 代码块让测试逻辑更清晰易读。

3.3 利用 Groovy 特性增强断言

thenexpect 块中,断言是隐式的——每个表达式都会被求值,结果为 false 时测试失败。结合 Groovy 特性可大幅简化代码:

def "Should be able to remove from list"() {
    given:
        def list = [1, 2, 3, 4]  // Groovy 简化列表创建

    when:
        list.remove(0)

    then:
        list == [2, 3, 4]  // 操作符重载调用 equals() 方法
}

❌ 故意触发失败的错误信息示例:

Condition not satisfied:

list == [1, 3, 4]
|    |
|    false
[2, 3, 4]
 <Click to see difference>

at FirstSpecification.Should be able to remove from list(FirstSpecification.groovy:30)

✅ Spock 会智能分析失败断言,提供详细调试信息。

分组断言(Same Target Object)

Spock 2.0+ 支持用 with 块对同一对象分组断言(灵感来自 Groovy 的 Object.with):

class ShoppingCartTest extends Specification {
    def "verify multiple properties of a ShoppingCart"() {
        given:
        ShoppingCart cart = new ShoppingCart()
        cart.addItem("Apple", 3)
        cart.addItem("Banana", 2)

        expect:
        with(cart) {  // 所有断言针对 cart 对象
            totalItems == 5
            totalPrice == 10.00
            items.contains("Apple")
            items.contains("Banana")
        }
    }
}

3.4 异常断言

Spock 提供原生异常验证,无需 try-catch 或第三方库:

def "Should get an index out of bounds when removing a non-existent item"() {
    given:
        def list = [1, 2, 3, 4]
 
    when:
        list.remove(20)  // 触发异常

    then:
        thrown(IndexOutOfBoundsException)  // 验证异常类型
        list.size() == 4  // 测试继续执行
}

thrown() 会验证异常类型但不会中断测试。

4. 数据驱动测试

4.1 什么是数据驱动测试?

数据驱动测试:用不同参数重复测试同一行为(如数学运算)。在 Java 中通常称为参数化测试(parameterized testing)。

4.2 JUnit 参数化测试实现

先看 JUnit 的传统实现(对比用):

@RunWith(Parameterized.class)
public class FibonacciTest {
    @Parameters
    public static Collection<Object[]> data() {
        return Arrays.asList(new Object[][] {     
          { 1, 1 }, { 2, 4 }, { 3, 9 }  
        });
    }

    private int input;
    private int expected;

    public FibonacciTest (int input, int expected) {
        this.input = input;
        this.expected = expected;
    }

    @Test
    public void test() {
        assertEquals(fExpected, Math.pow(3, 2));  // 注意:此处代码有误,仅为示例
    }
}

❌ 缺点:代码冗余,可读性差,需创建二维数组和包装类。

4.3 Spock 数据表(Datatables)

Spock 用 数据表 简化参数化测试:

def "numbers to the power of two"(int a, int b, int c) {
  expect:
      Math.pow(a, b) == c

  where:
      a | b | c
      1 | 2 | 1
      2 | 2 | 4
      3 | 2 | 9
}

✅ 优势:

  • 参数表与测试逻辑紧邻
  • 无需样板代码
  • 方法名和块结构清晰表达意图

4.4 数据表失败场景

当测试失败时,Spock 会精确定位问题数据行:

Condition not satisfied:

Math.pow(a, b) == c
     |   |  |  |  |
     4.0 2  2  |  1
               false

Expected :1
Actual   :4.0

5. Mocking

5.1 什么是 Mocking?

Mocking:替换被测系统依赖类的行为(如将网络调用替换为模拟对象)。核心目标是隔离业务逻辑测试。

5.2 Spock Mocking

Spock 内置 Mock 框架,利用 Groovy 动态特性简化操作:

PaymentGateway paymentGateway = Mock()  // 类型推断
// 或显式指定类型
def paymentGateway = Mock(PaymentGateway)

✅ 默认采用 宽松 Mock(lenient mocking):未定义的方法返回合理默认值(而非抛异常),使测试更健壮。

when:
    def result = paymentGateway.makePayment(12.99)

then:
    result == false  // 默认返回 false

5.3 方法桩(Stubbing)

配置 Mock 方法对特定参数的响应:

given:
    paymentGateway.makePayment(20) >> true  // 参数 20 返回 true

when:
    def result = paymentGateway.makePayment(20)

then:
    result == true

高级桩示例: | 语法 | 作用 | |-----------------------|--------------------------| | makePayment(_) >> true | 忽略参数,始终返回 true | | makePayment(_) >>> [true, true, false] | 按顺序返回列表中的值 |

5.4 改进的 Mock 声明(Spock 2.0+)

可在创建 Mock 时直接定义交互,提升可读性:

class BookServiceTest extends Specification {
    def "should retrieve book details and verify method calls"() {
        given:
        // 创建 Mock 并定义行为
        def bookRepository = Mock(BookRepository) {
            findById(1L) >> new Book("Effective Java", "Joshua Bloch")
            findById(2L) >> null
        }
        def bookService = new BookService(bookRepository)

        when:
        Book effectiveJava = bookService.getBookDetails(1L)
        Book unknownBook = bookService.getBookDetails(2L)

        then:
        1 * bookRepository.findById(1L)  // 验证调用次数
        1 * bookRepository.findById(2L)
        effectiveJava.title == "Effective Java"
        unknownBook == null
    }
}

5.5 验证(Verification)

验证 Mock 方法是否按预期调用(尤其对返回 void 的方法):

def "Should verify notify was called"() {
    given:
        def notifier = Mock(Notifier)

    when:
        notifier.notify('foo')

    then:
        1 * notifier.notify('foo')  // 验证调用次数
}

❌ 故意设置错误期望(调用 2 次)的错误信息:

Too few invocations for:
2 * notifier.notify('foo')   (1 invocation)

灵活验证语法: | 语法 | 作用 | |--------------------------|-------------------------------| | 2 * notifier.notify(_) | 忽略参数,验证调用 2 次 | | 2 * notifier.notify(!'foo') | 验证参数不为 'foo' 的调用 |

5.6 全局 Mock(Global Mocks)

Spock 2.0+ 支持全局替换类实例,便于测试无法注入 Mock 的场景:

public class UtilityClass {
    public static String getMessage() {
        return "Original Message";
    }
}

public class MessageService {
    public String fetchMessage() {
        return UtilityClass.getMessage();  // 直接调用静态方法
    }
}

测试代码:

class MessageServiceTest extends Specification {
    def "should use global mock for UtilityClass"() {
        given:
        // 创建全局 Mock
        def utilityMock = GroovySpy(UtilityClass, global: true)
        utilityMock.getMessage() >> "Mocked Message"

        when:
        MessageService service = new MessageService()
        String message = service.fetchMessage()

        then:
        1 * utilityMock.getMessage()  // 验证调用
        message == "Mocked Message"  // 验证结果
    }
}

✅ 无需修改被测代码即可替换依赖行为。

6. 总结

本文快速介绍了 Spock 测试框架的核心能力:

  • ✅ 利用 Groovy 特性编写更表达性强的测试
  • ✅ 通过规约(Specifications)和特性(Features)组织测试
  • ✅ 简单粗暴的数据驱动测试(数据表)
  • ✅ 原生支持 Mocking 和断言

完整示例代码可在 GitHub 获取(Maven 项目可直接运行)。


原始标题:Introduction to Testing with Spock and Groovy