筛选、排序和分页
那些直到第 3 页才有人注意到的列表端点 bug。
列表端点看起来很简单:请求商品,得到商品。然后列表变得太大,一个响应装不下了,用户想按价格排序、按类别筛选、按名称搜索,而每一个功能都是一小段有着自己边界情况的代码。列表的 bug 很隐蔽,因为响应看起来总是合情合理的。一页放错了十个商品,仍然是一页十个商品。
本课使用 /api/Products(初始数据中有 20 个商品),以及 /api/paginationdocs 下的演示端点。同样的参数适用于这个 API 中的每个列表,API 参考文档对它们有说明。
| 参数 | 在这里的作用 |
|---|---|
limit | 每页大小,1 到 100。不带它,你会得到全部数据。 |
page | 从 1 开始的页码,与 limit 配合使用。 |
offset | 跳过这么多条记录,可以替代 page。 |
sort, order | 按任意字段排序,asc(默认)或 desc。 |
q | 在 title、description 等文本字段中进行不区分大小写的搜索。 |
任意字段名 | 精确且不区分大小写的筛选,比如 ?category=books。 |
分页
每页请求三个商品,除了响应体,也要看看响应头:
/api/Products?limit=3&page=1二十个商品每页三个,共七页,最后一页有两个。最后一页是寻找 bug 的首选之处,因为只有在这种情况下页面才没有填满:
$ curl -i "https://api.testingapis.com/api/Products?limit=3&page=7"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
x-page: 7
x-per-page: 3
x-total-count: 20
x-total-pages: 7
[{"id":19,"title":"Non-Slip Yoga Mat",...},{"id":20,"title":"Resistance Bands Set",...}]正确:七页,最后一页有商品 19 和 20。这里有个经典的 bug:用整数除法计算页数(20 / 3 = 6),这会让逐页浏览的人看不到最后两个商品。
超出范围的值
接下来,发送文档说无效的值,或者文档根本没有提到的值。这个 API 是这样处理它们的:
?limit=3&page=8 200, x-page: 8, body []
?limit=3&page=0 200, x-page: 1, same as page 1
?limit=0 200, x-per-page: 1
?limit=-1 200, x-per-page: 1
?limit=101 200, x-per-page: 100
?limit=abc 200, x-per-page: 20 (limit ignored, all 20 returned)服务器从不拒绝。它会把错误的值调整为最接近的有效值,或者干脆忽略。这是一种正当的设计,对客户端也很友好,但测试应该把它记录下来,因为另一种做法(返回一个说明有效范围的 400)同样常见。危险的情况是这种调整掩盖了客户端的 bug。一个因为模板错误而发送 limit=abc 的客户端,在这里会拿到全部 20 条记录。如果换成一张有两百万行的表,同样的行为就意味着服务器会试图把所有行都返回。
超出末尾的页面会返回一个空列表和 200。大多数 API 都是这样做的,这也是那些一直翻页直到拿不到数据的客户端所期望的。对空页面返回 404 也说得过去,返回 500 则不行。
不遗漏,不重复
最重要的分页测试也是最容易描述的:遍历每一页,收集 ID,检查每条记录都恰好拿到了一次。把收集到的数量与 X-Total-Count 比较。
async function allPages(path, limit) {
const seen = [];
for (let page = 1; ; page++) {
const res = await fetch(`https://api.testingapis.com${path}?limit=${limit}&page=${page}`);
const items = await res.json();
if (items.length === 0) break;
seen.push(...items.map((item) => item.id));
}
return seen;
}
const ids = await allPages("/api/Products", 3);
const total = 20; // from X-Total-Count
console.log(ids.length === total, new Set(ids).size === ids.length);用几种不同的每页大小运行它:1、3、7,以及一个比整个列表还大的值。偏移量计算中的差一错误往往只在特定大小下才会出现。如果 API 同时支持 page 和 offset,检查 limit=5&page=3 和 limit=5&offset=10 是否返回相同的记录。
只有在运行期间没有人修改数据时,这个测试才能稳定通过。在像这样的共享服务器上,可能会有人修改。这就引出了页码的主要弱点,下文会讲到。
排序
按价格降序排序,检查整个结果的顺序,而不只是第一个条目:
/api/Products?sort=price&order=desc&limit=5前五个依次是 249.99、199.99、89.99、89.99、59.99。商品 3 和 8 的价格相同。并列的情况对分页很重要:如果服务器不以固定的方式处理并列,同一个商品可能既出现在第 1 页的末尾,又出现在第 2 页的开头,或者两页都不出现。除非查询加上第二个排序键(通常是 ID),大多数数据库对相等行的顺序不作任何保证。
按类别排序可以看出这个 API 的处理方式。在每个类别内,商品始终按 ID 顺序排列,升序和降序都是如此:
?sort=category
10:books 11:books 12:books 13:books 6:clothing 7:clothing 8:clothing 9:clothing
1:electronics 2:electronics 3:electronics 4:electronics 5:electronics 14:home ...
?sort=category&order=desc
18:sports 19:sports 20:sports 14:home 15:home 16:home 17:home 1:electronics ...这是稳定的排序,正是你想要的。要测试这一点,按一个有很多重复值的字段排序,把列表获取两次(并用较小的 limit 逐页浏览),检查顺序从不改变。
在任何 API 上都值得一试的更多排序情况:
- 大小写混合的字符串。“apple”排在“Banana”之前还是之后?
- 以字符串存储的数字,“10”会排在“9”前面。
- 排序字段为 null 或缺失的记录。它们排在最前、最后,还是消失了?
- 不同时区的日期。
被忽略的参数
参数写错时会发生什么,比任何功能本身带来的麻烦都更多:
?sort=colour&limit=5 200, ids 1,2,3,4,5 (unsorted)
?sort=price&order=sideways 200, sorted ascending
?categry=books 200, all 20 products
?rating.rate=4.3 200, all 20 products每一个都返回 200 和一个看起来合理的列表。按不存在的字段排序会被丢弃。sideways 这个排序方向变成了升序。拼错的筛选条件 categry 被忽略,所以客户端请求的是图书,却拿到了全部 20 个商品。不支持用点号表示法按嵌套字段筛选,这也会被忽略。
在实践中,拼错的筛选条件是其中最糟糕的。设想一个显示“客户 42 的订单”的页面,它基于一个被人改了名的筛选参数。改名之后,每个客户都能看到所有订单,而 API 一直返回 200。测试筛选时,一定要包含一个筛选条件应该排除大部分记录的用例,并检查它确实排除了。只检查“返回的每个商品都属于 books 类别”的测试,在列表为空时也能通过,所以还要检查数量。
筛选和搜索
这个 API 的筛选是精确且不区分大小写的:?category=Electronics 返回五个电子类商品,而 ?category=electron 返回空列表。布尔字段的用法相同:
/api/Products?inStock=false用 q搜索是子字符串匹配。这时编码开始变得重要,因为搜索词中会包含空格、& 符号、百分号和引号,而它们在 URL 中都有特殊含义:
?q=YOGA 19
?q=yoga+mat 19
?q=yoga%20mat 19
?q=100%25 6 (description contains "100% organic cotton")
?q=%27 11,13 (titles and descriptions with an apostrophe)
?q= all 20
?q=a&q=b []
?category=books&category=home []空格写成 + 或 %20 都可以。字面上的 % 必须以 %25 发送,它能正确找到那件“100% organic cotton”的 T 恤。搜索撇号也能正常工作并返回两本书,这是一个小小的迹象,说明搜索没有通过拼接字符串来构造 SQL(安全测试那一课会解释为什么这很重要)。如果你发送了未编码的空格,curl 会干脆拒绝发送请求。
最后两行值得说明一下。在 URL 中重复一个参数是合法的,而不同的 API 处理方式不同:第一个值生效、最后一个值生效、两个值用 OR 组合,或者把它们拼接成一个字符串。在这里,重复 q 或 category 会返回空列表,这说明这些值被拼接成了类似 books,home 的东西,什么也匹配不上。如果你的产品界面将来允许用户选择两个类别,这里就有一个等着被发现的 bug。
组合使用
在一个请求中同时筛选、排序和分页,并检查它们是否按正确的顺序生效:先筛选,再排序,最后取出对应的页。
/api/Products?category=electronics&sort=price&order=desc&limit=2&page=2第 2 页包含商品 3 和 4(89.99 和 59.99),X-Total-Count 是 5,也就是电子类商品的数量,而不是 20。如果总数忽略了筛选条件,客户端会对一个只有三页的列表显示“第 1 页,共 10 页”。这个错误很常见,因为总数往往是由另一个单独的查询统计的,而有人忘了更新它。
为什么页码会出问题
页码和偏移量描述的是列表中的一个位置:“跳过 20 条,给我 10 条”。对于不会变化的数据,这没有问题。数据一旦变化,位置就会移动:
- 你获取了一个按最新排序的列表的第 1 页(第 1 到 10 项)。
- 有人创建了一个新条目。它排到了最前面,其余所有条目都往后挪了一位。
- 你获取第 2 页(第 11 到 20 位)。第 11 位现在是原来的第 10 项,所以你会看到它两次。
删除一个条目则正好相反:所有条目都往前挪一位,有一个条目永远不会显示。对于新闻推送,这只是个小麻烦。而对于一个通过逐页遍历导出所有发票的任务,这意味着导出结果中少了一张发票,而且没人注意到。
游标分页通过描述你停在哪里,而不是你走了多远,来避免这个问题。每个响应都会给你一个用于下一次请求的令牌:
/api/pagination/cursor?limit=3{
"object": "list",
"url": "/api/pagination/cursor",
"data": [
{ "id": 1, "name": "Smart Headset", "category": "electronics", "price": 42.01, "createdAt": "2026-01-01T21:00:00Z" },
{ "id": 2, "name": "Compact Speaker", "category": "outdoors", "price": 79.02, "createdAt": "2026-01-02T10:00:00Z" },
{ "id": 3, "name": "Deluxe Blender", "category": "office", "price": 116.03, "createdAt": "2026-01-02T23:00:00Z" }
],
"has_more": true,
"next_cursor": "eyJsYXN0SWQiOjN9"
}把 next_cursor 作为 ?cursor= 传回去,一直继续,直到 has_more为 false。无效的游标会得到一个明确的 400:“Invalid cursor. Pass the 'next_cursor' value from a previous response unchanged.”
键集分页是同样的思路,只是去掉了包装。客户端发送它看到的最后一个 ID,服务器返回在它之后的记录:
/api/pagination/keyset?after_id=10&limit=3{
"data": [
{ "id": 11, "name": "Smart Lamp", "category": "electronics", "price": 12.11, "createdAt": "2026-01-07T07:00:00Z" },
{ "id": 12, "name": "Compact Headset", "category": "outdoors", "price": 49.12, "createdAt": "2026-01-07T20:00:00Z" },
{ "id": 13, "name": "Deluxe Speaker", "category": "office", "price": 86.13, "createdAt": "2026-01-08T09:00:00Z" }
],
"has_more": true,
"next_after_id": 13
}第三种风格像 GitHub 的 API 那样,把分页链接放在 Link 响应头中。响应体仍然是一个普通数组:
$ curl -i "https://api.testingapis.com/api/pagination/link?page=2&per_page=3"
HTTP/1.1 200 OK
link: <http://api.testingapis.com/api/pagination/link?page=3&per_page=3>; rel="next",
<http://api.testingapis.com/api/pagination/link?page=34&per_page=3>; rel="last",
<http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="first",
<http://api.testingapis.com/api/pagination/link?page=1&per_page=3>; rel="prev"
x-total-count: 100页数是正确的(100 个条目每页 3 个,共 34 页)。但看看这些 URL。请求发往的是 https,而每个链接都指向 http。这是一个真实而且常见的 bug:应用运行在一个处理 HTTPS 的代理后面,它自己看到的只是普通的 HTTP,于是就按它看到的来构造链接。跟随这些链接的客户端要么会被重定向,要么在更严格的配置下以未加密的方式发送下一个请求。检查 API 生成的每个 URL 的协议和主机,是一个能发现这个问题的低成本测试。
对于每种分页风格,核心测试都是一样的:遍历到底、收集 ID、检查是否有遗漏和重复,并检查最后一页的情况。对于游标和键集分页,还要在遍历过程中添加一条记录,确认你仍然恰好看到每条原始记录一次。
练习
选择 /api/Books登录后查看,记下每一步的结果。
- 通过
X-Total-Count查出有多少本书,然后用limit=4遍历列表。每个 ID 都恰好拿到一次了吗? - 按
genre排序。获取两次,并用limit=3跨页获取。每个类型内部的顺序稳定吗? - 按某个类型筛选,按
price降序排序,并请求第 2 页。X-Total-Count是筛选后的数量吗? - 把筛选参数名拼错。返回了什么?
- 把
/api/pagination/offset登录后查看 和/api/pagination/link登录后查看 遍历到底。它们在哪里表示最后一页?两者的总数一致吗?
对于每一个意外的结果,判断它是 bug、文档缺漏,还是一个你仍然会提出质疑的设计选择。