端到端测试一个资源

创建、读取、更新和删除,并检查每一步是否真的发生了。

大多数 API 都围绕资源构建:图书、订单、用户、发票。每种资源通常都有创建、读取、修改和删除的方式。测试人员把这称为 CRUD(create、read、update、delete),一个 CRUD 测试会跟踪一条记录的整个生命周期。对于任何新资源,这都是你首先应该测试的,因为几乎所有其他功能都依赖它正常工作。

本课使用 /api/Books登录后查看。它支持所有操作,它的参考页面列出了各个字段。你已经在HTTP 方法中了解了这些方法。这里的问题不一样:你如何证明每个操作真的做到了它声称的事?

响应是一种声明,而不是证据

当服务器用 201 和一份记录副本来响应 POST 时,它是在告诉你它认为自己存了什么。这份副本通常是用即将保存的同一个对象构建的,所以即使保存失败了,或者某个字段在写入数据库的途中丢失了,它看起来也可能完美无缺。要知道到底存了什么,唯一的办法是在另一个单独的请求中再查一次。

所以 CRUD 测试会在执行操作和用新的读取来验证之间交替进行:

  1. 创建一条记录。检查响应。
  2. 按 ID 读回它。把每个字段和你发送的内容比较。
  3. 在列表端点中找到它。
  4. 用 PUT 替换它。再读回来。
  5. 用 PATCH 修改一个字段。再读回来。
  6. 删除它。再读回来,预期得到 404。
  7. 再删除一次,看看会发生什么。

创建

Terminal
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 发送:

Terminal
curl -X POST https://api.testingapis.com/api/Books \
  -H "Content-Type: application/json" \
  -d '{"title": "The Test Book", "author": "Ada Tester", "year": 2026}'
这个示例使用的是面向已登录成员的端点。登录创建免费账户
响应(201 Created)
{
  "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 上可以按标题搜索:

GET/api/Books?q=web%20apis
这个请求使用的是面向已登录成员的端点。登录创建免费账户

如果你的书不在结果中,要么是创建时的响应说了谎,要么是列表读取的来源与单条记录端点不同(缓存、搜索索引、只读副本)。这些在真实系统中都会发生。检查列表还能发现一个更隐蔽的问题:记录已经保存,却被过滤出了列表,比如某个默认过滤条件隐藏了有空字段的记录。

用 PUT 和 PATCH 更新

PUT 替换整条记录。你省略的内容会被重置,通常变成空值或默认值。无论是客户端还是服务端,人们总是忘记这一点,所以要专门测试它。下面只用三个字段更新一本书:

Terminal
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}'
这个示例使用的是面向已登录成员的端点。登录创建免费账户
响应(200 OK)
{
  "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,它的响应会列出修改了什么、忽略了什么:

Terminal
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}'
这个示例使用的是面向已登录成员的端点。登录创建免费账户
响应(200 OK)
{
  "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 允许在一个请求中创建或删除多条记录。值得关注的问题是:当其中一些条目有问题时会发生什么。

Terminal
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、没找到哪些:

Terminal
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每条记录只删除一次,第二次出现时不报错。

练习

在 /api/Movies登录后查看 上走完整个生命周期,字段参考它的参考页面。创建一部填写了所有字段的电影,然后:

  1. 读回它,把每个字段与你发送的内容比较。
  2. 用 ?q= 加上标题中的一个词找到它。
  3. 只用必填字段对它执行 PUT。哪些字段被重置了?
  4. PATCH 一个字段,同时发送一个不存在的字段名。查看 ignored 列表。
  5. 删除它,然后再执行 GET、PATCH 和 DELETE。记下每个状态码。

最后,写下一件你会去问开发者的事(一个被忽略的字段、一个被重复使用的 ID、一个优先于另一个错误的错误)。决定报告什么,和发送请求一样,都是测试的一部分。

练习与延伸