接口测试别只断言 200:Postman / HttpRunner / Pact / k6 对比与断言设计避坑
上周三下午,订单服务返回了 HTTP/1.1 200 OK,body 里是 {"code":50001,"msg":"库存不足"}。前端老周只判断了 res.status === 200,于是购物车被清空、订单没生成、用户打了客服电话。复盘的时候我问测试同学为什么没测出来,他说“接口是通的啊,返回 200”。那天晚上我把我们组 267 条接口用例导出来跑了个统计:198 条的断言只有一句 assert resp.status_code == 200,占比 74%。剩下 69 条里,又有 51 条断言了 body 里某个字段等于一个写死的值。
这个数字比什么“覆盖率”都刺眼。接口测试真正的问题从来不是“跑不跑得通”,而是你把断言下在哪儿。下面这些是我踩了三年的坑和现在的做法,不一定对,但都是真的花过时间的。
一、工具选型这事,别听人吹,看你团队的钱和痛
先声明一下,我不用“最好”这个词,选型是成本问题不是技术问题。
Postman 依然是覆盖面最广的那一个。Collection 格式 v2.1.0 基本成了事实标准,Apifox、Insomnia、Bruno 都能导。但有几个具体的东西要注意:免费版 Collection Run 有月度配额,团队协作要付费;2023 年 5 月它把 Scratch Pad 砍掉那波,社区骂了快一个月才恢复,这件事说明你的测试资产如果强绑定在一家 SaaS 上是有风险的。CI 里跑用 Newman,命令行大概是:
newman run collection.json -e env.json --reporters cli,htmlextra --timeout-request 5000 --delay-request 200
--timeout-request 一定要设,默认是 0 也就是不超时,一个挂起的请求能把你的流水线卡到天荒地老。--delay-request 在打被测环境比较弱的时候有用,200ms 能避免你自己把环境压挂然后以为是 bug。
Bruno 是 2023 年底冒出来的开源客户端,.bru 纯文本存本地,git diff 很干净,完全离线。适合那种“公司不让把接口数据传到外部 SaaS”的团队。缺点是生态还小,脚本 API 和 Postman 的 pm.* 不兼容,迁移得重写。HttpRunner 是国内团队做的,v4 之后 CLI 是 Go 写的(hrp run),不依赖 Python 运行时,在 CI 镜像里很香,写法是 YAML 加内置函数,${get_timestamp()}、${random_string(8)} 这类。但 YAML 写复杂逻辑会变成灾难,超过 20 行判断的用例我建议直接下沉到 pytest。
如果团队是 Java 栈,RestAssured 其实是最省事的,given().when().then() 那套配合 JUnit 5 和 Allure,报告直接出。Karate 值得一提,它自带 mock server,karate.callSingle 可以跨 feature 复用登录态,不用每个用例都去拿 token,这个细节能把整套用例的执行时间砍掉一大截,尤其是鉴权走 OAuth2 那种。性能这块 k6 现在是主流,JS 写脚本,thresholds 直接当断言用:
export const options = {
thresholds: { http_req_duration: ['p(95)<500', 'p(99)<1200'], http_req_failed: ['rate<0.01'] },
};
注意 http_req_duration 是含 DNS + 建连 + TLS + 首字节 + 下载的总时间,你想拆开看要看 http_req_blocked、http_req_connecting、http_req_tls_handshaking、http_req_waiting 这几个指标。很多人拿 p95 说事,其实 TLS 握手就占了 80ms 都没意识到。
二、断言分三层,绝大多数人只写了半层
我的观点是:断言分三层——状态码层、结构层、语义层。大部分团队卡在第一层就不动了。
结构层用 JSON Schema。注意 additionalProperties: false 是个双刃剑,它能在后端偷偷加字段时报警,但也会在你灰度发版的时候把老版本客户端搞挂。我的做法是核心接口用 false,边缘接口用 true,然后单独写一条“字段变更监控”的用例。Java 那边有个东西很多人不知道,JSONAssert 的 LENIENT 模式:
JSONAssert.assertEquals(expect, actual, JSONCompareMode.LENIENT);
它允许实际结果比预期多字段,但不能少。这个语义恰好就是“接口向后兼容”的定义,比你自己写一堆 assertThat(json).has("xxx") 强得多。
语义层的坑最多,说三个具体的:浮点数别用 ==,用 abs(a-b) < 1e-6 或者 Hamcrest 的 closeTo;时间字段别写死,用正则或者 schema 的 format: date-time;分页接口一定要测“空页”和“最后一页”,total 和 list.length 不一致的 bug 我至少见过三次。还有个国内特别普遍的事——业务错误码和 HTTP 状态码打架。大量 API 返回 200 加一个 code: 500,这不是对错问题,是约定问题,但测试必须两种都断言。我的模板是状态码用参数化,业务码用数据驱动,两者组合成矩阵。
三、比接口测试更值钱的,是让接口自己带合同
这是我想说的核心观点,可能会得罪人:大部分团队的接口自动化资产,本质上是负资产。因为断言写在用例里,接口一改,你得去改 200 个用例文件。正确的做法是把断言搬到契约里,用例只负责数据编排。
Pact 那套 consumer-driven contract 就是这个思路:消费方定义期望,pact-broker 存起来,提供方 CI 里跑 can-i-deploy,不通过就别发。代价是需要消费方配合,小团队推不动。推不动怎么办?退一步——把 OpenAPI 3.1 当单一事实来源,然后用 Schemathesis 做属性测试:
schemathesis run ./openapi.yaml --checks all --hypothesis-max-examples=200 --base-url http://127.0.0.1:8080
它会根据你的 schema 自动构造边界数据,空字符串、超大整数、畸形 Unicode 这些,你自己写用例基本写不出来。我拿它在一个 60 个接口的服务上跑,10 分钟出了 7 个 500,其中 3 个是 null 没判。
四、数据、Mock 和故障注入,接口测试真正难的地方
接口测试的复杂度不在请求,在依赖。数据库用 Testcontainers 起真的容器,别用 H2,H2 和 MySQL 在 group by 配合 only_full_group_by 上的行为不一样,你测过了线上照样炸。Testcontainers 启动一个 MySQL 8.0 大概 8 到 15 秒,加上 withReuse(true) 能复用容器,整套用例时间大概能降 40%。
下游服务用 WireMock,记得开 --global-response-templating,然后就能用 {{now}}、{{randomValue type='UUID'}} 这些模板。它的 __admin/mappings 接口可以在运行时动态改桩,做“下游返回 500”的场景不用重启。故障注入用 Toxiproxy:
toxiproxy-cli create mysql -l 127.0.0.1:26379 -u 127.0.0.1:3306
toxiproxy-cli toxic add mysql -t latency -a latency=2000 -a jitter=500
2000ms 延迟加 500ms 抖动,能测出你客户端超时设置到底合不合理。我见过最典型的 bug 是客户端超时 3s、重试 3 次、下游 2.5s 慢响应,一次用户点击打了 9 次请求出去。
五、安全相关的两个,顺手就能测
一是越权。准备 A、B 两个账号各建一条资源,然后拿 A 的 token 去 GET B 的资源 ID。如果返回 200 或 403 而不是 404,说明你在泄露资源存在性。这个测试一条代码都不用写,纯手工,但 80% 的项目第一次测都会中。二是幂等。所有 POST/PUT 的支付、下单类接口,用 Idempotency-Key 头重放两次,看是不是只产生一条记录,Stripe 那套做法现在国内不少支付网关也在抄。
六、最后说点不好听的
我们组现在有一份 46 条的冒烟集,跑完 90 秒,卡在每次 MR 上。另外一份 200 多条的全量集每天晚上跑,但我不看通过率,我看两个东西:误报率和平均修复时间。如果一份接口测试集误报率超过 10%,团队一个月内就会开始无视它,第三个月就没人维护了。这时候增加用例数是负收益,宁可 46 条一直绿,也不要 267 条天天红。
至于覆盖率,接口测试的覆盖率是个伪指标。一个接口被调用不等于被验证。我宁可跟踪“断言密度”,也就是每条用例平均几个有效断言,这个数字到 3 以上,心里才踏实。