主题
界面
主题
接口测试的三个"进阶话题"——它们都源于同一个现实:
接口不是孤立的:有先后依赖(先登录后下单)、有重复调用(用户会连点)、有第三方消费(前端依赖你的字段)。 ✓
| 话题 | 对应的现实 |
|---|---|
| 接口依赖 | "先创建订单,才能查询它" ——测试怎么组织? |
| 幂等性 | 用户双击提交、网络重试——重复调用会怎样?★ |
| 契约测试 | 前端依赖你的字段名——改了谁发现?★ |
extract() 吗?(T2-005 的 5.4 节)★一个真实的"幂等性事故":
用户下单 → 网络超时 → 前端自动重试 → 后端又创建了一单
用户看到:两笔订单 ✗✗
财务对账:多了一笔 ✗✗✗根因:POST 不是幂等的,而"重试"在分布式系统里必然发生 ✗
"网络会失败,客户端会重试"是分布式系统的基本假设 ✓
幂等性不是"锦上添花",是接口的"生存能力" ★★★
接口之间的依赖是客观存在的:
查询依赖创建(没有订单查什么)
删除依赖创建
支付依赖订单存在处理原则:依赖发生在"一个测试内部"是合法的 ✓
@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) ⚠️(顺序化有代价,优先用生命周期法 ✓)
定义:同一个操作执行一次与执行多次,结果相同 ✓
| 方法 | 天然幂等吗 | 说明 |
|---|---|---|
| GET | ✅ 是 | 只读 ✓ |
| PUT(整体替换) | ✅ 是 | 设为同一状态 N 次 = 一次 ✓ |
| DELETE | ✅ 是 | 删 N 次 = 删一次(第二次 404 也算"达到同一结果")✓ |
| POST | ❌ 不是 | N 次 = N 笔订单 ✗✗ |
★ POST 是唯一"天生不幂等"的 ——而它恰恰是"创建"这类高危操作 ✗
实测思路(为 DELETE 设计幂等用例):
// ① 删除一次 → 204
// ② 再删一次 → 404(或 204——两种设计,见下)
// ③ 断言:资源状态与"删一次"后一致 ✓(这就是幂等的定义)"重复 DELETE 返回什么"的两种设计:
| 设计 | 第二次返回 | 理由 |
|---|---|---|
| 404 | 明确"资源不存在" | 客户端能感知"已经没了" ✓(本项目采用 ✓) |
| 204 | 幂等语义的极端解读 | 客户端无法区分"删成功"与"本来就没有" ⚠️ |
两种都符合幂等定义 ✓ ——重要的是"团队约定并写进文档"(《接口文档与前后端联调》)✓
客户端生成唯一 key(如 UUID)随请求携带
服务端:key 已存在 → 返回第一次的结果(不重复执行)✓// 客户端
given().header("Idempotency-Key", "client-order-20260921-001")
.body(createRequest)
.when().post("/api/orders")
.then().statusCode(201);
// ★ 网络超时后重试:带同一个 key → 服务端返回同一笔订单(不重复建)✓✓服务端的实现思路:
接收请求 → 检查幂等键表 → 存在则返回缓存结果
→ 不存在则执行业务 + 记录 key + 结果(同一事务)✓这是支付、订单等"高危写操作"的行业标准做法 ✓✓(Stripe 等平台的 API 都有 Idempotency-Key)
回顾 T2-001 与 《Ajax 与前后端交互》:
| 接口测试(本阶段) | 契约测试 | |
|---|---|---|
| 验证 | "我的接口按我的预期工作" | "消费方的期望与我的实现一致" ★ |
| 谁来定义 | 后端 | 双方共同 ✓ |
| 改字段名会怎样 | 文档更新,没人红 ⚠️ | 消费方的契约变红 ✓✓ |
契约的最小形式(不必上重型工具):
// "消费方(前端)声明的契约"——由后端 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):
① 消费方跑测试 → 生成"契约文件"(pact 文件:我对接口的期望)
② 契约文件推送到 Pact Broker
③ 提供方拉取契约 → 在 CI 里"对着契约"验证自己的实现
④ 双方不匹配 → 双方 CI 都红 ✓实践建议:
| 团队形态 | 建议 |
|---|---|
| 同团队前后端 | 手写契约测试(如上)就够 ✓ |
| 跨团队 / 对外 API | Pact / OpenAPI 契约校验 ✓ |
| 先做接口测试再谈契约 | T2-001、T2-005 是基础 ✓(别跳级 ✗) |
《接口文档与前后端联调》 生成了 OpenAPI 文档 ——它可以被"校验" ✓
// 校验"实际实现与 OpenAPI 文档一致"(springdoc-openapi + openapi3 验证器思路)
// 文档漂移(改了实现没更新文档)会被拦住 ✓实践建议:文档即契约的一部分——文档与实现的一致性也要有测试 ✓
delete("/orders/ORD-1") → 204
delete("/orders/ORD-1") → 404 // ★ 重复调用安全 ✓@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 请求映射与参数绑定》 的进阶任务)⚠️
static String deletedOrderNo; // ✗ A 测试删了它,B 测试还在用 ✗✗修:生命周期法(5.1)✓
文件:labs/spring-boot-web/src/test/java/cn/kb/learn/web/OrderIdempotencyTest.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 ✓
POST → POST 的两个 orderNo(不幂等的现场)✓;| # | 错误 | 说明 |
|---|---|---|
| 1 | 以为 PUT 幂等就安全 | PUT 到一个"会自动创建"的端点上,语义是 UPSERT ⚠️ ——仍要设计好 ✓ |
| 2 | 重试 POST 却没有幂等键 ✗ | 重复下单(4 的根因)✗ |
| 3 | 跨测试共享"被删除的资源" ✗ | 顺序依赖(6.3)✗ |
| 4 | 契约测试一开始就上重型工具 ✗ | 先手写最小契约(5.3)✓ |
| 5 | 幂等键用"请求体 hash" ⚠️ | 相同的"合法重复下单"会被当成重试 ⚠️——key 应由客户端生成(业务上区分"新请求"与"重试") ✓ |
orders_idempotency 表(key 唯一约束 + 返回的 orderNo);POST 带 Idempotency-Key 时先查表:存在则返回旧结果 ✓;扩展任务:为"支付回调"设计幂等方案:
DELETE /orders/1 第二次返回 404,说明这个接口不幂等。(对 / 错)full_lifecycle 一个方法四个断言,review 该不该批?说明你的取舍。POST /likes 重复点赞会怎样?),并写出用例。set -e 思路)✓ 取舍:生命周期测试(合并)vs 拆分测试(顺序化)——合并是首选(T1-007 的顺序化代价)✓《接口测试报告与失败分析》:跑起来了之后,"结果"怎么呈现、"失败"怎么分析。
暂无