编写你的第一批测试用例
预期结果、反向测试、边界值和等价类。
到目前为止,你一直在发送请求并查看返回的内容。当你在发送之前就决定应该返回什么,然后再去比较时,测试才真正开始。把这个决定写下来,就是一个测试用例。这一课讲如何编写测试用例,以及如何选择要写哪些,因为你不可能把所有可能的请求都发一遍。
测试用例包含什么
不同团队使用的模板不同,但几乎都包含同样的几个部分:
| 部分 | 写什么 | 示例 |
|---|---|---|
| ID | 一个简短的编号,方便大家讨论 | PROD-GET-03 |
| 标题 | 用一句话说明检查的内容 | 未知的商品 ID 返回 404 |
| 前置条件 | 开始之前必须满足的条件 | 不存在 ID 为 9999 的商品 |
| 请求 | 方法、URL、请求头和请求体 | GET /api/Products/9999 |
| 预期结果 | 状态码、重要的响应头、响应体 | 404,JSON 响应体的消息中提到 ID 9999 |
| 实际结果 | 执行时填写 | 符合预期 |
预期结果是人们最容易写不好的部分。“返回一个错误”不算预期结果,因为几乎什么结果都能满足它。要具体写明状态码、重要的响应头(通常是 Content-Type,有时是缓存或 Location 响应头)以及响应体:哪些字段必须存在,类型和值分别是什么。如果你说不出正确答案是什么,那你就找到了一个该问开发者或产品负责人的问题,值得在测试任何东西之前先问清楚。
预期结果从哪里来?来自文档、描述该功能的工单、同一 API 中类似端点的行为,有时也来自常识(价格不应该是负数)。当这些来源互相矛盾时,写下你遵循的是哪一个。
正向测试和反向测试
正向测试发送一个有效的请求,检查它是否正常工作:商品 1 存在,所以 GET /api/Products/1 会返回它。反向测试发送一些 API 应该拒绝的内容,检查它是否正确地拒绝:正确的状态码、有用的消息,并且没有副作用。
初学者大多写正向测试,因为他们照着功能描述来写。开发者在交付之前通常已经亲自试过正向路径了,而问题往往是在反向测试中发现的。粗略地说,对于任何接收输入的端点,反向测试的数量应该多于正向测试。
等价类
列表端点 GET /api/Products 接受一个 limit 参数。API 参考文档说它表示每页大小,范围是 1 到 100。你可以发送的值有无穷多个。等价类划分的意思是,把服务器应该同样对待的输入分成一组,每组只测一个值,而不是全部都测:
| 类别 | 示例值 | 预期 |
|---|---|---|
| 有效,在范围内 | limit=10 | 十个商品 |
| 低于范围 | limit=-5 | 被拒绝,或被调整为最小值 |
| 高于范围 | limit=500 | 被拒绝,或被调整为 100 |
| 不是数字 | limit=abc | 以 400 拒绝 |
| 不是整数 | limit=2.5 | 以 400 拒绝 |
| 空值 | limit= | 按未提供处理 |
| 未提供 | 没有 limit | 按文档所述,返回所有商品 |
注意那些“被拒绝,或被调整”的条目。文档没有说明超出范围时会发生什么,所以存在两种合理的行为,两种都说得过去。你要测试的是 API 始终如一地采用其中一种,并且不会崩溃,也不会返回莫名其妙的结果。
边界值
bug 往往集中在范围的边缘,因为开发者正是在这里把本该写 <= 的地方写成了 <。边界值分析的意思是测试边界本身,以及边界两侧各一步的值。对于 1 到 100 的范围,就是 0、1、2、99、100 和 101。下面是这个 API 对这些值以及其他类别的实际处理方式。X-Per-Page 响应头报告服务器实际使用的每页大小。
| 请求 | 状态码 | 返回的条目数 | X-Per-Page |
|---|---|---|---|
?limit=0 | 200 | 1 | 1 |
?limit=1 | 200 | 1 | 1 |
?limit=-1 | 200 | 1 | 1 |
?limit=100 | 200 | 20(全部) | 100 |
?limit=101 | 200 | 20(全部) | 100 |
?limit=abc | 200 | 20 | 20 |
?limit=2.5 | 200 | 20 | 20 |
?limit= | 200 | 20 | 20 |
/api/Products?limit=0解读结果:小于 1 的值被调整为 1,大于 100 的值被调整为 100,任何非整数的值都被忽略,就像没有提供 limit 一样。这些都没有写在文档里,但行为是一致的,所以很可能是有意为之。不过有两点仍然值得在测试报告中记上一笔。limit=0 返回一个条目令人意外:一个请求零个条目的客户端(比如想低成本地读取总数)却收到了一个它不想要的商品。而 limit=abc 被悄悄忽略,意味着客户端里的拼写错误不会被发现,而返回 400 本可以抓住它。
这里还有一个关于测试数据的教训。这个 API 只有 20 个商品,所以 limit=100 和 limit=101 返回的响应体完全相同。仅凭响应体你无法看出 101 被调整过,是响应头揭示了这一点。当边界比你的数据量还大时,要么像这样找另一个信号,要么创建足够多的记录来触及边界。
边界也藏在 ID 里
参考文档把 /api/Products/{id} 中的 id 列为 int32,即最大值为 2,147,483,647 的 32 位整数。这是一个值得测试的边界:
| 请求 | 状态码 | 响应体 |
|---|---|---|
/api/Products/20 | 200 | 商品 20,最后一个 |
/api/Products/21 | 404 | JSON 错误消息 |
/api/Products/0 | 404 | JSON 错误消息 |
/api/Products/-1 | 404 | JSON 错误消息 |
/api/Products/2147483647 | 404 | JSON 错误消息 |
/api/Products/2147483648 | 404 | 空 |
/api/Products/abc | 404 | 空 |
/api/Products/1.5 | 404 | 空 |
/api/Products/01 | 200 | 商品 1 |
有两点很突出。所有不是有效 int32 的值都得到一个完全没有响应体的 404,而有效但不存在的 ID 得到的是 JSON 错误。另外,01 被当作 1 接受了。后者在这里无害,但在按 URL 缓存响应的 API 上,/1 和 /01 会被分别缓存。两个结果都不算明显错误,但都值得了解。
一个端点的一组测试用例
综合起来,GET /api/Products/{id} 的一组合理的初始测试用例如下。这并不是你能测试的全部内容,但它覆盖了每个类别各一次,以及每个边界。
| ID | 请求 | 预期结果 |
|---|---|---|
| PROD-GET-01 | GET /api/Products/1 | 200,JSON,id 为 1,title 是非空字符串,price 是大于 0 的数字 |
| PROD-GET-02 | GET /api/Products/20 | 200,初始数据中最大的 ID |
| PROD-GET-03 | GET /api/Products/21 | 404,JSON 错误响应体中提到 ID 21 |
| PROD-GET-04 | GET /api/Products/0 | 404,JSON 错误响应体 |
| PROD-GET-05 | GET /api/Products/-1 | 404,JSON 错误响应体 |
| PROD-GET-06 | GET /api/Products/abc | 400 或 404,带 JSON 错误响应体 |
| PROD-GET-07 | GET /api/Products/2147483648 | 400 或 404,带 JSON 错误响应体 |
| PROD-GET-08 | HEAD /api/Products/1 | 与 GET 相同的状态码和响应头,没有响应体 |
| PROD-GET-09 | GET /api/Products/1 两次 | 两次的响应体完全相同 |
在当前的 API 上,PROD-GET-06 和 07 会失败,因为响应体是空的。PROD-GET-08 也会失败:HEAD /api/Products/1 返回 405 Method Not Allowed,而像 /api/Books/{id}登录后查看 这样较新的资源支持 HEAD。这里是否应该支持 HEAD,是要问 API 负责人的问题。这没关系。测试用例记录的是应该发生什么;失败的测试用例正是你发现它没有发生的方式。PROD-GET-02 有一个弱点:如果有人删除了商品 20 或添加了商品 21,它就不再代表它所说的含义了。下一课会介绍如何创建你自己的测试数据,让测试不依赖别人控制的记录。
撰写 bug 报告
测试用例失败时,你要把它写成报告。好的 bug 报告能让开发者不用问你任何问题,就在一分钟内复现问题。以上面那些空的 404 为例,先比较这两个响应:
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"status":404,"error":"Not Found","message":"Product with ID 9999 does not exist."}HTTP/1.1 404 Not Found
Content-Length: 0一份报告可以这样写(这里用的是英文,许多团队都这样写):
Title: GET /api/Products/{id} returns an empty 404 for non-numeric
and out-of-range IDs, instead of the JSON error body
Environment: https://api.testingapis.com, 30 Sep 2026
Steps:
curl -i https://api.testingapis.com/api/Products/9999
curl -i https://api.testingapis.com/api/Products/abc
curl -i https://api.testingapis.com/api/Products/2147483648
Expected:
All three return 404 with Content-Type: application/json and a body
like {"status":404,"error":"Not Found","message":"..."}, as documented
under "Responses and errors" in the API reference.
Actual:
9999 404, JSON body with a message
abc 404, Content-Length: 0, no Content-Type, no body
2147483648 404, empty body (one above the int32 maximum; 2147483647
returns the JSON error)
Impact:
Clients that parse every error body as JSON fail on the empty
responses. The empty 404 also looks like "this URL does not exist"
rather than "this product does not exist".
Notes:
Probably the route only matches int32 values, so other input never
reaches the controller. Could be intended; if so, the docs should say
which errors have no body.这份报告有用的原因:
- 标题说明了哪里出了什么问题,所以在五十个工单的列表里也一目了然。
- 步骤是可以直接粘贴的命令,而不是“用一个错误的 ID 调用商品端点”。
- 预期行为指向了一个来源(文档),所以不只是你的个人偏好。
- 它展示的是模式,而不是单个例子。三个请求,其中一个正常工作用来对照,比一个失败的请求更有说服力。
- 它说明了对真实客户端的影响。开发者按影响来排优先级,光说“不一致”很少会被修复。
- 它承认了不确定性。这种行为可能是有意的,报告说明了两种情况下怎样解决(修改代码,或者修改文档)。