筛选、排序和分页

那些直到第 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。

分页

每页请求三个商品,除了响应体,也要看看响应头:

GET/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 比较。

JavaScript
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 是否返回相同的记录。

只有在运行期间没有人修改数据时,这个测试才能稳定通过。在像这样的共享服务器上,可能会有人修改。这就引出了页码的主要弱点,下文会讲到。

排序

按价格降序排序,检查整个结果的顺序,而不只是第一个条目:

GET/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 顺序排列,升序和降序都是如此:

结果(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 返回空列表。布尔字段的用法相同:

GET/api/Products?inStock=false

用 q搜索是子字符串匹配。这时编码开始变得重要,因为搜索词中会包含空格、& 符号、百分号和引号,而它们在 URL 中都有特殊含义:

结果(ID)
?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。

组合使用

在一个请求中同时筛选、排序和分页,并检查它们是否按正确的顺序生效:先筛选,再排序,最后取出对应的页。

GET/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 页(第 1 到 10 项)。
  2. 有人创建了一个新条目。它排到了最前面,其余所有条目都往后挪了一位。
  3. 你获取第 2 页(第 11 到 20 位)。第 11 位现在是原来的第 10 项,所以你会看到它两次。

删除一个条目则正好相反:所有条目都往前挪一位,有一个条目永远不会显示。对于新闻推送,这只是个小麻烦。而对于一个通过逐页遍历导出所有发票的任务,这意味着导出结果中少了一张发票,而且没人注意到。

游标分页通过描述你停在哪里,而不是你走了多远,来避免这个问题。每个响应都会给你一个用于下一次请求的令牌:

GET/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,服务器返回在它之后的记录:

GET/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登录后查看,记下每一步的结果。

  1. 通过 X-Total-Count 查出有多少本书,然后用 limit=4 遍历列表。每个 ID 都恰好拿到一次了吗?
  2. 按 genre 排序。获取两次,并用 limit=3 跨页获取。每个类型内部的顺序稳定吗?
  3. 按某个类型筛选,按 price 降序排序,并请求第 2 页。X-Total-Count 是筛选后的数量吗?
  4. 把筛选参数名拼错。返回了什么?
  5. 把 /api/pagination/offset登录后查看 和 /api/pagination/link登录后查看 遍历到底。它们在哪里表示最后一页?两者的总数一致吗?

对于每一个意外的结果,判断它是 bug、文档缺漏,还是一个你仍然会提出质疑的设计选择。

练习与延伸