1. 简介

单元测试是软件开发中不可或缺的一环,它确保了代码的可靠性和正确性。当我们使用 Kotlin 编写测试时,Kotest 是一个非常流行的测试框架,它提供了丰富的功能,包括 beforeSpec()beforeEach()beforeInvocation() 这三种生命周期钩子函数。这些钩子允许我们分别在测试类开始前、每个测试方法前、以及每次测试调用前执行特定的初始化逻辑。

本文将带你一步步了解如何在 Kotest 中使用这些生命周期钩子函数,帮助你更好地组织测试逻辑和初始化操作。

2. 在整个测试类开始前运行代码:beforeSpec()

beforeSpec() 函数用于在整个测试类(Spec)运行前执行一次初始化逻辑。这对于准备测试环境、初始化共享资源非常有用。

使用方式如下:

class BeforeSpecSamples : FunSpec({

    val userRepository = UserRepository()

    beforeSpec {
        userRepository.addUser(User(1, "Admin"))
    }

    // 测试用例
})

在这个例子中,beforeSpec 块中的代码会在该测试类中的第一个测试用例执行前运行一次。需要注意的是,这个初始化操作在整个测试类中是共享的。

2.1. 隔离模式与 beforeSpec()

Kotest 提供了三种隔离模式:

  • SingleInstance(默认):整个测试类只创建一个实例,所有测试共享该实例。
  • InstancePerLeaf:每个测试叶子节点创建一个实例。
  • InstancePerTest:每个测试创建一个新实例。

如果使用非默认的隔离模式,beforeSpec() 可能会被多次调用。因此在选择隔离模式时需谨慎,避免重复执行初始化逻辑。

3. 每个测试用例前运行:beforeEach()

如果你希望在每个测试用例执行前都执行一段初始化代码,可以使用 *beforeEach()*。它非常适合用于重置状态、准备测试数据等场景。

示例代码如下:

class BeforeTestSamples : FunSpec({

    val userRepository = UserRepository()

    beforeEach {
        userRepository.addUser(User(1, "Admin"))
    }

    test("Accessing all users should include added user and Admin") {
        userRepository.addUser(User(2, "SimpleUser"))

        userRepository.all() shouldBe listOf(
            User(1, "Admin"),
            User(2, "SimpleUser")
        )
    }
})

beforeEach() 会在每个 test 块之前执行一次,非常适合用于每个测试都需要的初始化逻辑。

4. 每次测试调用前运行:beforeInvocation()

Kotest 支持一个测试方法运行多次(通过 invocations = N 设置)。如果你希望每次调用前都执行初始化操作,可以使用 *beforeInvocation()*。

实现方式是通过注册一个 BeforeInvocationListener

class BeforeInvocationSamples : FunSpec({

    val userRepository = UserRepository()

    isolationMode = IsolationMode.InstancePerTest

    extension(object : BeforeInvocationListener {
        override suspend fun beforeInvocation(testCase: TestCase, iteration: Int) {
            userRepository.addUser(User(iteration.toLong(), "Admin"))
        }
    })

    test("Accessing all users should include added user and Admin").config(invocations = 30) {
        userRepository.addUser(User(2, "SimpleUser"))

        userRepository.all().last() shouldBe User(2, "SimpleUser")
    }
})

⚠️ beforeInvocation() 每次调用都会执行,适合需要动态初始化的场景,例如根据调用次数生成不同数据。

5. 隔离模式详解

Kotest 默认使用 IsolationMode.InstancePerSpec,即每个测试类共享一个实例。如果你希望每个测试都独立运行,可以设置为 IsolationMode.InstancePerTest

class BeforeTestSamples : FunSpec({

    val userRepository = UserRepository()

    isolationMode = IsolationMode.InstancePerTest

    beforeEach {
        userRepository.addUser(User(1, "Admin"))
    }

    test("Accessing all users should include added user and Admin") {
        userRepository.addUser(User(2, "SimpleUser"))

        userRepository.all() shouldBe listOf(
            User(1, "Admin"),
            User(2, "SimpleUser")
        )
    }
})
隔离模式 说明
SingleInstance 整个测试类共享一个实例(默认)
InstancePerLeaf 每个测试叶子节点一个实例
InstancePerTest 每个测试方法一个实例

✅ 使用 InstancePerTest 模式可以确保测试之间的完全隔离,避免因共享状态导致的测试干扰。

⚠️ 但要注意:使用非默认隔离模式会增加实例创建的开销,可能会影响测试执行速度。

6. 总结

通过使用 Kotest 提供的以下生命周期钩子函数,我们可以灵活地控制测试环境的初始化逻辑:

钩子函数 执行时机
beforeSpec() 整个测试类运行前执行一次
beforeEach() 每个测试用例前执行一次
beforeInvocation() 每次测试调用前执行(支持多次运行)

合理使用这些钩子函数,可以提高测试的可维护性、独立性和可读性。它们不仅能帮助我们减少重复代码,还能确保测试环境的一致性。

如需查看本文完整示例代码,欢迎访问:GitHub 项目地址


原始标题:How to Run a Function Before Every Test Using Kotest