Appearance
实战:API 测试
本章导读:规范的 REST API 有一个福利——协议层本身可测试。状态码、头部义务、方法语义、幂等与缓存行为都能用 HTTP 客户端直接断言,不必深入业务。本章给一套从手工调试(cURL)到自动化回归(REST Assured / Newman)的测试打法,并附"规范合规测试用例清单"。
1. 调试层:cURL 打满所有规范点
1.1 基本姿势
bash
BASE=https://api.example.com
TOKEN=$(curl -s -X POST $BASE/v1/sessions \
-H 'Content-Type: application/json' \
-d '{"username":"ma_gua","password":"…"}' | jq -r .accessToken)
# -i 看头部(验证状态码与义务头部是测试的一半)
curl -i $BASE/v1/articles/42 -H "Authorization: Bearer $TOKEN"1.2 逐条验证规范行为
bash
# ① 201 + Location
curl -si -X POST $BASE/v1/articles \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-H 'Idempotency-Key: test-001' \
-d '{"title":"t","content":"c"}' | grep -Ei '^HTTP|^location'
# 期望:HTTP/2 201 / location: /v1/articles/<id>
# ② 幂等重放:同 Key 再发一次 → 返回同一 id、状态码
curl -si -X POST $BASE/v1/articles -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: test-001' \
-d '{"title":"t","content":"c"}' | jq .id
# ③ ETag / If-Match 乐观锁
ETAG=$(curl -sI $BASE/v1/articles/42 -H "Authorization: Bearer $TOKEN" | grep -i ^etag | cut -d' ' -f2 | tr -d '\r')
curl -i -X PUT $BASE/v1/articles/42 -H "Authorization: Bearer $TOKEN" \
-H "If-Match: $ETAG" -H 'Content-Type: application/json' \
-d '{"title":"new","content":"c"}' # → 200,新 ETag
curl -i -X PUT $BASE/v1/articles/42 -H "Authorization: Bearer $TOKEN" \
-H "If-Match: $ETAG" -H 'Content-Type: application/json' \
-d '{"title":"new2","content":"c"}' # → 412(旧 ETag 复用)
curl -i -X PUT $BASE/v1/articles/42 -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -d '{"title":"x","content":"c"}' # → 428 缺 If-Match
# ④ 协商缓存:If-None-Match → 304
curl -i $BASE/v1/articles/42 -H "If-None-Match: $ETAG" # (若未变)→ 304 无 body
# ⑤ 405 义务头
curl -i -X POST $BASE/v1/articles/42 -H "Authorization: Bearer $TOKEN"
# 期望:HTTP/2 405 + allow: GET, PUT, DELETE, OPTIONS…
# ⑥ 错误形状:422 problem+json
curl -si -X POST $BASE/v1/articles -H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' -H 'Idempotency-Key: t-2' \
-d '{"title":"","content":null}' | head -20
# 期望:Content-Type: application/problem+json;含 type/title/status/detail/errors
# ⑦ 方法探测
curl -si -X OPTIONS $BASE/v1/articles/42 | grep -i ^allow
# ⑧ 分页上限:pageSize=101 → 400/422;=0 → 400curl 高效技巧: -w '%{http_code}' 只输出状态码写断言脚本;--fail-with-body 让非 2xx 以非零退出码失败(CI 可用);-o /dev/null -s 静音。
1.3 HTTPie / Apifox / Bruno
- HTTPie 语法更人读:
http POST $BASE/v1/articles title:=null --check-status(:=显式 JSON null); - Apifox/Postman/Bruno:环境变量 + 断言脚本(Postman:
pm.response.to.have.status(201)、pm.expect(pm.response.headers.get('Location')).to.include('/v1/articles/'));Bruno 把集合存进 git——与代码同评审。
2. 自动化层:REST Assured 规范断言(Java)
java
@Test
void createArticle_shouldReturn201WithLocation_andBeIdempotent() {
String key = "it-" + UUID.randomUUID();
// 首次:201 + Location + 资源可读
String loc = given()
.header("Authorization", "Bearer " + token)
.header("Idempotency-Key", key)
.contentType(ContentType.JSON)
.body("""
{ "title": "IT 文章", "content": "hello" }
""")
.when().post("/v1/articles")
.then().statusCode(201)
.header("Location", matchesRegex("/v1/articles/[0-9a-f-]+"))
.contentType(ContentType.JSON)
.body("id", notNullValue())
.extract().header("Location");
// 重放:同键同体 → 重放首次结果(同 id、201)
given().header("Authorization", "Bearer " + token)
.header("Idempotency-Key", key)
.contentType(ContentType.JSON)
.body("""
{ "title": "IT 文章", "content": "hello" }
""")
.when().post("/v1/articles")
.then().statusCode(201).header("Location", equalTo(loc));
// 新位置可读
given().header("Authorization", "Bearer " + token)
.when().get(loc)
.then().statusCode(200).body("status", equalTo("draft"));
}
@Test
void putWithoutIfMatch_shouldReturn428_andWithStaleEtag_shouldReturn412() {
String etag = when().get("/v1/articles/{id}", articleId)
.then().statusCode(200).extract().header("ETag");
given().header("If-Match", etag).contentType(ContentType.JSON)
.body(replaceBody("v2"))
.when().put("/v1/articles/{id}", articleId)
.then().statusCode(200)
.header("ETag", not(equalTo(etag))); // 写后 ETag 必须变化
given().header("If-Match", etag).contentType(ContentType.JSON) // 旧 ETag
.body(replaceBody("v3"))
.when().put("/v1/articles/{id}", articleId)
.then().statusCode(412)
.contentType("application/problem+json")
.body("type", endsWith("/problems/version-conflict"));
given().contentType(ContentType.JSON).body(replaceBody("v4"))
.when().put("/v1/articles/{id}", articleId)
.then().statusCode(428);
}
@Test
void deletedArticle_shouldReturn204_then410() {
given().header("If-Match", etagOf(articleId))
.when().delete("/v1/articles/{id}", articleId)
.then().statusCode(204);
when().get("/v1/articles/{id}", articleId)
.then().statusCode(410);
// 幂等删除:第二次仍"无伤害"(404/410/204 按团队约定断言)
}3. 集合级"规范合规"测试清单
把下表作为每个新资源的验收模板(可脚本化,20 条断言全绿才允许上线):
| # | 断言 | 期望 |
|---|---|---|
| 1 | GET 集合 无认证 | 公开数据 200 / 私有数据 401 + WWW-Authenticate |
| 2 | GET 集合 默认参数 | 200;items 存在;pageSize ≤ 默认值 |
| 3 | pageSize=10000 | 400/422 或被钳制到上限(文档一致) |
| 4 | 未知查询参数 ?foo=1 | 400(策略:拒绝) |
| 5 | GET 不存在 ID | 404 + problem+json |
| 6 | 非法格式 ID(/articles/abc) | 400,提示字段与原因 |
| 7 | POST 缺 Idempotency-Key | 400/428(按规范强制程度) |
| 8 | POST 同键异体 | 422 idempotency-key-reuse |
| 9 | PUT 缺 If-Match | 428;旧 ETag → 412 |
| 10 | PUT 成功后 ETag 变化 | ETag(v2) != ETag(v1) |
| 11 | DELETE 成功 | 204;后续 GET → 410/404(按约定) |
| 12 | OPTIONS | 2xx/405 且 Allow 完整准确 |
| 13 | 不支持的方法 | 405 + Allow |
| 14 | 错误 Content-Type(text/plain) | 415 |
| 15 | Accept: text/csv | 406 |
| 16 | 私有资源响应头 | 含 Cache-Control: private 或 no-store |
| 17 | 500 形状 | 无堆栈;含 traceId;Content-Type: application/problem+json |
| 18 | CORS 预检(OPTIONS + Origin) | 204 + 正确的 Allow-* 头,且无需认证 |
| 19 | 时间字段格式 | 全部匹配 `^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(.\d+)?(Z |
| 20 | 大数 ID 类型 | typeof id === "string"(JSON Schema 断言) |
4. 契约测试:让文档与实现不再分家
方案 A:消费者驱动(Pact) 消费者写期望 → 提供者验证实现符合
方案 B:OpenAPI 回放(Dredd/Postman/SCHTM) 直接用 openapi.yaml 生成请求断言响应 Schema
方案 C:契约即测试数据 用 openapi 的 example 生成 Mock Server,前端先行;
再用 oasdiff 在 CI 拦截破坏性变更(见下一章)REST Assured 侧可叠加 JSON Schema 校验把"结构回归"自动化:
java
given().spec(request)
.when().get("/v1/articles/{id}", id)
.then().statusCode(200)
.body(matchesJsonSchemaInClasspath("schema/article.schema.json"));Schema 文件由 OpenAPI 导出(见下一章),实现"一处定义、三处复用"(文档/Mock/测试)。
5. CI 集成模板
yaml
# .github/workflows/api-test.yml(骨架)
jobs:
api-compliance:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker compose up -d api db
- run: ./gradlew test -Dtags=compliance # §3 清单自动化
- run: npx @apidevtools/swagger-cli validate docs/openapi.yaml
- run: npx oasdiff breaking base.yaml docs/openapi.yaml # 破坏性变更门禁
- run: npx spectral lint docs/openapi.yaml --ruleset .spectral.yml # 风格规则原则:合规测试不进 CI 等于没有测试——设计规范的每一行,都应能找到对应的断言。
6. 本章小结
- cURL 三板斧:
-i/-si看状态码与义务头、幂等键重放实验、If-Match 冲突实验——五分钟摸清一个 API 的"成色"。 - 自动化断言盯协议层行为(状态码/头部/幂等/缓存)而不只是 body 内容——这是 REST 测试区别于普通接口测试的地方。
- 20 条合规清单 + OpenAPI 契约门禁(oasdiff/spectral)= 规范不靠自觉,靠 CI。
7. 下一步
最后一步:把设计文档变成机器可读的 OpenAPI 规范 → 实战:OpenAPI 文档