# 10. 测试策略 ## 决策 JUnit 5 + MockK 做单元测试,Testcontainers(MySQL)做集成测试,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 + MockK,mock 掉 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() 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.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) } } ``` 两个使用上的注意点: 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-Docker(dind),托管 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)