Skip to content

接口依赖、幂等性与契约测试 ​

T2-007建议学高级约 60 分钟JDK 21测试专项 B:接口·UI·性能 · 接口自动化

1. 本节定位 ​

接口测试的三个"进阶话题"——它们都源于同一个现实:

接口不是孤立的:有先后依赖(先登录后下单)、有重复调用(用户会连点)、有第三方消费(前端依赖你的字段)。 ✓

话题对应的现实
接口依赖"先创建订单,才能查询它" ——测试怎么组织?
幂等性用户双击提交、网络重试——重复调用会怎样?★
契约测试前端依赖你的字段名——改了谁发现?★

2. 学习目标 ​

  • 会组织"有依赖的接口测试"(生命周期测试)★★;
  • 说出幂等性的定义,并为 DELETE / PUT / POST 设计幂等性用例 ★★★;
  • 说出"为什么 POST 默认不幂等、怎么让它幂等"(幂等键)★★;
  • 说出契约测试与接口测试的区别 ★★;
  • 知道 Pact 类工具的原理(概念级)。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

一个真实的"幂等性事故":

text
用户下单 → 网络超时 → 前端自动重试 → 后端又创建了一单
用户看到:两笔订单 ✗✗
财务对账:多了一笔 ✗✗✗

根因:POST 不是幂等的,而"重试"在分布式系统里必然发生 ✗

"网络会失败,客户端会重试"是分布式系统的基本假设 ✓

幂等性不是"锦上添花",是接口的"生存能力" ★★★

5. 核心概念 ​

5.1 接口依赖与生命周期测试 ★★ ​

接口之间的依赖是客观存在的:

text
查询依赖创建(没有订单查什么)
删除依赖创建
支付依赖订单存在

处理原则:依赖发生在"一个测试内部"是合法的 ✓

java
@Test void order_lifecycle() {          // ★ 依赖全部在"本测试内"完成 ✓
    String no = create(...);            // 依赖 1
    Order o = get(no);                  // 依赖创建的结果 ✓
    delete(no);
    get(no) → 404;                      // 依赖删除的结果 ✓
}

三个原则:

原则说明
依赖可以,跨测试依赖不行 ★T1-007 的 I 原则 ✓
依赖的数据"自建"不依赖"别人留下的"(T2-006)✓
生命周期测试 ≠ 所有接口测试只有关键流程需要;每个接口的独立测试照旧 ✓

⚠️ 一个常见争论:"生命周期测试一个方法跑四个断言,失败时不知道挂在哪一步" ⚠️

解法:每步断言给出清晰的描述(REST Assured 的 .log()、断言消息)✓ ——或者拆成"共享数据的多个测试"(用 @TestMethodOrder) ⚠️(顺序化有代价,优先用生命周期法 ✓)

5.2 幂等性 ★★★ ​

定义:同一个操作执行一次与执行多次,结果相同 ✓

方法天然幂等吗说明
GET✅ 是只读 ✓
PUT(整体替换)✅ 是设为同一状态 N 次 = 一次 ✓
DELETE✅ 是删 N 次 = 删一次(第二次 404 也算"达到同一结果")✓
POST❌ 不是N 次 = N 笔订单 ✗✗

★ POST 是唯一"天生不幂等"的 ——而它恰恰是"创建"这类高危操作 ✗

实测思路(为 DELETE 设计幂等用例):

java
// ① 删除一次 → 204
// ② 再删一次 → 404(或 204——两种设计,见下)
// ③ 断言:资源状态与"删一次"后一致 ✓(这就是幂等的定义)

"重复 DELETE 返回什么"的两种设计:

设计第二次返回理由
404明确"资源不存在"客户端能感知"已经没了" ✓(本项目采用 ✓)
204幂等语义的极端解读客户端无法区分"删成功"与"本来就没有" ⚠️

两种都符合幂等定义 ✓ ——重要的是"团队约定并写进文档"(《接口文档与前后端联调》)✓

让 POST 幂等:幂等键(Idempotency Key)★★ ​

text
客户端生成唯一 key(如 UUID)随请求携带
服务端:key 已存在 → 返回第一次的结果(不重复执行)✓
java
// 客户端
given().header("Idempotency-Key", "client-order-20260921-001")
       .body(createRequest)
   .when().post("/api/orders")
   .then().statusCode(201);

// ★ 网络超时后重试:带同一个 key → 服务端返回同一笔订单(不重复建)✓✓

服务端的实现思路:

text
接收请求 → 检查幂等键表 → 存在则返回缓存结果
                        → 不存在则执行业务 + 记录 key + 结果(同一事务)✓

这是支付、订单等"高危写操作"的行业标准做法 ✓✓(Stripe 等平台的 API 都有 Idempotency-Key)

5.3 契约测试 ★★ ​

回顾 T2-001 与 《Ajax 与前后端交互》:

