端到端测试一个资源
创建、读取、更新和删除,并检查每一步是否真的发生了。
大多数 API 都围绕资源构建:图书、订单、用户、发票。每种资源通常都有创建、读取、修改和删除的方式。测试人员把这称为 CRUD(create、read、update、delete),一个 CRUD 测试会跟踪一条记录的整个生命周期。对于任何新资源,这都是你首先应该测试的,因为几乎所有其他功能都依赖它正常工作。
本课使用 /api/Books登录后查看。它支持所有操作,它的参考页面列出了各个字段。你已经在HTTP 方法中了解了这些方法。这里的问题不一样:你如何证明每个操作真的做到了它声称的事?
响应是一种声明,而不是证据
当服务器用 201 和一份记录副本来响应 POST 时,它是在告诉你它认为自己存了什么。这份副本通常是用即将保存的同一个对象构建的,所以即使保存失败了,或者某个字段在写入数据库的途中丢失了,它看起来也可能完美无缺。要知道到底存了什么,唯一的办法是在另一个单独的请求中再查一次。
所以 CRUD 测试会在执行操作和用新的读取来验证之间交替进行:
- 创建一条记录。检查响应。
- 按 ID 读回它。把每个字段和你发送的内容比较。
- 在列表端点中找到它。
- 用 PUT 替换它。再读回来。
- 用 PATCH 修改一个字段。再读回来。
- 删除它。再读回来,预期得到 404。
- 再删除一次,看看会发生什么。
创建
curl -i -X POST https://api.testingapis.com/api/Books \
-H "Content-Type: application/json" \
-d '{
"title": "Testing Web APIs",
"author": "Mark Winteringham",
"isbn": "9781617299537",
"genre": "testing",
"publishedYear": 2022,
"pages": 264,
"language": "English",
"price": 49.99,
"rating": 4.6,
"available": true,
"tags": ["api", "testing"]
}'HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
{
"message": "Book created successfully",
"data": {
"id": 26,
"title": "Testing Web APIs",
"author": "Mark Winteringham",
"isbn": "9781617299537",
"genre": "testing",
"publishedYear": 2022,
"pages": 264,
"language": "English",
"price": 49.99,
"rating": 4.6,
"available": true,
"tags": ["api", "testing"]
}
}这个响应要检查的内容,大致按重要性排序:
- 状态码是 201,而不是 200。两者都表示成功,但 201 说明有新资源被创建,客户端有时会据此走不同的分支。
- 有一个
id,而且它是数字(或文档承诺的任何类型)。把它保存下来,后面每一步都需要它。 - 你发送的每个字段都以相同的值返回。逐个比较,包括那些看起来无关紧要的。价格
49.99返回成49.9899999或"49.99"就是 bug。 - 是否有
Location响应头指向新记录。很多 REST API 会在 201 时发送它,这个 API 不发送。这更多是设计选择而非缺陷,但你应该知道你的 API 属于哪一种,并把它写进测试里。
现在看看请求体里有个小错误时会怎样。字段名是 publishedYear,这里却作为 year 发送:
curl -X POST https://api.testingapis.com/api/Books \
-H "Content-Type: application/json" \
-d '{"title": "The Test Book", "author": "Ada Tester", "year": 2026}'{
"message": "Book created successfully",
"data": {
"id": 26,
"title": "The Test Book",
"author": "Ada Tester",
"isbn": "",
"genre": "",
"publishedYear": 0,
"pages": 0,
"language": "",
"price": 0,
"rating": 0,
"available": false,
"tags": []
}
}服务器接受了请求,一声不吭地忽略了 year,并把 0 存为年份。状态码中没有任何迹象表明出了问题。只有把存下来的记录和你本来想发送的内容比较,才能发现这一点。被悄悄忽略的字段,是“数据错了却没人知道为什么”这类 bug 最常见的来源之一。有些 API 会改为拒绝未知字段;下一课会讲到这一点。
由服务器填写的字段
有些字段不是由你提供的:ID,以及很多资源上的时间戳,比如 createdAt 或 updatedAt。你无法把它们和固定值比较,因为每次运行它们都会变。改为测试它们的性质:
- ID 存在,类型正确,并且与你之前创建的 ID 都不相同。
createdAt是符合文档格式的有效日期,并且与你发送请求的时间相差不超过几秒。要考虑到你的机器和服务器之间的时钟差异。- 更新之后
updatedAt会变,而createdAt不会。 - 在请求体里发送你自己的
id并不能让你指定 ID。Books 的文档说请求体中的 ID 会被忽略。用"id": 1试一下,然后检查第 1 本书没有被改动。
读回来
用你拿到的 ID 获取这条记录,并把整个响应体与你发送的内容比较。然后检查列表。在这个 API 上可以按标题搜索:
/api/Books?q=web%20apis如果你的书不在结果中,要么是创建时的响应说了谎,要么是列表读取的来源与单条记录端点不同(缓存、搜索索引、只读副本)。这些在真实系统中都会发生。检查列表还能发现一个更隐蔽的问题:记录已经保存,却被过滤出了列表,比如某个默认过滤条件隐藏了有空字段的记录。
用 PUT 和 PATCH 更新
PUT 替换整条记录。你省略的内容会被重置,通常变成空值或默认值。无论是客户端还是服务端,人们总是忘记这一点,所以要专门测试它。下面只用三个字段更新一本书:
curl -X PUT https://api.testingapis.com/api/Books/27 \
-H "Content-Type: application/json" \
-d '{"title": "Lessons Learned in Software Testing", "author": "Cem Kaner", "price": 39.0}'{
"message": "Book updated successfully",
"data": {
"id": 27,
"title": "Lessons Learned in Software Testing",
"author": "Cem Kaner",
"isbn": "",
"genre": "",
"publishedYear": 0,
"pages": 0,
"language": "",
"price": 39.0,
"rating": 0,
"available": false,
"tags": []
}
}ISBN、页数、年份和标签都没了。这是正确的 PUT 行为。如果服务器保留了旧值,它的行为就像 PATCH 了,而依赖 PUT 来清空某个字段的客户端将永远做不到。同一个请求还在请求体中发送了 "id": 1;记录的 ID 仍然是 27,另一个单独的 GET 确认了第 1 本书没有改变。
缺少必填字段的 PUT 会以 400 失败,这是应该的:只发送 {"price": 35} 会返回 Missing required field: title。
PATCH 只修改你发送的字段。这个 API 使用 JSON merge patch,它的响应会列出修改了什么、忽略了什么:
curl -X PATCH https://api.testingapis.com/api/Books/26 \
-H "Content-Type: application/merge-patch+json" \
-d '{"price": 35, "available": false, "colour": "blue", "id": 999}'{
"message": "Book patched successfully",
"changed": ["price", "available"],
"ignored": ["colour", "id"],
"data": {
"id": 26,
"title": "Testing Web APIs",
"author": "Mark Winteringham",
"isbn": "9781617299537",
"genre": "testing",
"publishedYear": 2022,
"pages": 264,
"language": "English",
"price": 35,
"rating": 4.6,
"available": false,
"tags": ["api", "testing"]
}
}检查列出的两个字段是否变了,其他每个字段是否保持原值,以及被忽略的 id 是否真的被忽略了。然后重新 GET 一次,把所有内容再检查一遍。只更新了响应却没有更新已存记录的 PATCH 很少见,但一旦发生,正是那种会流入生产环境的 bug,因为在页面重新加载之前,客户端一直显示的是新值。
删除,然后再删除一次
$ curl -i -X DELETE https://api.testingapis.com/api/Books/26
HTTP/1.1 200 OK
{"message":"Book deleted successfully"}
$ curl -i https://api.testingapis.com/api/Books/26
HTTP/1.1 404 Not Found
{"status":404,"error":"Not Found","message":"Book with ID 26 does not exist."}
$ curl -i -X DELETE https://api.testingapis.com/api/Books/26
HTTP/1.1 404 Not Found
{"status":404,"error":"Not Found","message":"Book with ID 26 does not exist."}删除之后,GET 必须返回 404。还要检查这条记录已经从列表中消失,并且总数减少了(在初始数据上,/api/Books/count登录后查看 返回 {"count": 25})。
第二次 DELETE 是各个 API 表现不同的地方。这个 API 返回 404。另一些 API 每次删除都返回 204 或 200,理由是最终状态(不存在这条记录)是一样的。两种做法都说得过去。你要找的是 500,或者一个声称删除了某个根本不存在的东西的 200。还要向已删除的 ID 发送 PUT 和 PATCH。在这里,PATCH 得到 404,但请求体不完整的 PUT 会在服务器发现记录不存在之前,就因为缺少字段而得到 400。当两种错误同时适用时哪一个优先,值得在文档中写上一句,而文档往往对此只字未提。
创建你自己的数据
因为记录 1 总是存在,所以人们很想拿它来测试。不要这样做。在共享服务器上,任何人都可以修改或删除第 1 本书,而一个修改它的测试会破坏所有读取它的其他测试。每个测试都应该创建自己需要的记录,只使用分配给它的 ID,并在结束时删除它们。
这个 API 很能说明原因。它把数据保存在内存中,由所有使用者共享。在编写这一课时,就在我们的某次删除和下一次创建之间的短短几秒内,另一个用户创建了一本 ID 为 26 的书。一个假设“下一本书会是 26 号”的测试就会因为与被测代码毫无关系的原因而失败。
同一个实验还揭示了另一件事。第 26 本书被删除后,下一本创建的书又得到了 ID 26。这个服务器会分配下一个空闲的编号,所以 ID 会被重复使用。如果另一个系统在删除之前存下了“第 26 本书”,它现在指向的就是另一本书了。这是否算 bug 取决于产品,但这类发现值得提出来。正因如此,大多数数据库从不重复使用 ID。
批量端点
很多 API 允许在一个请求中创建或删除多条记录。值得关注的问题是:当其中一些条目有问题时会发生什么。
curl -X POST https://api.testingapis.com/api/Books/bulk \
-H "Content-Type: application/json" \
-d '[
{"title": "Bulk A", "author": "Tester"},
{"title": "Bulk B", "author": "Tester"},
{"title": "", "author": "Tester"}
]'
{"status":400,"error":"Bad Request","message":"Item [2]: Missing required field: title"}第三个条目的标题为空,所以整个请求都失败了,错误信息按索引指出了是哪个条目。Books 的文档说这种情况下不会保存任何内容,你应该加以验证:检查请求失败后“Bulk A”并不存在。一个先保存了前两个条目然后才失败的 API,会让客户端完全不知道该重试什么。
批量删除会报告它找到了哪些 ID、没找到哪些:
curl -X DELETE "https://api.testingapis.com/api/Books/bulk?ids=27,28,5000"
{"message":"2 book record(s) deleted","deleted":[27,28],"notFound":[5000]}| 批量场景 | 要检查什么 |
|---|---|
| 所有条目都有效 | 每个条目都被保存,ID 互不相同,总数增加的数量等于发送的数量。 |
| 有一个无效条目 | 什么都不保存,或者响应准确说明了哪些条目被保存了。 |
| 空数组 | 一个明确的 400,而不是什么都没创建的 201。 |
| 超过上限(这里是 100) | 一个说明上限的 400。 |
| 批量删除中有重复的 ID | 每条记录只删除一次,第二次出现时不报错。 |