Files
Guangfei.Zhao 74b02ed427 feat: add documentation for cross-domain collaboration and aggregation
- Introduced a new section on cross-domain collaboration and aggregation, detailing decision-making processes, contract module usage for cross-domain reads, and domain events for writes.
- Added guidelines for parallel aggregation using a dedicated thread pool and context propagation.
- Established rules for transaction boundaries, idempotency, optimistic locking, scheduled tasks, and caching strategies in a concurrent environment.
- Included examples and best practices for implementing these concepts in the application.
2026-08-14 16:03:47 +08:00

26 KiB
Raw Permalink Blame History

10. 测试策略

决策

JUnit 5 + MockK 做单元测试,TestcontainersMySQL)做集成测试,WireMock 做外部依赖打桩,ArchUnit 把架构规则变成可执行测试。分层对应 02-layering.md

测试依赖基线

// 各模块 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 自带的是 @MockitoBeanBoot 3.4+ 取代了 @MockBean),但 Mockito 对 Kotlin 的 final class 和协程支持不如 MockK,这套代码库统一用 MockK 一套到底,避免两套 mock 语法混着写。

分层测试策略

手段 起 Spring 容器 数量
domain 纯 JUnit + MockKmock 掉 repository/client 接口 最多
application slice test 或 @SpringBootTestmock 掉 infrastructure 轻量
infrastructure Testcontainers 起真实 MySQL 跑 repository 测试 是(@DataJpaTest
api @WebMvcTest 验证参数校验、异常处理、响应结构(见 06-api-design.md 是(web slice
integration/* WireMock 打桩,覆盖超时/重试/熔断/舱壁路径 视用例
架构规则 ArchUnit,全代码库扫描 一组

domain 层单元测试示例(对应 02 里的换票场景)

// 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

// 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)。这些 H2 一个都模拟不了。

容器复用:默认每个测试类起一个新容器,模块一多启动开销很可观。开发本机可以在 ~/.testcontainers.properties 里设 testcontainers.reuse.enable=true 并给容器加 .withReuse(true)CI 上不要开——CI 需要的是每次都干净的环境。

api 层测试示例(@WebMvcTest

@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 降级路径)

// 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.md02-layering.md 里定的规则,光靠 code review 肉眼盯着一定会漏。全部写成 ArchUnit 测试,放在专门的 architecture-test 模块里(它依赖所有其他模块,是唯一能看到全代码库的地方):

// architecture-test/build.gradle
dependencies {
    testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
    // 依赖所有被检查的模块,否则 ClassFileImporter 扫不到它们的字节码
    testImplementation project(':bootstrap')
}
// 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.mdapi 层不 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("统一用 InstantUTC 存储,见 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 承诺的那条)

MySQL 里 schema ≡ database跨 database join 只要账号有权限就是合法的,编译器和 ArchUnit 都拦不住。所以额外加一条扫描测试作为软约束的第二道:

// 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 里说了切面叠加顺序由 *-aspect-order 属性决定、而且属性值的方向容易记反,所以配完必须实测一次,别靠记忆:

@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-Dockerdind),托管 Runner 的默认选择

# .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 里那种自建 self-hosted Runnerazure-vnet-runner

# 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 插件把所有模块的执行数据合并成一份报告:

// 根 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'
        }
    }
}

排除项——这些代码算进分母只会让指标失真,逼着团队去写毫无意义的测试:

subprojects {
    tasks.withType(JacocoReport).configureEach {
        classDirectories.setFrom(files(classDirectories.files.collect {
            fileTree(dir: it, exclude: [
                '**/*MapperImpl*',            // MapStruct 生成的代码
                '**/*Request*', '**/*Response*', '**/*Dto*',   // 纯数据类,没有逻辑
                '**/*Entity*',                 // 同上
                '**/*Config*', '**/*Properties*',
                '**/BootstrapApplication*',
            ])
        }))
    }
}
# .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",字段一改,几十个测试同时爆掉。统一用测试数据构建器,只显式写这个测试真正关心的字段:

// 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 }) 才有价值。
  • 测试名用反引号中文描述行为(如上面示例),不要 test1testSwitchStore2——失败时 CI 报告上那一行就是问题描述本身。

附录:为什么 domain 层用 mock、infrastructure 层坚持用真实依赖

这是测试金字塔的实际落地取舍:越往下层(domain)测试数量应该越多、跑得越快,因为业务规则的分支组合往往很多(各种边界条件),用 mock 把依赖都隔离掉才能便宜地把每个分支都测到;越往上/往基础设施层,测试数量应该越少但真实度要求越高,因为这一层要验证的恰恰是"我们对某个具体技术(JPA、真实 MySQL、真实 HTTP 依赖)的假设是否成立"——如果这一层也用 mock,等于假设了"这个假设是对的",那测试就失去了意义。

Testcontainers 和 WireMock 的共同点是:它们让"跑得慢、需要真实环境"的测试仍然可以在 CI 里可重复地跑起来(每次测试起一个全新的容器,跑完销毁,不依赖某个共享的、状态可能被污染的测试环境)。

待补充

  • 是否需要和 APP 端做端到端契约测试(比如引入 Pact)。
  • 性能/压测基线(至少要有一条:首页聚合接口在 N 并发下的 P99)。

参考链接