接口测试(本阶段)契约测试
验证"我的接口按我的预期工作""消费方的期望与我的实现一致" ★
谁来定义后端双方共同 ✓
改字段名会怎样文档更新,没人红 ⚠️消费方的契约变红 ✓✓

契约的最小形式(不必上重型工具):

java
// "消费方(前端)声明的契约"——由后端 CI 执行 ✓
@Test void contract_order_fields_for_list_page() {
    given().when().get("/api/orders")
    .then().statusCode(200)
        .body("data[0]", hasKey("orderNo"))          // 前端列表需要
        .body("data[0]", hasKey("amountInCents"))    // 前端金额展示需要
        .body("data[0]", hasKey("status"));           // 前端状态徽标需要
}

★ 这就是"消费方驱动的契约测试"(CDC)的最小实现:

前端把"我需要哪些字段"写成测试,放进后端的 CI ——后端删字段时,CI 直接红 ✓✓

T2-001 综合任务里"契约测试在变更流程中的角色"在这里落地 ✓

重型工具(Pact):

text
① 消费方跑测试 → 生成"契约文件"(pact 文件:我对接口的期望)
② 契约文件推送到 Pact Broker
③ 提供方拉取契约 → 在 CI 里"对着契约"验证自己的实现
④ 双方不匹配 → 双方 CI 都红 ✓

实践建议:

团队形态建议
同团队前后端手写契约测试(如上)就够 ✓
跨团队 / 对外 APIPact / OpenAPI 契约校验 ✓
先做接口测试再谈契约T2-001、T2-005 是基础 ✓(别跳级 ✗)

5.4 接口文档与契约的自动化校验 ★ ​

《接口文档与前后端联调》 生成了 OpenAPI 文档 ——它可以被"校验" ✓

java
// 校验"实际实现与 OpenAPI 文档一致"(springdoc-openapi + openapi3 验证器思路)
// 文档漂移(改了实现没更新文档)会被拦住 ✓

实践建议:文档即契约的一部分——文档与实现的一致性也要有测试 ✓

6. 递进示例 ​

6.1 最小示例:幂等 DELETE ​

java
delete("/orders/ORD-1")  → 204
delete("/orders/ORD-1")  → 404        // ★ 重复调用安全 ✓

6.2 业务示例:幂等键的测试 ​

java
@Test void idempotent_create_with_key() {
    String key = "key-" + UUID.randomUUID();
    given().header("Idempotency-Key", key).contentType(JSON).body(REQ)
           .when().post("/api/orders").then().statusCode(201);
    // ★ 同 key 重试:应返回同一笔订单(orderNo 相同)而不是新建 ✗✗
    given().header("Idempotency-Key", key).contentType(JSON).body(REQ)
           .when().post("/api/orders")
           .then().statusCode(201)
           .body("orderNo", equalTo(firstOrderNo));      // ★ 同一单 ✓
}

**(这需要后端实现幂等键表——是 《Spring MVC 请求映射与参数绑定》 的进阶任务)⚠️

6.3 反例:测试间共享"已删除"的资源 ​

java
static String deletedOrderNo;      // ✗ A 测试删了它,B 测试还在用 ✗✗

修:生命周期法(5.1)✓

7. 完整可运行代码 ​

文件:labs/spring-boot-web/src/test/java/cn/kb/learn/web/OrderIdempotencyTest.java

java
@SpringBootTest(webEnvironment = RANDOM_PORT)
@DisplayName("幂等性测试")
class OrderIdempotencyTest {

    @LocalServerPort int port;

    @BeforeEach void setUp() { RestAssured.port = port; }

    @Test @DisplayName("GET 是幂等的:查 N 次结果一致")
    void get_is_idempotent() {
        String no = create();                    // [T2-006](./T2-006) 的自建数据 ✓
        for (int i = 0; i < 3; i++) {
            given().when().get("/api/orders/" + no)
                   .then().statusCode(200)
                   .body("amountInCents", equalTo(19900));   // ★ 每次结果一致 ✓
        }
    }

    @Test @DisplayName("DELETE 是幂等的:重复删除得到一致结果")
    void delete_is_idempotent() {
        String no = create();
        given().when().delete("/api/orders/" + no).then().statusCode(204);   // 第一次 204
        given().when().delete("/api/orders/" + no).then().statusCode(404);   // 第二次 404
        // ★ 两种返回都符合"幂等"——**关键是团队约定并一致** ✓
    }

    @Test @DisplayName("POST 不是幂等的:重复提交产生多笔订单 ✗(现状验证)")
    void post_is_not_idempotent() {
        String first  = create();
        String second = create();                  // ★ 同样的请求体再发一次
        assertThat(second).isNotEqualTo(first);    // ← 生成了**两笔**不同的订单 ✗
        // 这就是"用户双击提交两笔订单"的根因 ★(修复思路见 T2-007 的 5.2 幂等键)
    }

