Files
conti-docs/backend/10-testing.md
T
Guangfei.Zhao 8c0fcd84e8 feat: Enhance documentation on layering, object naming conventions, and API design
- Added object naming conventions (PO/DAO/BO/DTO/VO) in 02-layering.md to clarify terminology and usage within the team.
- Updated 06-api-design.md to include MapStruct for DTO and entity conversion, providing examples and configuration details.
- Expanded 07-config-governance.md with local development instructions and strategies for running without K8s, including two recommended approaches.
- Included K8s probe configuration details in 08-observability.md for liveness and readiness checks.
- Clarified CI/CD processes in 09-build-deploy.md, detailing environment distinctions and deployment strategies for local, Dev, UAT, and Prod.
- Introduced ArchUnit for architectural testing in 10-testing.md, ensuring adherence to defined layering rules and coverage verification with Jacoco.
2026-08-13 15:19:05 +08:00

274 lines
11 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 做单元测试,Testcontainers 做集成测试,WireMock 做外部依赖打桩,分层对应 [02-layering.md](./02-layering.md)。
## 分层测试策略
- **domain 层**(有的话):纯单元测试,不起 Spring 容器,mock 掉 repository/client 接口,验证业务规则本身。
- **application 层**`@SpringBootTest` 或轻量 slice testmock 掉 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() }
jsonPath("$.code") { value("INVALID_PARAM") } // 对应 06-api-design.md 的 ApiResult 结构
}
}
}
```
## 外部依赖打桩示例(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 生效
}
}
```
## `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-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
```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.ymlCI 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` 层的诉求是"关键路径别漏测"而不是"每一行都要覆盖",统一定一个较低的整体阈值,作用是**拦住完全没写测试就合入的代码**,而不是逼着每个模块都卷到很高的数字——那样容易导致为了凑覆盖率写没有意义的测试。
## 附录:为什么 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)
- [ArchUnit 官方文档](https://www.archunit.org/userguide/html/000_Index.html)
- [Jacoco Gradle Plugin 官方文档](https://docs.gradle.org/current/userguide/jacoco_plugin.html)