backend scaffold
This commit is contained in:
@@ -0,0 +1,550 @@
|
||||
# 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<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.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)
|
||||
Reference in New Issue
Block a user