    private String create() {
        return given().contentType(JSON)
            .body("{\"productId\":\"P-ID\",\"quantity\":1,\"address\":\"测试\"}")
        .when().post("/api/orders")
        .then().statusCode(201).extract().jsonPath().getString("orderNo");
    }
}

★ 第 3 个测试是"故意暴露问题":它证明了"现状不幂等" ✓ ——写一个"暴露缺陷"的测试,是推动修复的最好方式 ✓✓

运行:**总测试数增至 124 ✓

8. Debug 观察点 ​

  1. 对比 rebase 前后 POST:POST → POST 的两个 orderNo(不幂等的现场)✓;
  2. PUT 两次同一内容:观察第二次也是 200 且内容一致(幂等 ✓);
  3. DELETE 两次:记录两种返回码的取舍(5.2)✓;
  4. 在 jstack 里看"幂等键实现"的并发:两个并发请求同时到达 → 没有锁时会双写 ⚠️(幂等键表要加唯一约束 ✓,《Linux 文件、用户与权限管理》)。

9. 常见错误 ​

#错误说明
1以为 PUT 幂等就安全PUT 到一个"会自动创建"的端点上,语义是 UPSERT ⚠️ ——仍要设计好 ✓
2重试 POST 却没有幂等键 ✗重复下单(4 的根因)✗
3跨测试共享"被删除的资源" ✗顺序依赖(6.3)✗
4契约测试一开始就上重型工具 ✗先手写最小契约(5.3)✓
5幂等键用"请求体 hash" ⚠️相同的"合法重复下单"会被当成重试 ⚠️——key 应由客户端生成(业务上区分"新请求"与"重试") ✓

10. 动手实验 ​

  1. 跑第 7 节的 3 个幂等测试 ✓;
  2. 设计并实现幂等键:
    • 加 orders_idempotency 表(key 唯一约束 + 返回的 orderNo);
    • POST 带 Idempotency-Key 时先查表:存在则返回旧结果 ✓;
    • 重跑第 3 个测试改造版:同一 key 两次 → 同一 orderNo ✓✓;
  3. 验证"唯一约束防并发双写":并发发两个同 key 请求——观察数据库唯一约束的兜底 ★(《索引》)✓。

扩展任务:为"支付回调"设计幂等方案:

  • 回调可能重发(支付平台的规范)★;
  • 要求:用"支付单号 + 状态"做幂等(已处理直接返回成功)✓;
  • 说明:这与"幂等键"的异同(本质都是"用唯一标识 + 存储 + 原子性" ✓)。

11. 练习题 ​

  1. 判断:DELETE /orders/1 第二次返回 404,说明这个接口不幂等。(对 / 错)
  2. 填空:HTTP 方法中天然幂等的是 、、______;不幂等的是 ______。
  3. 简答:为什么"网络会失败,客户端会重试"是分布式系统的基本假设?
  4. (代码阅读)接口测试里 full_lifecycle 一个方法四个断言,review 该不该批?说明你的取舍。
  5. (编程)为"点赞/取消点赞"接口设计幂等语义(POST /likes 重复点赞会怎样?),并写出用例。
参考答案(要点)
  1. 错。幂等的定义是"结果一致" ——404(资源已不存在)与 204(删过)都达到了同一终态 ✓(5.2)
  2. GET、PUT、DELETE;POST ✓
  3. 因为"失败与重试"是网络编程的基本现实 ✗ 没有重试 → 用户看到失败但后端其实成功了(结果丢失) ✗ 有重试 → 必须幂等,否则重复执行 ✗✗ "至少一次投递 + 幂等消费"是分布式消息的标准组合 ✓
  4. 该批——但有条件:断言要有清晰描述、中间步骤失败要"快速失败"(set -e 思路)✓ 取舍:生命周期测试(合并)vs 拆分测试(顺序化)——合并是首选(T1-007 的顺序化代价)✓
  5. 要点:"重复点赞"应该幂等(状态不变/返回当前状态)——用"唯一约束 (user, article) + ON CONFLICT 忽略"实现 ✓ 用例:首次点赞 → 201;重复点赞 → 200(幂等)而非重复记录 ✓✓

12. 掌握标准 ​

  • [ ] 能组织"有依赖的接口测试"(生命周期法)★★;
  • [ ] 能说出幂等性的定义与各 HTTP 方法的天然属性 ★★★;
  • [ ] 会设计幂等键方案并测试它 ★★;
  • [ ] 能区分接口测试与契约测试 ★★;
  • [ ] 知道"用暴露缺陷的测试推动修复"的做法 ✓;
  • [ ] 知道"契约测试从最小形式开始,别跳级" ✓。

13. 扩展资源 ​

14. 下一步学习 ​

《接口测试报告与失败分析》:跑起来了之后,"结果"怎么呈现、"失败"怎么分析。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引