| name | 03-unit-testing |
| description | 单元测试编写指南,涵盖 JUnit5/MockK 使用、测试命名规范、Mock 技巧、测试覆盖率要求、TDD 实践。当用户编写单元测试、Mock 依赖、提高测试覆盖率或进行测试驱动开发时使用。 |
Skill 03: 单元测试编写
概述
BK-CI 后端使用 JUnit 5 + MockK 作为测试框架,遵循 AAA(Arrange-Act-Assert)测试模式。
测试框架和工具链
| 组件 | 版本 | 用途 |
|---|---|---|
| JUnit 5 (Jupiter) | - | 主测试框架 |
| MockK | 1.12.2 | Kotlin 原生 Mock 框架 |
| JOOQ MockConnection | - | 数据库测试 |
测试文件组织规范
命名和路径约定
- 测试文件命名:
*Test.kt - 路径镜像源文件:
源代码:src/main/kotlin/com/tencent/devops/common/api/util/JsonUtil.kt
测试代码:src/test/kotlin/com/tencent/devops/common/api/util/JsonUtilTest.kt
- 测试资源:
src/test/resources/
测试基类
BkCiAbstractTest
位于 common-test 模块,提供:
- MockConnection
- ObjectMapper
- 工具方法
abstract class BkCiAbstractTest {
protected val dslContext: DSLContext = DSL.using(
MockConnection(Mock.of(0)),
SQLDialect.MYSQL
)
protected val objectMapper: ObjectMapper = JsonUtil.getObjectMapper()
}
测试方法命名约定
模式1:简洁描述性名称(推荐)
@Test
fun mapToTest() { }
@Test
fun getAllVariable() { }
模式2:Kotlin Backtick 中文描述
@Test
fun `when container fail`() { }
@Test
fun `test verifyClientInformation with invalid grant type`() { }
模式3:配合 @DisplayName
@Test
@DisplayName("迁移api创建-管理员用户组")
fun test_1() { }
AAA 测试模式
@Test
fun continueWhenFailure() {
// Arrange(准备数据)
val nullObject = null
val additionalOptions = elementAdditionalOptions(
runCondition = RunCondition.PRE_TASK_FAILED_ONLY
)
// Act(执行)
val result = ControlUtils.continueWhenFailure(additionalOptions)
// Assert(验证)
Assertions.assertFalse(result)
}
Mock 使用规范
Mock 对象创建
// 基础 Mock
private val authOauth2ClientDetailsDao = mockk<AuthOauth2ClientDetailsDao>()
// Relaxed Mock(自动返回默认值)
private val pipelineAsCodeService: PipelineAsCodeService = mockk(relaxed = true)
// Spy(部分 Mock)
private val self: MigrateV3PolicyService = spyk(
MigrateV3PolicyService(...),
recordPrivateCalls = true // 可访问私有方法
)
Stub 行为定义
// 简单返回值
every { pipelineBuildVarDao.getVars(dslContext, projectId, buildId) } returns mockVars
// 条件应答
every { redisOperation.execute(any<RedisScript<*>>(), any(), any()) } answers {
val scriptObject = args[0]!!
if (scriptObject is DefaultRedisScript<*> && scriptObject.resultType == Long::class.java) {
return@answers 1
} else {
throw RuntimeException("redisOperation.execute must mock by self")
}
}
// Spring Bean Mock
mockkObject(SpringContextUtil)
every { SpringContextUtil.getBean(CommonConfig::class.java) } returns commonConfig
生命周期钩子
@BeforeEach
fun setup() {
val commonConfig: CommonConfig = mockk()
val redisOperation: RedisOperation = mockk()
every { commonConfig.devopsDefaultLocaleLanguage } returns "zh_CN"
every { redisOperation.get(any()) } returns "zh_CN"
mockkObject(SpringContextUtil)
every { SpringContextUtil.getBean(CommonConfig::class.java) } returns commonConfig
}
断言和验证模式
常用断言
// 基本断言
Assertions.assertEquals(expected, actual)
Assertions.assertTrue(condition)
Assertions.assertFalse(condition)
Assertions.assertNull(value)
Assertions.assertNotNull(value)
// 异常断言
val exception = assertThrows<ErrorCodeException> {
oauth2ClientService.verifyClientInformation(...)
}
Assertions.assertEquals(AuthMessageCode.INVALID_AUTHORIZATION_TYPE, exception.errorCode)
// 集合断言
Assertions.assertEquals(map.size, 2)
Assertions.assertNotNull(map["key"])
验证调用
// 验证方法被调用
verify { mockService.doSomething(any()) }
// 验证调用次数
verify(exactly = 1) { mockService.doSomething(any()) }
// 验证未被调用
verify(exactly = 0) { mockService.doSomething(any()) }
测试数据构建
Builder 模式(推荐)
// 在 TestBase 中定义 Builder 方法
fun elementAdditionalOptions(
enable: Boolean = true,
runCondition: RunCondition = RunCondition.PRE_TASK_SUCCESS,
customVariables: MutableList<NameAndValue>? = null,
retryCount: Int = 0
): ElementAdditionalOptions {
return ElementAdditionalOptions(
enable = enable,
runCondition = runCondition,
customVariables = customVariables,
retryCount = retryCount
)
}
// 使用
val options = elementAdditionalOptions(
enable = false,
runCondition = RunCondition.PRE_TASK_FAILED_ONLY
)
从资源文件加载
val classPathResource = ClassPathResource("v3/group_api_policy_admin.json")
val taskDataResult = JsonUtil.to(
json = classPathResource.inputStream.readBytes().toString(Charset.defaultCharset()),
type = MigrateTaskDataResult::class.java
)
共享 Fixture
companion object {
const val projectId = "devops1"
const val buildId = "b-12345678901234567890123456789012"
const val pipelineId = "p-12345678901234567890123456789012"
}
数据库测试规范
使用 JOOQ MockConnection
// 在 BkCiAbstractTest 中配置
val dslContext: DSLContext = DSL.using(
MockConnection(Mock.of(0)),
SQLDialect.MYSQL
)
// Mock 查询结果
fun <R : Record> DSLContext.mockResult(t: Table<R>, vararg records: R): Result<R> {
val result = newResult(t)
records.forEach { result.add(it) }
return result
}
测试组织模式
使用 @Nested 组织相关测试
class MigrateV3PolicyServiceTest {
@Nested
inner class BuildRbacAuthorizationScopeList {
@Test
@DisplayName("迁移api创建-管理员用户组")
fun test_1() { }
@Test
@DisplayName("迁移api创建-用户自定义用户组")
fun test_2() { }
}
}
测试覆盖要求
- 单元测试:关键业务逻辑必须有测试覆盖
- Service 层核心方法
- 工具类公共方法
- 复杂算法和条件分支
- 边界测试:空值、空列表、边界值、异常情况
- 覆盖率工具:JaCoCo