1. 简介

规范测试(Specification Testing)框架是对单元测试框架的重要补充,能更有效地验证应用行为。

本文将介绍 Spek 框架 —— 一个专为 JVM 设计、支持 Java 和 Kotlin 的规范测试框架。它特别适合在 Kotlin 项目中替代或补充 JUnit,提升测试代码的可读性和表达力。

如果你已经厌倦了 testWhenXThenY 这类命名风格,希望测试代码像文档一样清晰,那 Spek 值得一试。


2. 什么是规范测试?

简单来说,规范测试关注的是“系统应该做什么”,而不是“代码怎么实现”。

它强调以业务需求或功能规格为出发点来组织测试,这与 行为驱动开发(BDD) 的理念高度契合。通过贴近自然语言的结构,让开发、测试甚至产品都能理解测试用例的意图。

常见的规范测试框架包括:

  • Spock ✅ Groovy 生态标杆
  • Cucumber ✅ 支持 Gherkin 自然语言
  • Jasmine ✅ JavaScript 主流选择
  • RSpec ✅ Ruby 社区事实标准

这类框架的核心价值在于:✅ 提升可读性 ❌ 不是用来替代单元测试,而是更高层次的抽象。


2.1 什么是 Spek?

Spek 是基于 Kotlin 的 JVM 规范测试框架,设计为 JUnit 5 Test Engine 的插件形式运行。这意味着:

  • 可无缝集成到已有 JUnit 5 项目中
  • 能与其他测试(如 JUnit 测试类)共存并统一执行
  • 完全利用 JUnit Platform 的强大生态(IDE 支持、报告生成等)

⚠️ 注意:虽然也支持 JUnit 4(通过 junit-platform-runner),但建议新项目直接使用 JUnit 5。


2.2 Maven 依赖配置

要使用 Spek,需添加以下两个核心依赖(版本必须一致):

<dependency>
    <groupId>org.jetbrains.spek</groupId>
    <artifactId>spek-api</artifactId>
    <version>1.1.5</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.jetbrains.spek</groupId>
    <artifactId>spek-junit-platform-engine</artifactId>
    <version>1.1.5</version>
    <scope>test</scope>
</dependency>

其中:

  • spek-api:提供测试 DSL 和核心 API
  • spek-junit-platform-engine:作为 JUnit 5 引擎执行测试

📌 最新版本请参考 Maven Central


2.3 第一个测试示例

Spek 测试类需继承自 Spek 并传入一个 lambda 构造块:

class FirstSpec : Spek({
    // 在这里编写测试逻辑
})

这个结构初看有点怪异,但正是这种 DSL 风格让测试更具可读性。接下来我们看看具体怎么写。


3. 测试风格(Test Styles)

Spek 的核心优势之一是支持多种 DSL 风格,让你用最自然的方式描述测试逻辑。


3.1 given/on/it 风格

这是最推荐的 BDD 风格,结构清晰,语义明确:

关键字 含义
given 准备测试上下文(前置条件)
on 执行操作(触发行为)
it 断言结果(期望输出)

示例:

class CalculatorTest : Spek({
    given("A calculator") {
        val calculator = Calculator()
        on("Adding 3 and 5") {
            val result = calculator.add(3, 5)
            it("Produces 8") {
                assertEquals(8, result)
            }
        }
    }
})

✅ 可读性极强:Given a calculator, on adding 3 and 5, it produces 8.

⚠️ 注意嵌套顺序必须是 given → on → it,否则逻辑会混乱。


3.2 describe/it 风格

另一种常见风格,更接近 RSpec 或 Jasmine:

class CalculatorTest : Spek({
    describe("A calculator") {
        val calculator = Calculator()
        describe("Addition") {
            val result = calculator.add(3, 5)
            it("Produces the correct answer") {
                assertEquals(8, result)
            }
        }
    }
})

优点:

  • 更灵活,describe 可无限嵌套
  • 适合复杂场景的分层描述

缺点:

  • 不如 given/on/it 语义明确
  • 容易写出意义模糊的 describe("xxx")

📌 建议团队统一风格,避免混用造成维护困难。


3.3 其他可用关键字

Spek 实际上提供了更多语义化关键字供自由组合:

  • given
  • on
  • describe
  • context(等价于 given,用于区分不同上下文)

例如:

class AuthTest : Spek({
    context("User is logged in") {
        given("has admin role") {
            on("accessing dashboard") {
                it("should be allowed") { /* ... */ }
            }
        }
    }
})

✅ 核心规则不变:所有断言必须放在 it 块内,且不能在顶层直接写 it


3.4 数据驱动测试

由于 Spek 的 DSL 本质是 Kotlin 函数调用,因此可以轻松实现数据驱动测试。

