2026-08-12 18:23:11 +08:00
|
|
|
|
# 10. 测试策略
|
|
|
|
|
|
|
|
|
|
|
|
## 决策
|
|
|
|
|
|
|
|
|
|
|
|
JUnit 5 + MockK 做单元测试,Testcontainers 做集成测试,WireMock 做外部依赖打桩,分层对应 [02-layering.md](./02-layering.md)。
|
|
|
|
|
|
|
|
|
|
|
|
## 分层测试策略
|
|
|
|
|
|
|
|
|
|
|
|
- **domain 层**(有的话):纯单元测试,不起 Spring 容器,mock 掉 repository/client 接口,验证业务规则本身。
|
|
|
|
|
|
- **application 层**:`@SpringBootTest` 或轻量 slice test,mock 掉 infrastructure 层。
|
|
|
|
|
|
- **infrastructure 层**:用 Testcontainers 起真实数据库跑 repository 测试,避免 H2 和生产数据库行为差异导致的假通过。
|
|
|
|
|
|
- **api 层**:`@WebMvcTest` 验证参数校验、异常处理、响应结构是否符合 [06-api-design.md](./06-api-design.md) 的约定。
|
|
|
|
|
|
- **f6-integration / mini-clients**:对外部依赖用 WireMock 打桩,覆盖超时/重试/熔断路径。
|
|
|
|
|
|
|
|
|
|
|
|
## `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 内存跑完,这类测试应该是数量最多、跑得最快的一层。
|
|
|
|
|
|
|
|
|
|
|
|
## `infrastructure` 层集成测试示例(Testcontainers)
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
|
|
// domains/identity-store/src/test/kotlin/.../StoreJpaRepositoryTest.kt
|
|
|
|
|
|
@DataJpaTest
|
|
|
|
|
|
@Testcontainers
|
|
|
|
|
|
class StoreJpaRepositoryTest {
|
|
|
|
|
|
|
|
|
|
|
|
companion object {
|
|
|
|
|
|
@Container
|
|
|
|
|
|
@JvmStatic
|
|
|
|
|
|
val postgres = PostgreSQLContainer("postgres:16")
|
|
|
|
|
|
|
|
|
|
|
|
@JvmStatic
|
|
|
|
|
|
@DynamicPropertySource
|
|
|
|
|
|
fun props(registry: DynamicPropertyRegistry) {
|
|
|
|
|
|
registry.add("spring.datasource.url", postgres::getJdbcUrl)
|
|
|
|
|
|
registry.add("spring.datasource.username", postgres::getUsername)
|
|
|
|
|
|
registry.add("spring.datasource.password", postgres::getPassword)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
用真实 PostgreSQL(而不是 H2)跑测试,是因为 Flyway migration 里的 SQL 方言、JPA 对特定数据库函数的行为,H2 不一定能完全模拟,容易出现"H2 测试通过、生产环境报错"的假阳性。
|
|
|
|
|
|
|
|
|
|
|
|
## `api` 层测试示例(`@WebMvcTest`)
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
|
|
@WebMvcTest(StoreController::class)
|
|
|
|
|
|
class StoreControllerTest {
|
|
|
|
|
|
@Autowired lateinit var mockMvc: MockMvc
|
|
|
|
|
|
@MockkBean lateinit var storeAppService: StoreAppService
|
|
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
|
fun `切换门店参数为空时返回参数校验错误`() {
|
|
|
|
|
|
mockMvc.post("/api/v1/stores/switch") {
|
|
|
|
|
|
contentType = MediaType.APPLICATION_JSON
|
|
|
|
|
|
content = """{}"""
|
|
|
|
|
|
}.andExpect {
|
|
|
|
|
|
status { isBadRequest() }
|
2026-08-13 19:28:36 +08:00
|
|
|
|
jsonPath("$.code") { value(ErrorCode.INVALID_PARAM) } // 对应 06-api-design.md 的 ApiResult 结构
|
2026-08-12 18:23:11 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 外部依赖打桩示例(WireMock,覆盖 F6 超时/熔断路径)
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
|
|
@Testcontainers
|
|
|
|
|
|
class F6ApiClientResilienceTest {
|
|
|
|
|
|
companion object {
|
|
|
|
|
|
@Container
|
|
|
|
|
|
@JvmStatic
|
|
|
|
|
|
val wireMock = WireMockContainer("wiremock/wiremock:3.9.1")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
|
fun `F6 响应超时后走 fallback 返回降级数据`() {
|
|
|
|
|
|
wireMock.stubFor(
|
|
|
|
|
|
get(urlPathEqualTo("/f6/procurement/list"))
|
|
|
|
|
|
.willReturn(aResponse().withFixedDelay(5000).withStatus(200)) // 模拟超过 timeout 配置的慢响应
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
val result = f6ApiClient.fetchProcurementList(storeId = 1).block()
|
|
|
|
|
|
|
|
|
|
|
|
assertTrue(result!!.degraded) // 验证 05-integration-layer.md 里配置的 2s 超时 + fallback 生效
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-08-13 15:19:05 +08:00
|
|
|
|
## `ArchUnit`:把分层规则变成可执行的测试
|
|
|
|
|
|
|
|
|
|
|
|
[01-project-structure.md](./01-project-structure.md) 和 [02-layering.md](./02-layering.md) 里定的依赖方向规则(`domain` 不依赖 Spring/JPA、domains 之间不互相依赖、`api` 不直接依赖 `infrastructure`),光靠 code review 肉眼盯着容易漏,模块一多更盯不过来。用 [ArchUnit](https://www.archunit.org/) 把这些规则写成测试,每次构建自动检查:
|
|
|
|
|
|
|
|
|
|
|
|
```groovy
|
|
|
|
|
|
// build.gradle(专门放架构测试的模块,或加进 bootstrap 的 test 依赖)
|
|
|
|
|
|
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```kotlin
|
|
|
|
|
|
// architecture-test/src/test/kotlin/.../LayeringRulesTest.kt
|
|
|
|
|
|
class LayeringRulesTest {
|
|
|
|
|
|
private val classes = ClassFileImporter().importPackages("com.continental.retailapp")
|
|
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
|
fun `domain 层不能依赖 Spring 或 JPA`() {
|
|
|
|
|
|
noClasses()
|
|
|
|
|
|
.that().resideInAPackage("..domain..")
|
|
|
|
|
|
.should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "jakarta.persistence..")
|
|
|
|
|
|
.check(classes)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
|
fun `domains 之间不能互相依赖(bff-orchestration 除外)`() {
|
|
|
|
|
|
slices()
|
|
|
|
|
|
.matching("com.continental.retailapp.(*)..")
|
|
|
|
|
|
.should().notDependOnEachOther()
|
|
|
|
|
|
.ignoreDependency(
|
|
|
|
|
|
DescribedPredicate.describe("来自 bff-orchestration") { it.resideInAPackage("..bffOrchestration..") },
|
|
|
|
|
|
DescribedPredicate.alwaysTrue(),
|
|
|
|
|
|
) // bff-orchestration 允许依赖多个 domain,是唯一的例外,见 02-layering.md
|
|
|
|
|
|
.check(classes)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@Test
|
|
|
|
|
|
fun `api 层不能直接依赖 infrastructure 层`() {
|
|
|
|
|
|
noClasses()
|
|
|
|
|
|
.that().resideInAPackage("..api..")
|
|
|
|
|
|
.should().dependOnClassesThat().resideInAPackage("..infrastructure..")
|
|
|
|
|
|
.check(classes)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
这类测试一次写好,覆盖的是全代码库范围的结构性规则,跑在 CI Validation 阶段(见 [09-build-deploy.md](./09-build-deploy.md)),比"等 code review 时人工发现某个 domain 偷偷 import 了另一个 domain 的 entity"要可靠得多,而且不区分改动大小——哪怕只加了一行 `import`,只要违反规则就会立刻挂红。
|
|
|
|
|
|
|
|
|
|
|
|
## CI 里跑 Testcontainers 的前提条件
|
|
|
|
|
|
|
|
|
|
|
|
`infrastructure` 层的集成测试(前面 `StoreJpaRepositoryTest` 那个例子)依赖 Testcontainers 起真实容器,这要求执行 `./gradlew test` 的 GitLab Runner 本身**能起 Docker 容器**,不是随便一个 Runner 都能跑,两种常见配置:
|
|
|
|
|
|
|
|
|
|
|
|
**方式一: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)
|
|
|
|
|
|
|
|
|
|
|
|
```groovy
|
|
|
|
|
|
// build.gradle
|
|
|
|
|
|
plugins {
|
|
|
|
|
|
id 'jacoco'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
jacocoTestReport {
|
|
|
|
|
|
dependsOn test
|
|
|
|
|
|
reports {
|
|
|
|
|
|
xml.required = true // CI 里给 GitLab 覆盖率可视化用
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
jacocoTestCoverageVerification {
|
|
|
|
|
|
violationRules {
|
|
|
|
|
|
rule {
|
|
|
|
|
|
limit {
|
|
|
|
|
|
minimum = 0.70
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
check.dependsOn jacocoTestCoverageVerification // 覆盖率不达标,./gradlew check 直接失败
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
|
# .gitlab-ci.yml,CI Validation 阶段追加覆盖率门禁
|
|
|
|
|
|
unit-integration-test:
|
|
|
|
|
|
stage: validate
|
|
|
|
|
|
script:
|
|
|
|
|
|
- ./gradlew test jacocoTestCoverageVerification --no-daemon
|
|
|
|
|
|
artifacts:
|
|
|
|
|
|
reports:
|
|
|
|
|
|
coverage_report:
|
|
|
|
|
|
coverage_format: cobertura
|
|
|
|
|
|
path: '**/build/reports/jacoco/test/jacocoTestReport.xml'
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
阈值定在 70% 而不是追求 90%+:`domain` 层因为纯逻辑、mock 成本低,覆盖率天然会很高;`infrastructure`/`api` 层的诉求是"关键路径别漏测"而不是"每一行都要覆盖",统一定一个较低的整体阈值,作用是**拦住完全没写测试就合入的代码**,而不是逼着每个模块都卷到很高的数字——那样容易导致为了凑覆盖率写没有意义的测试。
|
|
|
|
|
|
|
2026-08-12 18:23:11 +08:00
|
|
|
|
## 附录:为什么 domain 层用 mock、infrastructure 层坚持用真实依赖
|
|
|
|
|
|
|
|
|
|
|
|
这是测试金字塔的实际落地取舍:越往下层(domain)测试数量应该越多、跑得越快,因为业务规则的分支组合往往很多(各种边界条件),用 mock 把依赖都隔离掉才能便宜地把每个分支都测到;越往上/往基础设施层,测试数量应该越少但真实度要求越高,因为这一层要验证的恰恰是"我们对某个具体技术(JPA、真实数据库、真实 HTTP 依赖)的假设是否成立"——如果这一层也用 mock,等于假设了"这个假设是对的",那测试就失去了意义。
|
|
|
|
|
|
|
|
|
|
|
|
Testcontainers 和 WireMock 的共同点是:它们让"跑得慢、需要真实环境"的测试仍然可以在 CI 里可重复地跑起来(每次测试起一个全新的容器,跑完销毁,不依赖某个共享的、状态可能被污染的测试环境)。
|
|
|
|
|
|
|
|
|
|
|
|
## 待补充
|
|
|
|
|
|
|
|
|
|
|
|
- 是否需要和 APP 端做端到端契约测试(比如引入 Pact)。
|
|
|
|
|
|
|
|
|
|
|
|
## 参考链接
|
|
|
|
|
|
|
|
|
|
|
|
- [MockK 官方文档](https://mockk.io/)
|
|
|
|
|
|
- [Testcontainers 官方文档](https://testcontainers.com/)
|
|
|
|
|
|
- [WireMock 官方文档](https://wiremock.org/docs/)
|
|
|
|
|
|
- [Martin Fowler: Test Pyramid](https://martinfowler.com/bliki/TestPyramid.html)
|
2026-08-13 15:19:05 +08:00
|
|
|
|
- [ArchUnit 官方文档](https://www.archunit.org/userguide/html/000_Index.html)
|
|
|
|
|
|
- [Jacoco Gradle Plugin 官方文档](https://docs.gradle.org/current/userguide/jacoco_plugin.html)
|