26 KiB
10. 测试策略
决策
JUnit 5 + MockK 做单元测试,Testcontainers(MySQL)做集成测试,WireMock 做外部依赖打桩,ArchUnit 把架构规则变成可执行测试。分层对应 02-layering.md。
测试依赖基线
// 各模块 build.gradle。版本能由 BOM 管的一律不写死(根工程已引入 Spring Boot BOM,见 01-project-structure.md)
dependencies {
testImplementation 'org.springframework.boot:spring-boot-starter-test' // JUnit 5 + AssertJ + Mockito
testImplementation 'io.mockk:mockk' // Kotlin 友好的 mock 库
testImplementation 'com.ninja-squad:springmockk:5.0.1' // 提供 @MockkBean / @MockkSpyBean
// Testcontainers:版本由 Spring Boot BOM 统一管理,不要手动 pin。
// 如果确实要覆盖版本,注意跨大版本时坐标和包路径可能变动,改完先跑一遍再提交。
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql'
testImplementation 'org.wiremock:wiremock-standalone:3.9.1'
}
@MockkBean 不是 Spring 自带的,它来自 springmockk——这个依赖漏了的话,注解根本不存在,编译期就红。Spring 自带的是 @MockitoBean(Boot 3.4+ 取代了 @MockBean),但 Mockito 对 Kotlin 的 final class 和协程支持不如 MockK,这套代码库统一用 MockK 一套到底,避免两套 mock 语法混着写。
分层测试策略
| 层 | 手段 | 起 Spring 容器 | 数量 |
|---|---|---|---|
domain |
纯 JUnit + MockK,mock 掉 repository/client 接口 | 否 | 最多 |
application |
slice test 或 @SpringBootTest,mock 掉 infrastructure |
轻量 | 多 |
infrastructure |
Testcontainers 起真实 MySQL 跑 repository 测试 | 是(@DataJpaTest) |
中 |
api |
@WebMvcTest 验证参数校验、异常处理、响应结构(见 06-api-design.md) |
是(web slice) | 中 |
integration/* |
WireMock 打桩,覆盖超时/重试/熔断/舱壁路径 | 视用例 | 少 |
| 架构规则 | ArchUnit,全代码库扫描 | 否 | 一组 |
domain 层单元测试示例(对应 02 里的换票场景)
// domains/webview-ticket/src/test/kotlin/.../IssueWebviewTicketServiceTest.kt
class IssueWebviewTicketServiceTest {
private val ticketRepository = mockk<WebviewTicketRepository>()
private val fixedClock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC)
private val service = IssueWebviewTicketService(ticketRepository, fixedClock)
@Test
fun `复用未过期的已签发票据`() {
val existing = WebviewTicket("t1", storeId = 1, userId = 1, status = TicketStatus.ISSUED,
expiresAt = fixedClock.instant().plusSeconds(60))
every { ticketRepository.findActiveTicket(1, 1) } returns existing
val result = service.issue(userId = 1, storeId = 1)
assertEquals("t1", result.ticketId)
verify(exactly = 0) { ticketRepository.save(any()) } // 复用场景不应该重新签发
}
@Test
fun `没有有效票据时签发新票据`() {
every { ticketRepository.findActiveTicket(1, 1) } returns null
every { ticketRepository.save(any()) } just Runs
val result = service.issue(userId = 1, storeId = 1)
assertEquals(TicketStatus.ISSUED, result.status)
verify(exactly = 1) { ticketRepository.save(any()) }
}
}
不起 Spring 容器、不连数据库,纯 JVM 内存跑完,这类测试应该是数量最多、跑得最快的一层。
注意 Clock 是构造参数注入的,不是 Instant.now() 硬编码。所有涉及时间的业务代码都必须注入 Clock(生产环境 Clock.systemUTC() 由 platform-* 提供一个 @Bean),否则"过期判断"这类逻辑根本没法稳定测试,只能靠 Thread.sleep 硬等——那是慢测试和随机失败的主要来源。
infrastructure 层集成测试示例(Testcontainers + MySQL)
// domains/identity-store/src/test/kotlin/.../StoreJpaRepositoryTest.kt
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class StoreJpaRepositoryTest {
companion object {
@Container
@ServiceConnection // Boot 自动把容器的 url/user/password 注入 DataSource
@JvmStatic
val mysql = MySQLContainer("mysql:8.4")
.withUrlParam("connectionTimeZone", "UTC") // 与生产一致,见 03-persistence.md
.withUrlParam("preserveInstants", "true")
}
@Autowired lateinit var repository: StoreJpaRepository
@Test
fun `按 code 查询门店`() {
repository.save(StoreEntity(name = "示例门店", code = "S001", status = StoreStatus.ACTIVE))
val found = repository.findByCode("S001")
assertNotNull(found)
assertEquals("示例门店", found?.name)
}
}
两个注解缺一不可,而且都是"不加就静默走错路"的类型:
@ServiceConnection:Boot 3.1+ 提供,自动从容器推导连接信息。没有它就得手写一堆@DynamicPropertySource,容易漏配(比如漏了时区参数,测试全绿但生产时间差 8 小时)。@AutoConfigureTestDatabase(replace = NONE):@DataJpaTest默认会把 DataSource 替换成嵌入式内存库。如果 classpath 上恰好有 H2,容器白起了,测试实际跑在 H2 上——而且不会有任何报错,只会在某天遇到 MySQL 特有行为时才暴露。这是本篇最值得单独强调的一个坑。
用真实 MySQL 而不是 H2,是因为要验证的恰恰是对 MySQL 的假设:Flyway 迁移脚本里的 DDL 方言、utf8mb4_0900_ai_ci 排序规则下的中文比较、datetime(6) 的精度截断、唯一索引在 3072 字节限制下能不能建起来(见 03-persistence.md)。这些 H2 一个都模拟不了。
容器复用:默认每个测试类起一个新容器,模块一多启动开销很可观。开发本机可以在 ~/.testcontainers.properties 里设 testcontainers.reuse.enable=true 并给容器加 .withReuse(true);CI 上不要开——CI 需要的是每次都干净的环境。
api 层测试示例(@WebMvcTest)
@WebMvcTest(StoreController::class)
@Import(GlobalExceptionHandler::class) // slice test 默认不装配 platform-web 里的 advice,要显式引入
class StoreControllerTest {
@Autowired lateinit var mockMvc: MockMvc
@MockkBean lateinit var storeAppService: StoreAppService
@Test
@WithMockUser
fun `切换到无权访问的门店返回 10403`() {
every { storeAppService.switchStore(any(), 999) } throws StoreNotAccessibleException()
mockMvc.post("/api/v1/stores/999/switch") // 路径与 06-api-design.md、客户端保持一致
.andExpect {
status { isForbidden() }
jsonPath("$.code") { value(ErrorCode.STORE_NOT_ACCESSIBLE) } // Int,见 06-api-design.md
jsonPath("$.traceId") { exists() }
}
}
}
@WebMvcTest 只装配 web 层(Controller、@ControllerAdvice、消息转换器、参数校验),不装配 Service/Repository——所以 StoreAppService 必须 mock 掉,否则容器起不来。这一层测的是协议:状态码对不对、错误码对不对、字段名和 JSON 结构对不对,而不是业务逻辑。
外部依赖打桩示例(WireMock,覆盖 F6 降级路径)
// integration/f6-adapter/src/test/kotlin/.../F6ApiClientResilienceTest.kt
@SpringBootTest
class F6ApiClientResilienceTest {
companion object {
@JvmStatic
val wireMock = WireMockServer(options().dynamicPort()).apply { start() }
@JvmStatic
@DynamicPropertySource
fun props(registry: DynamicPropertyRegistry) {
registry.add("integration.f6.base-url") { wireMock.baseUrl() }
registry.add("resilience4j.circuitbreaker.instances.f6.minimum-number-of-calls") { 3 }
}
}
@Autowired lateinit var f6ApiClient: F6ApiClient
@Test
fun `F6 响应超时后走 fallback 返回降级数据`() {
wireMock.stubFor(
get(urlPathEqualTo("/f6/procurement/list"))
.willReturn(aResponse().withFixedDelay(5_000).withStatus(200)), // 超过 responseTimeout 配置
)
// 同步 RestClient,直接拿返回值,没有 .block()(见 05-integration-layer.md)
val result = f6ApiClient.fetchProcurementList(storeId = 1)
assertTrue(result.degraded) // 验证超时后 @Retry 耗尽 → fallback 生效
}
@Test
fun `连续失败后熔断器打开并直接走 fallback`() {
wireMock.stubFor(
get(urlPathEqualTo("/f6/procurement/list"))
.willReturn(aResponse().withStatus(503)),
)
repeat(5) { f6ApiClient.fetchProcurementList(storeId = 1) }
// 熔断打开后不应该再发出真实请求 —— 这是"熔断真的生效了"的唯一硬证据,
// 只断言返回降级数据是不够的(重试耗尽也会返回降级数据,两者分不开)
val callsBefore = wireMock.findAll(getRequestedFor(urlPathEqualTo("/f6/procurement/list"))).size
f6ApiClient.fetchProcurementList(storeId = 1)
assertEquals(callsBefore, wireMock.findAll(getRequestedFor(urlPathEqualTo("/f6/procurement/list"))).size)
}
}
熔断相关的测试必须在测试里把 minimum-number-of-calls 调小(生产配置通常是 20~50),否则要打满几十次请求才会触发,测试又慢又难读。
ArchUnit:把架构规则变成可执行的测试
01-project-structure.md 和 02-layering.md 里定的规则,光靠 code review 肉眼盯着一定会漏。全部写成 ArchUnit 测试,放在专门的 architecture-test 模块里(它依赖所有其他模块,是唯一能看到全代码库的地方):
// architecture-test/build.gradle
dependencies {
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
// 依赖所有被检查的模块,否则 ClassFileImporter 扫不到它们的字节码
testImplementation project(':bootstrap')
}
// architecture-test/src/test/kotlin/.../ArchitectureRulesTest.kt
class ArchitectureRulesTest {
private val classes = ClassFileImporter()
.withImportOption(ImportOption.DoNotIncludeTests())
.importPackages("com.continental.retailapp")
private val domains = listOf("identitystore", "bff", "workbench", "webviewticket")
// —— 规则组一:模块边界(01-project-structure.md)——
@Test
fun `domain 之间只能通过 contract 包互相依赖`() {
domains.forEach { source ->
domains.filter { it != source }.forEach { target ->
noClasses()
.that().resideInAPackage("..retailapp.$source..")
.should().dependOnClassesThat(
JavaClass.Predicates.resideInAPackage("..retailapp.$target..")
.and(DescribedPredicate.not(
JavaClass.Predicates.resideInAPackage("..retailapp.$target.contract..")))
)
.because("跨 domain 只能走 -contract 模块发布的接口/传输模型/事件")
.check(classes)
}
}
}
@Test
fun `contract 模块不能依赖 Spring Web 或 JPA`() {
noClasses()
.that().resideInAPackage("..retailapp.*.contract..")
.should().dependOnClassesThat()
.resideInAnyPackage("org.springframework.web..", "jakarta.persistence..", "..infrastructure..")
.because("契约模块只放接口、传输模型和事件,不携带任何技术栈")
.check(classes)
}
// —— 规则组二:层内方向(02-layering.md)——
@Test
fun `层依赖方向`() {
layeredArchitecture().consideringOnlyDependenciesInLayers()
.layer("api").definedBy("..retailapp.*.api..")
.layer("application").definedBy("..retailapp.*.application..")
.layer("domain").definedBy("..retailapp.*.domain..")
.layer("infrastructure").definedBy("..retailapp.*.infrastructure..")
.whereLayer("api").mayNotBeAccessedByAnyLayer()
.whereLayer("application").mayOnlyBeAccessedByLayers("api")
.whereLayer("domain").mayOnlyBeAccessedByLayers("application", "infrastructure")
// 注意这里是 application 而不是"谁都不能访问":跳过 domain 层的简单 CRUD 场景下,
// application 直接依赖 infrastructure 里定义的 repository 接口是 02-layering.md 明确允许的。
// 写成 mayNotBeAccessedByAnyLayer() 会把 02 自己的示例判红 —— 规则必须和文档一致,
// 真正的红线是下面那条"api 不能碰 infrastructure"。
.whereLayer("infrastructure").mayOnlyBeAccessedByLayers("application")
.check(classes)
}
@Test
fun `api 层不能依赖 infrastructure 层`() {
noClasses()
.that().resideInAPackage("..api..")
.should().dependOnClassesThat().resideInAPackage("..infrastructure..")
.because("02-layering.md:api 层不 import infrastructure 包下的任何类型(包括 Entity)")
.check(classes)
}
@Test
fun `domain 层不能依赖 Spring 或 JPA`() {
noClasses()
.that().resideInAPackage("..domain..")
.should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "jakarta.persistence..")
.check(classes)
}
// —— 规则组三:Entity 边界(02-layering.md / 06-api-design.md 里逐字相同的那句话)——
// 「XxxEntity 不出现在 api 层的任何签名或 import 里,也不跨出所在模块的边界。」
@Test
fun `Entity 只能待在 infrastructure 包里`() {
classes()
.that().haveSimpleNameEndingWith("Entity")
// platform-* 模块不按四层划分(见 01-project-structure.md 的包名对应表),
// 里面的 BaseEntity / VersionedEntity 和幂等记录表按模块自身结构组织,整体排除。
// 这条规则约束的是各业务域的 Entity 不许爬出 infrastructure。
.and().resideOutsideOfPackage("..retailapp.platform..")
.should().resideInAPackage("..infrastructure..")
.check(classes)
}
@Test
fun `api 层不能触碰 Entity`() {
noClasses()
.that().resideInAPackage("..api..")
.should().dependOnClassesThat().haveSimpleNameEndingWith("Entity")
.because("Entity → Response 的转换发生在 application 层,mapper 放在 application/mapper/")
.check(classes)
}
// —— 规则组四:编码约定 ——
@Test
fun `Controller 的端点方法必须返回 ApiResult`() {
methods()
.that().areDeclaredInClassesThat().areAnnotatedWith(RestController::class.java)
// 用 metaAnnotatedWith 而不是 arePublic:@GetMapping/@PostMapping 都是 @RequestMapping 的
// 元注解派生,这样只圈住真正的端点方法,不会误伤 Controller 里的 public 辅助方法
.and().areMetaAnnotatedWith(RequestMapping::class.java)
.should().haveRawReturnType(ApiResult::class.java)
.because("统一响应结构,见 06-api-design.md")
.check(classes)
}
@Test
fun `禁止使用 java 时间类型的老 API`() {
noClasses()
.should().dependOnClassesThat()
.belongToAnyOf(java.util.Date::class.java, java.util.Calendar::class.java)
.because("统一用 Instant,UTC 存储,见 03-persistence.md")
.check(classes)
}
@Test
fun `禁止字段注入`() {
noFields().should().beAnnotatedWith(Autowired::class.java)
.because("统一用构造器注入,可测试且不可变")
.check(classes)
}
}
两个使用上的注意点:
ImportOption.DoNotIncludeTests()不能省。测试代码里 mock、构造 Entity、跨层引用都是正常的,不排除掉会误报一片。- 匹配不到任何类的规则默认会失败(ArchUnit 1.x 的
allowEmptyShould行为)。比如某个 domain 还没建 contract 包,规则就会空转失败。要么用.allowEmptyShould(true),要么等这个模块真的建起来再加规则——不要因为这个把整条规则删掉。
原生 SQL 跨库扫描测试(03-persistence.md 承诺的那条)
MySQL 里 schema ≡ database,跨 database join 只要账号有权限就是合法的,编译器和 ArchUnit 都拦不住。所以额外加一条扫描测试作为软约束的第二道:
// architecture-test/src/test/kotlin/.../NativeQueryScanTest.kt
class NativeQueryScanTest {
// 模块目录 -> 它自己的 database 名。没有自己数据库的模块(bff-orchestration)不在表里,
// 它一个 database 名都不该出现,所以 owner 传 null 即可。
private val ownerByModule = mapOf(
"identity-store" to "identity_store",
"workbench" to "workbench",
"webview-ticket" to "webview_ticket",
)
private val allDatabases = ownerByModule.values.toSet()
@Test
fun `原生 SQL 里不能出现其他 domain 的库名`() {
val violations = Files.walk(Path.of("../domains"))
.filter { it.toString().endsWith(".kt") }
.toList()
.flatMap { file ->
val path = file.toString().replace('\\', '/')
val owner = ownerByModule.entries.firstOrNull { path.contains("/${it.key}/") }?.value
val text = Files.readString(file)
(allDatabases - setOfNotNull(owner))
.filter { text.contains("$it.") }
.map { "$path 引用了 $it" }
}
assertTrue(violations.isEmpty()) { "跨 database 访问:\n${violations.joinToString("\n")}" }
}
}
这是近似检查、不是严密证明(它只做字符串匹配,绕过它很容易)。它的价值在于把"不小心写了个跨库 join"这种最常见的情况挡在 CI 上;真正的硬约束是生产账号只 GRANT 本域 database 的权限。这一点要如实认识,不要因为有这个测试就以为边界被守住了。
Resilience4j 切面顺序验证测试(05-integration-layer.md 承诺的那条)
05 里说了切面叠加顺序由 *-aspect-order 属性决定、而且属性值的方向容易记反,所以配完必须实测一次,别靠记忆:
@SpringBootTest
class ResilienceAspectOrderTest {
// WireMock 的搭建同上一节的 F6ApiClientResilienceTest,这里只列关键断言
@Autowired lateinit var f6ApiClient: F6ApiClient
@Autowired lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
@Test
fun `Retry 在外层时每次重试都计入熔断统计`() {
wireMock.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(503)))
val cb = circuitBreakerRegistry.circuitBreaker("f6")
f6ApiClient.fetchProcurementList(storeId = 1) // 1 次调用 + 2 次重试
// Retry 在外 → 熔断器看到 3 次失败;Retry 在内 → 熔断器只看到 1 次。
// 这个断言就是"配置到底生效成什么样"的答案,改配置后它会立刻告诉你方向反没反。
assertEquals(3, cb.metrics.numberOfFailedCalls)
}
}
CI 里跑 Testcontainers 的前提条件
集成测试依赖 Testcontainers 起真实容器,这要求执行 ./gradlew test 的 GitLab Runner 本身能起 Docker 容器,两种常见配置:
方式一:Docker-in-Docker(dind),托管 Runner 的默认选择
# .gitlab-ci.yml
unit-integration-test:
stage: validate
image: eclipse-temurin:21-jdk
services:
- docker:24-dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
script:
- ./gradlew test --no-daemon
方式二:挂载宿主机 Docker socket,适合 09-build-deploy.md 里那种自建 self-hosted Runner(azure-vnet-runner)
# GitLab Runner 的 config.toml
[[runners]]
[runners.docker]
privileged = true
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
方式二没有 dind 的嵌套虚拟化开销,跑起来更快,但要求 Runner 能访问宿主机的 Docker socket(等价于 Runner 对宿主机有较高权限),只适合放在我们自己管控的 self-hosted Runner 上;如果以后接入公共共享 Runner 跑这一类测试,只能用方式一,不应该在共享 Runner 上开 Docker socket 权限。
覆盖率门禁(Jacoco 多模块聚合)
先说清楚为什么必须聚合:多模块工程里如果每个模块各算各的覆盖率,会出现两个问题——① 根工程自己没有测试,在根上跑 jacocoTestCoverageVerification 直接就是 0/0 通过,门禁形同虚设;② architecture-test 模块跑的测试覆盖到的是其他模块的代码,按模块统计时这部分贡献会被完全丢掉。用 Gradle 自带的 jacoco-report-aggregation 插件把所有模块的执行数据合并成一份报告:
// 根 build.gradle
plugins {
id 'jacoco-report-aggregation'
}
dependencies {
// 依赖 bootstrap 即可 —— 它传递性地依赖了所有 platform-* / domains/* / integration/*
jacocoAggregation project(':bootstrap')
jacocoAggregation project(':architecture-test')
}
subprojects {
apply plugin: 'jacoco'
}
// 聚合报告的具体配置属性名在 Gradle 各版本间有过调整,
// 升级 Gradle 后先跑一次 ./gradlew testCodeCoverageReport 确认任务还在、报告路径没变。
reporting {
reports {
testCodeCoverageReport(JacocoCoverageReport) {
testSuiteName = 'test'
}
}
}
排除项——这些代码算进分母只会让指标失真,逼着团队去写毫无意义的测试:
subprojects {
tasks.withType(JacocoReport).configureEach {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/*MapperImpl*', // MapStruct 生成的代码
'**/*Request*', '**/*Response*', '**/*Dto*', // 纯数据类,没有逻辑
'**/*Entity*', // 同上
'**/*Config*', '**/*Properties*',
'**/BootstrapApplication*',
])
}))
}
}
# .gitlab-ci.yml
unit-integration-test:
stage: validate
script:
- ./gradlew test testCodeCoverageReport --no-daemon
artifacts:
reports:
junit: '**/build/test-results/test/TEST-*.xml'
coverage_report:
coverage_format: cobertura
path: 'build/reports/jacoco/testCodeCoverageReport/testCodeCoverageReport.xml'
阈值定在 70% 而不是追求 90%+:domain 层因为纯逻辑、mock 成本低,覆盖率天然会很高;infrastructure/api 层的诉求是"关键路径别漏测"而不是"每一行都要覆盖"。这个门禁的作用是拦住完全没写测试就合入的代码,而不是逼着每个模块都卷到很高的数字——那样只会催生出一堆 assertNotNull(result) 式的假测试,覆盖率好看了,实际保护力反而下降。
测试数据构造约定
集成测试里最容易失控的地方是"每个测试自己 new 一个有 15 个字段的 Entity",字段一改,几十个测试同时爆掉。统一用测试数据构建器,只显式写这个测试真正关心的字段:
// src/test/kotlin/.../fixtures/StoreFixtures.kt
fun aStore(
name: String = "示例门店",
code: String = "S001",
status: StoreStatus = StoreStatus.ACTIVE,
) = StoreEntity(name = name, code = code, status = status)
// 用法:读者一眼就知道这个测试关心的只有 status
val inactive = aStore(status = StoreStatus.INACTIVE)
其他几条:
- 测试之间不共享状态。
@DataJpaTest默认每个测试方法跑在会回滚的事务里;用@SpringBootTest时事务不会自动回滚,需要显式@Transactional或在@AfterEach里清理。 - 不依赖测试执行顺序。JUnit 5 的默认顺序是确定但不保证的,任何"必须先跑 A 再跑 B"的设计都要拆掉。
- 断言要具体。
assertTrue(list.isNotEmpty())在回归时几乎抓不到问题,assertEquals(listOf("S001"), list.map { it.code })才有价值。 - 测试名用反引号中文描述行为(如上面示例),不要
test1、testSwitchStore2——失败时 CI 报告上那一行就是问题描述本身。
附录:为什么 domain 层用 mock、infrastructure 层坚持用真实依赖
这是测试金字塔的实际落地取舍:越往下层(domain)测试数量应该越多、跑得越快,因为业务规则的分支组合往往很多(各种边界条件),用 mock 把依赖都隔离掉才能便宜地把每个分支都测到;越往上/往基础设施层,测试数量应该越少但真实度要求越高,因为这一层要验证的恰恰是"我们对某个具体技术(JPA、真实 MySQL、真实 HTTP 依赖)的假设是否成立"——如果这一层也用 mock,等于假设了"这个假设是对的",那测试就失去了意义。
Testcontainers 和 WireMock 的共同点是:它们让"跑得慢、需要真实环境"的测试仍然可以在 CI 里可重复地跑起来(每次测试起一个全新的容器,跑完销毁,不依赖某个共享的、状态可能被污染的测试环境)。
待补充
- 是否需要和 APP 端做端到端契约测试(比如引入 Pact)。
- 性能/压测基线(至少要有一条:首页聚合接口在 N 并发下的 P99)。