Files
conti-docs/backend/10-testing.md
T
Guangfei.Zhao 1e0cbb86a2 feat: Add comprehensive documentation for integration layer, API design, config governance, observability, build/deploy, and testing strategies
- Introduced integration layer design with Resilience4j for external vendor calls.
- Established API design standards with unified response structures and global exception handling.
- Defined configuration and service governance using Kubernetes native solutions.
- Implemented observability practices including trace ID propagation and structured logging.
- Outlined build and multi-environment deployment strategies using Gradle and GitLab CI/CD.
- Specified testing strategies across different layers, utilizing JUnit, MockK, Testcontainers, and WireMock.
2026-08-12 18:23:11 +08:00

152 lines
6.1 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 生效
}
}
```
## 附录:为什么 domain 层用 mock、infrastructure 层坚持用真实依赖
这是测试金字塔的实际落地取舍:越往下层(domain)测试数量应该越多、跑得越快,因为业务规则的分支组合往往很多(各种边界条件),用 mock 把依赖都隔离掉才能便宜地把每个分支都测到;越往上/往基础设施层,测试数量应该越少但真实度要求越高,因为这一层要验证的恰恰是"我们对某个具体技术(JPA、真实数据库、真实 HTTP 依赖)的假设是否成立"——如果这一层也用 mock,等于假设了"这个假设是对的",那测试就失去了意义。
Testcontainers 和 WireMock 的共同点是:它们让"跑得慢、需要真实环境"的测试仍然可以在 CI 里可重复地跑起来(每次测试起一个全新的容器,跑完销毁,不依赖某个共享的、状态可能被污染的测试环境)。
## 待补充
- 各 domain 覆盖率要求。
- 是否需要和 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)