常用方式:遍历数据集合,动态生成测试用例。

class DataDrivenTest : Spek({
    describe("A data driven test") {
        mapOf(
          "hello" to "HELLO",
          "world" to "WORLD"
        ).forEach { input, expected ->
            describe("Capitalising $input") {
                it("Correctly returns $expected") {
                    assertEquals(expected, input.toUpperCase())
                }
            }
        }
    }
})

✅ 这种写法避免了重复代码,同时保持高可读性。
⚠️ 注意:每个 it 块会被视为独立测试,所以 IDE 和报告中会显示多个测试项。


4. 断言(Assertions)

Spek 本身不绑定任何断言库,你可以自由选择最顺手的工具。

推荐选项:

特点
org.junit.jupiter.api.Assertions 默认选择,熟悉度高
Kluent Kotlin 友好,链式语法
Expekt 简洁流畅
HamKrest 匹配器模式,适合复杂断言

示例对比

使用 JUnit 原生断言:

assertEquals(8, result)

使用 Kluent 后更像自然语言:

result shouldEqual 8

✅ 强烈建议搭配 Kluent 或类似库,显著提升测试可读性,减少认知负担。


5. 前置与后置处理器(Before/After Handlers)

和大多数测试框架一样,Spek 支持生命周期钩子:

钩子 触发时机
beforeGroup 进入当前组前执行一次
afterGroup 离开当前组后执行一次
beforeEachTest 每个 it 前执行
afterEachTest 每个 it 后执行

这些块可以嵌套在任意层级,作用域遵循“就近原则”。

执行顺序示例

class GroupTest5 : Spek({
    describe("Outer group") {
        beforeEachTest {
            println("BeforeEachTest 0")
        }
        beforeGroup {
            println("BeforeGroup 0")
        }
        afterEachTest {
            println("AfterEachTest 0")
        }
        afterGroup {
            println("AfterGroup 0")
        }
        
        describe("Inner group 1") {
            beforeEachTest {
                println("BeforeEachTest 1")
            }
            beforeGroup {
                println("BeforeGroup 1")
            }
            afterEachTest {
                println("AfterEachTest 1")
            }
            afterGroup {
                println("AfterGroup 1")
            }
            it("Test 1") {
                println("Test 1")
            }
        }
    }
})

输出结果:

BeforeGroup 0
BeforeGroup 1
BeforeEachTest 0
BeforeEachTest 1
Test 1
AfterEachTest 1
AfterEachTest 0
AfterGroup 1
AfterGroup 0

📌 关键规律:

  • beforeGroup 全部先于 beforeEachTest 执行(外层 → 内层)
  • afterEachTest 先于 afterGroup 执行(内层 → 外层)
  • 类似栈结构,先进后出

踩坑提醒:不要在 beforeEachTest 中做耗时操作,会影响所有 it 的执行效率。


6. 测试主体(Test Subjects)

当整个 Spec 只针对单一被测对象时,可以用 SubjectSpek 简化管理。

通过 subject { } 块声明被测实例,在后续测试中用 subject 直接引用。

使用示例

class CalculatorTest : SubjectSpek<Calculator>({
    subject { Calculator() }
    describe("A calculator") {
        describe("Addition") {
            val result = subject.add(3, 5)
            it("Produces the correct answer") {
                assertEquals(8, result)
            }
        }
    }
})

✅ 优势:

  • 避免重复创建对象
  • 明确表达“这是本次测试的核心对象”
  • 提升可读性,尤其在大型测试套件中

6.1 启用 Subject 扩展的 Maven 依赖

需要额外引入扩展包:

<dependency>
    <groupId>org.jetbrains.spek</groupId>
    <artifactId>spek-subject-extension</artifactId>
    <version>1.1.5</version>
    <scope>test</scope>
</dependency>

⚠️ 注意版本必须与其他 Spek 依赖保持一致,否则运行时报错。


7. 总结

Spek 是一个强大的 Kotlin 测试框架,具备以下亮点:

高度可读的 DSL:让测试代码像文档一样清晰
灵活的测试风格:支持 given/on/itdescribe/it 等多种组织方式
无缝集成 JUnit 5:可与现有测试共存,无需改造构建流程
支持数据驱动和生命周期控制:满足复杂测试场景
配合 Kluent 等库实现极致表达力

📌 适用场景:

  • Kotlin 项目为主
  • 团队推行 BDD / 测试即文档
  • 希望提升测试可维护性和协作效率

虽然学习成本略高于传统 JUnit,但长期来看,其带来的可读性和维护性收益远超投入。值得一试!


原始标题:Writing Specifications with Kotlin and Spek