# 20 · T2-09 契约测试报告:实现与冻结契约 v1.2.0 的一致性保障 - **日期**:2026-09-08 - **角色**:Senior Developer(后端) - **工单**:T2-09 验收的契约一致性保障 - **代码提交**:patbond-api dev `d026f2f`(基线 `00f7dbd`) - **结论**:pets 域 18 操作全矩阵契约测试落地并入 CI(`./mvnw test` 即自动执行,ci.yml 零改动);发现并修复漂移 1 项;全套 `./mvnw clean test` **182 项全绿**(171 → 182,+11)。 --- ## 1. 机制选型:冻结快照进测试资源 **选定方案**:把 doc 仓正典 `docs/api/openapi.yaml`(v1.2.0,冻结于 doc main@511617b)**字节级复制**为 patbond-api 测试资源 `patbond-pet/src/test/resources/contract/openapi-v1.2.0.yaml`,契约测试对照快照跑。复制时点双方 sha256 均为 `243fe648…4a4cd689d`。 **否决的备选**:CI 里 checkout doc 仓再喂给测试。现有 ci.yml 是零外部 action、手动 `git init + fetch` 克隆本 Gitea 实例的模式,跨仓 checkout 意味着在工作流里再造一段带 token 的手动克隆、并让**本地** `./mvnw test` 依赖兄弟目录存在——本地与 CI 行为分叉,违背「门禁与本地同一条命令」的既定纪律。快照方案零 CI 改动、本地 CI 完全同构,代价只是一条同步纪律(见 §1.2)。 **解析与校验实现**:不引 swagger-parser / openapi-validator 类库——快照只用到 OpenAPI 3.0 的一个小子集(本地 `$ref`、type/required/nullable/enum/format/min-max),用构建里已有的 snakeyaml(Boot 传递依赖)解析 + 自写严格断言(约 500 行测试代码),零新增 Maven 依赖。自写的关键收益:**未声明字段即报漂移**——标准 OpenAPI 语义默认允许 additionalProperties,而冻结契约的语义是「恰好这些字段」,现成校验器恰恰放过改名/新增泄漏字段这类最常见漂移。 ### 1.1 三个测试类 | 文件(均在 `patbond-pet/src/test/java/...pet/contract/`) | 职责 | | --- | --- | | `OpenApiContract` | 加载快照、解析本地 `$ref`、枚举操作/状态码/schema | | `ContractValidator` | 响应体对 schema 严格校验:必填缺失、null 无 nullable、**契约未声明的字段**、类型/枚举/uuid/date-time/date 格式、min/max(Length) 边界 | | `ContractConformanceTest` | 沿用既有 Testcontainers + MockMvc 基建真实起服务,18 操作逐一发请求校验,最后两个门禁测试(见 §2) | ### 1.2 快照同步纪律 1. **正典唯一**:契约的唯一权威是 doc 仓 `docs/api/openapi.yaml`;api 仓快照是冻结副本,**永不单独修改**。 2. **契约变更流程**:doc 仓升版(如 1.3.0)→ 复制新文件为 `src/test/resources/contract/openapi-v1.3.0.yaml`(删旧快照)→ 更新 `OpenApiContract.RESOURCE` 与守卫测试期望值(版本号、路径/操作/schema 数)→ 按新契约增删测试用例,一并提交。 3. **忘同步的兜底**:守卫测试 `frozenSnapshotIsTheExpectedContractVersion` 锁定 `info.version == 1.2.0` 且 18 路径 / 24 操作 / 45 schema——契约变更后只改快照不改测试(或反之)都会在 CI 立即变红,不会默默对着旧契约测试。 ## 2. 测试什么:全响应矩阵 + 双门禁 覆盖 pets 域 **18 个操作**(契约中 tags ∈ {pets, dictionaries, health-records} 的全部操作,恰为 v1.2.0 新冻结的 12 路径)。每个操作真实发请求,对**契约声明的每一个 (操作, 状态码) 单元格**做结构校验: - **成功形态**(6 个用例):宠物 CRUD 全字段/全空两种形态、品种与疫苗目录(含 species 过滤)、体重与健康事件的 cursor 分页翻页(并断言 `hasMore=true ⇒ nextCursor 非空`、`hasMore=false ⇒ nextCursor 恒 null`)、疫苗 scheduled/completed 两形态与状态机 PATCH、提醒 completed/dismissed 两种流转、摘要空档案(三聚合 null)与满档案(四聚合非 null)+ tz 参数。 - **错误信封**(3 个用例):18 操作逐一裸请求验 401/40101;11 个 pet 路径操作验 40401 防枚举、3 个顶层短路径验 40402、8 个写操作按 viewer/caregiver 角色验 40300;12 处 400/40000(缺必填、limit 越界、非法 cursor、非法 species/status/tz)、40902 乐观锁过期(pets/vaccinations/health-events 三处)、40903 芯片号冲突、40904 剂次冲突、42201 疫苗规则两形态、42202 提醒规则。 - **门禁一**(快照守卫):见 §1.2 第 3 条。 - **门禁二**(覆盖率自证):`everyDeclaredResponseCellIsExercised` 断言上述用例真实触发并通过校验了契约声明的**每一个**响应单元格——契约将来新增操作或状态码,此测试自动变红,覆盖不会静默滑坡。**唯一豁免**:`PATCH /care-reminders/{id}` 的 409(无 version 列,靠并发条件更新守卫落空触发,单线程 MockMvc 无法确定性构造;其行为语义由第一波并发一致性设计与集成测试背书)。 行为语义(状态机迁移合法性、防枚举响应一致性、权限矩阵、幂等键语义)不在本单重复——既有 78 项 pet 集成测试已锁定,本单只锁**结构**。 **有效性自证(mutation check,未入库)**:向快照 Pet schema 注入假必填字段 `bogusDriftField` 后跑测试,9/11 用例即刻红(`$.data.bogusDriftField: 契约必填字段缺失`);还原快照后全绿。校验器确实在咬合,不是恒真。 ## 3. 发现并修复的漂移 | # | 位置 | 契约 | 实现(修复前) | 定性与处理 | | --- | --- | --- | --- | --- | | 1 | `POST /api/v1/pets` 请求体 `sex` | `CreatePetRequest.required` 含 `sex` | `sex` 可缺席,服务端静默补 `unknown` | 结构性漂移,按「以冻结契约为准」修实现:`CreatePetRequest.sex` 加 `@NotBlank`(缺失 400/40000),`PetService` 移除缺省补值;7 个既有测试文件的创建载荷补 `sex` 字段 | 仅此 1 项。其余 17 个操作的请求必填、响应字段名/类型/nullable、错误码值与冻结契约零偏差——第二波「先定型实测行为、再按行为冻结契约」的流程有效。**无语义级冲突**,无需仲裁项。 ## 4. 测试数变化 | 模块 | 之前 | 之后 | 变化 | | --- | --- | --- | --- | | patbond-common | 3 | 3 | — | | patbond-user | 59 | 59 | — | | patbond-auth | 31 | 31 | — | | patbond-pet | 78 | 89 | **+11**(ContractConformanceTest:6 成功形态 + 3 错误信封 + 2 门禁) | | **合计** | **171** | **182** | **+11** | `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test`:BUILD SUCCESS,182 项 0 失败。CI 无需任何改动——契约测试就是普通 surefire 测试,`./mvnw -B clean test` 门禁自动携带。 ## 5. 范围外记录 - **auth 域 6 操作无契约测试**(register/login/refresh/logout/me/trackEvents):M1 交付时无此机制,本单按工单口径不补,**建议 M2 内另立工单**——机制已就绪(快照已含 auth 域全部 schema,`OpenApiContract`/`ContractValidator` 直接复用),估计半天以内,落在 patbond-auth 与 patbond-user 的测试模块。 - **提醒 PATCH 409 豁免**:如后续想消除唯一豁免,可在测试中直接 UPDATE 数据库把提醒改成终态后再以旧状态提交 PATCH,确定性触发守卫落空;本单未做(属行为构造技巧,优先级低)。