Skip to content

实战: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 → 400

curl 高效技巧: -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 条断言全绿才允许上线):

#断言期望
1GET 集合 无认证公开数据 200 / 私有数据 401 + WWW-Authenticate
2GET 集合 默认参数200;items 存在;pageSize ≤ 默认值
3pageSize=10000400/422 或被钳制到上限(文档一致)
4未知查询参数 ?foo=1400(策略:拒绝)
5GET 不存在 ID404 + problem+json
6非法格式 ID(/articles/abc400,提示字段与原因
7POST 缺 Idempotency-Key400/428(按规范强制程度)
8POST 同键异体422 idempotency-key-reuse
9PUT 缺 If-Match428;旧 ETag → 412
10PUT 成功后 ETag 变化ETag(v2) != ETag(v1)
11DELETE 成功204;后续 GET → 410/404(按约定)
12OPTIONS2xx/405 且 Allow 完整准确
13不支持的方法405 + Allow
14错误 Content-Type(text/plain)415
15Accept: text/csv406
16私有资源响应头Cache-Control: privateno-store
17500 形状无堆栈;含 traceIdContent-Type: application/problem+json
18CORS 预检(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 文档