Files
conti-docs/backend/10-testing.md
T
Guangfei.Zhao 74b02ed427 feat: add documentation for cross-domain collaboration and aggregation
- Introduced a new section on cross-domain collaboration and aggregation, detailing decision-making processes, contract module usage for cross-domain reads, and domain events for writes.
- Added guidelines for parallel aggregation using a dedicated thread pool and context propagation.
- Established rules for transaction boundaries, idempotency, optimistic locking, scheduled tasks, and caching strategies in a concurrent environment.
- Included examples and best practices for implementing these concepts in the application.
2026-08-14 16:03:47 +08:00

551 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 10. 测试策略
## 决策
JUnit 5 + MockK 做单元测试,TestcontainersMySQL)做集成测试,WireMock 做外部依赖打桩,ArchUnit 把架构规则变成可执行测试。分层对应 [02-layering.md](./02-layering.md)。
## 测试依赖基线
```groovy
// 各模块 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 + MockKmock 掉 repository/client 接口 | 否 | 最多 |
| `application` | slice test 或 `@SpringBootTest`mock 掉 infrastructure | 轻量 | 多 |
| `infrastructure` | Testcontainers 起真实 MySQL 跑 repository 测试 | 是(`@DataJpaTest` | 中 |
| `api` | `@WebMvcTest` 验证参数校验、异常处理、响应结构(见 [06-api-design.md](./06-api-design.md) | 是(web slice | 中 |
| `integration/*` | WireMock 打桩,覆盖超时/重试/熔断/舱壁路径 | 视用例 | 少 |
| 架构规则 | ArchUnit,全代码库扫描 | 否 | 一组 |
## `domain` 层单元测试示例(对应 02 里的换票场景)
```kotlin
// 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
```kotlin
// 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](./03-persistence.md))。这些 H2 一个都模拟不了。
**容器复用**:默认每个测试类起一个新容器,模块一多启动开销很可观。开发本机可以在 `~/.testcontainers.properties` 里设 `testcontainers.reuse.enable=true` 并给容器加 `.withReuse(true)`;**CI 上不要开**——CI 需要的是每次都干净的环境。
## `api` 层测试示例(`@WebMvcTest`
```kotlin
@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 降级路径)
```kotlin
// 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](./01-project-structure.md) 和 [02-layering.md](./02-layering.md) 里定的规则,光靠 code review 肉眼盯着一定会漏。全部写成 ArchUnit 测试,放在专门的 `architecture-test` 模块里(它依赖所有其他模块,是唯一能看到全代码库的地方):
```groovy
// architecture-test/build.gradle
dependencies {
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
// 依赖所有被检查的模块,否则 ClassFileImporter 扫不到它们的字节码
testImplementation project(':bootstrap')
}
```
```kotlin
// 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.mdapi 层不 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("统一用 InstantUTC 存储,见 03-persistence.md")
.check(classes)
}
@Test
fun `禁止字段注入`() {
noFields().should().beAnnotatedWith(Autowired::class.java)
.because("统一用构造器注入,可测试且不可变")
.check(classes)
}
}
```
两个使用上的注意点:
1. **`ImportOption.DoNotIncludeTests()` 不能省**。测试代码里 mock、构造 Entity、跨层引用都是正常的,不排除掉会误报一片。
2. **匹配不到任何类的规则默认会失败**ArchUnit 1.x 的 `allowEmptyShould` 行为)。比如某个 domain 还没建 contract 包,规则就会空转失败。要么用 `.allowEmptyShould(true)`,要么等这个模块真的建起来再加规则——不要因为这个把整条规则删掉。
### 原生 SQL 跨库扫描测试([03-persistence.md](./03-persistence.md) 承诺的那条)
MySQL 里 schema ≡ database**跨 database join 只要账号有权限就是合法的**,编译器和 ArchUnit 都拦不住。所以额外加一条扫描测试作为软约束的第二道:
```kotlin
// 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-integration-layer.md) 承诺的那条)
05 里说了切面叠加顺序由 `*-aspect-order` 属性决定、而且属性值的方向容易记反,所以配完必须实测一次,别靠记忆:
```kotlin
@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-Dockerdind),托管 Runner 的默认选择**
```yaml
# .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](./09-build-deploy.md) 里那种自建 self-hosted Runner`azure-vnet-runner`**
```toml
# 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` 插件把所有模块的执行数据合并成一份报告:
```groovy
// 根 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'
}
}
}
```
排除项——这些代码算进分母只会让指标失真,逼着团队去写毫无意义的测试:
```groovy
subprojects {
tasks.withType(JacocoReport).configureEach {
classDirectories.setFrom(files(classDirectories.files.collect {
fileTree(dir: it, exclude: [
'**/*MapperImpl*', // MapStruct 生成的代码
'**/*Request*', '**/*Response*', '**/*Dto*', // 纯数据类,没有逻辑
'**/*Entity*', // 同上
'**/*Config*', '**/*Properties*',
'**/BootstrapApplication*',
])
}))
}
}
```
```yaml
# .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",字段一改,几十个测试同时爆掉。统一用**测试数据构建器**,只显式写这个测试真正关心的字段:
```kotlin
// 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)。
## 参考链接
- [MockK 官方文档](https://mockk.io/)
- [springmockk`@MockkBean`](https://github.com/Ninja-Squad/springmockk)
- [Testcontainers 官方文档](https://testcontainers.com/)
- [Spring Boot: Testcontainers 与 @ServiceConnection](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html)
- [WireMock 官方文档](https://wiremock.org/docs/)
- [ArchUnit 官方文档](https://www.archunit.org/userguide/html/000_Index.html)
- [Gradle: JaCoCo Report Aggregation Plugin](https://docs.gradle.org/current/userguide/jacoco_report_aggregation_plugin.html)
- [Martin Fowler: Test Pyramid](https://martinfowler.com/bliki/TestPyramid.html)