测试校验和错误

错误的输入、缺失的字段、错误的类型,以及好的错误是什么样子。

上一课发送了正确的数据,并检查它是否被保存。这一课发送错误的数据,并检查它是否被拒绝。在大多数项目中,bug 就藏在这里。开发者先写正常路径,在开发过程中手动测试它,然后还没来得及想清楚客户端可能会发送什么别的东西,时间就用完了。

校验测试有两部分。服务器必须以正确的状态码拒绝请求,而且不能保存任何东西。在此之后,错误消息的质量也很重要,因为总有一天会有另一位开发者读到它,试图弄明白自己哪里做错了。

一个带规则的表单

POST /api/validation/register登录后查看 是一个带有成文规则的注册端点。参考页面列出了这些规则:用户名由 3 到 20 个字母、数字或下划线组成,年龄是 18 到 120 之间的整数,国家是九个代码之一,等等。它不会保存任何数据,所以同一个请求体你想发多少次都行。一个有效的请求:

POST/api/validation/register
{
  "username": "ava_t",
  "email": "ava@example.com",
  "password": "Str0ng!Pass",
  "confirmPassword": "Str0ng!Pass",
  "age": 29,
  "country": "us",
  "phone": "+15550101",
  "acceptTerms": true
}
这个请求使用的是面向已登录成员的端点。登录创建免费账户
响应
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8

{
  "message": "User registered successfully",
  "data": {
    "id": 1001,
    "username": "ava_t",
    "email": "ava@example.com",
    "age": 29,
    "country": "US",
    "createdAt": "2026-09-30T18:32:12Z"
  }
}

注意 "us" 返回时变成了 "US"。服务器对这个值做了规范化处理,而不是拒绝它。这没问题,但这也是一种行为,测试应该把它固定下来,免得有人无意中改掉它。

现在一次性违反几乎所有规则:

POST/api/validation/register
{
  "username": "ab",
  "email": "ava@example",
  "password": "password",
  "confirmPassword": "Password",
  "age": 17,
  "country": "XX",
  "acceptTerms": false
}
这个请求使用的是面向已登录成员的端点。登录创建免费账户
响应
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json; charset=utf-8

{
  "type": "https://example.com/problems/validation-error",
  "title": "One or more fields failed validation.",
  "status": 422,
  "detail": "7 field(s) failed validation.",
  "instance": "/api/validation/register",
  "errors": {
    "username": ["Username must be 3-20 characters long."],
    "email": ["Email must be a valid email address."],
    "password": [
      "Password must contain an uppercase letter.",
      "Password must contain a digit.",
      "Password must contain a special character."
    ],
    "confirmPassword": ["Passwords do not match."],
    "age": ["Age must be between 18 and 120."],
    "country": ["Country must be one of: US, GB, IN, DE, FR, JP, CA, AU, BR."],
    "acceptTerms": ["You must accept the terms and conditions."]
  }
}
这个示例使用的是面向已登录成员的端点。登录创建免费账户

这是一个好的错误响应,值得看看它好在哪里,这样以后你就能认出糟糕的错误响应。

  • 它一次报告了所有问题。修改表单的客户端不必提交七次才找到七个错误。
  • 每个错误都以客户端发送的字段名作为键,所以界面可以把它显示在对应的输入框旁边。
  • 消息说明了规则(“between 18 and 120”),而不是只说值不对,让客户端去猜原因。
  • 它遵循 RFC 7807(已由 RFC 9457 更新),这是一种 HTTP 错误的标准格式,包含 type、title、status、detail 和 instance 字段,以及媒体类型 application/problem+json。你不必坚持要求这种格式,但如果一个 API 声称使用了它,就要检查这些字段是否齐全,以及响应体中的 status 是否与真实的状态码一致。

发送什么

你不会在每个字段上把所有情况都试一遍。逐个检查字段,问问自己下面哪些情况可能适用:

输入类型示例
缺失完全不传这个字段。传 null。传空字符串。它们的表现可能各不相同。
类型错误数字字段传 "29",字符串字段传 29 或 true,期望列表的地方传对象
边界精确的最小值和最大值、两侧各一步、零、负数、期望整数的地方传小数
长度空、一个字符、最大长度、多一个字符,以及非常长的内容(10,000 个字符)
格式几乎正确的邮箱、日期、电话号码、URL 和代码
空白和大小写开头的空格、全是空格的标题、admin 已被占用时的 Admin
未知字段拼错的字段名、多出来的字段、客户端不应该能设置的字段
请求体本身没有请求体、格式错误的 JSON、用数组代替对象、错误的 Content-Type

注册端点上的边界

年龄规则是 18 到 120,所以值得关注的值是 17、18、120 和 121。在这个 API 上,18 和 120 被接受,17 和 121 得到“Age must be between 18 and 120.”。18.5 得到另一条消息“Age must be an integer.”,字符串 "29" 也一样。有些 API 会悄悄把 "29" 转换成 29。这两种行为本身都没有错,但客户端会逐渐依赖上线的那一种。

用户名为 3 到 20 个字符:abc 和 20 个字母的名字能通过,21 个字母的名字会失败。带空格的用户名会被拒绝,并附上关于允许字符的消息。已被占用的名字 admin 会被拒绝,Admin 也一样,这说明唯一性检查不区分大小写。如果区分的话,两个用户可能会拥有在大多数地方看起来一模一样的名字。

邮箱检查一如既往地混乱。ava@example、ava@@example.com 和 ava.example.com 会被拒绝,开头带空格的 " ava@example.com" 也会被拒绝。ava+qa@example.com 和 AVA@EXAMPLE.COM 被接受,这是对的。a@b.c 也被接受。真正的邮箱校验是一场没人能赢的漫长争论,所以测试的重点不在于完美的规则,而在于你的产品关心的情况:带加号的地址、大写字母,以及首尾空格是被去掉还是被拒绝。

空并不等于缺失

发送 {},每个必填字段都会被报告为“is required”。把同样的字段设为 null 发送,得到的结果相同。这是一致的,这很好。在 /api/Books登录后查看 上,由三个空格组成的标题也会被当作缺失处理。很多 API 只检查字符串是否存在,所以全是空格的标题或空字符串会顺利通过,之后在某人的表格中显示为空白行。

长度限制常常完全没有。/api/Books登录后查看 用 201 接受了一个 5,000 个字符的标题。文档没有写明限制,所以这可能是有意的,但数据库的列或界面布局迟早会对此有意见。问问限制应该是多少,并测试限制两侧各一个字符的情况。

未知字段

当客户端发送一个服务器不认识的字段时,服务器可以忽略它,也可以拒绝它。在 CRUD 那一课中,/api/Books登录后查看 忽略了拼错的 year 字段,并把年份存成了 0。/api/validation/strict登录后查看 则会拒绝:

Terminal
curl -X POST https://api.testingapis.com/api/validation/strict \
  -H "Content-Type: application/json" \
  -d '{"name": "Widget", "email": "a@b.co", "quantity": 2, "discount": 50}'

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{
  "type": "https://example.com/problems/unknown-fields",
  "title": "Request body contains unknown fields.",
  "status": 400,
  "detail": "Allowed fields: name, email, quantity (names are case-sensitive).",
  "instance": "/api/validation/strict",
  "unknownFields": ["discount"]
}
这个示例使用的是面向已登录成员的端点。登录创建免费账户

这里的字段名区分大小写,所以用 Name 代替 name 也算未知字段。拒绝更安全,因为拼写错误会立即暴露。当 API 在演进、旧客户端还在发送已被移除的字段时,忽略则更友好。你的工作是弄清楚 API 采用的是哪种方式,以及各个端点是否一致地采用。

有一类未知字段是安全问题,而不是风格选择:记录上确实存在、但客户端不应该能设置的字段,比如 role、isAdmin、订单上的 price,或者 id。如果服务器把收到的每个字段都复制到记录上,用户就可以给自己提权。安全测试那一课把这称为批量赋值。

损坏的请求体

接下来,把请求作为一个整体来测试。下面是注册端点的真实响应:

响应
# No body at all
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"":["A non-empty request body is required."]},"traceId":"00-fc01be6d..."}

# {"username":"ava_t",}  (trailing comma)
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"$":["The JSON object contains a trailing comma at the end which is not supported in this mode. Change the reader options. Path: $ | LineNumber: 0 | BytePositionInLine: 20."]},"traceId":"00-e13ac011..."}

# [] instead of an object
HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{"status":400,"error":"Bad Request","message":"Request body must be a JSON object."}

# Valid JSON sent as Content-Type: text/plain
HTTP/1.1 415 Unsupported Media Type
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.16","title":"Unsupported Media Type","status":415,"traceId":"00-4cadc1e1..."}

四种情况都得到了处理,对错误的 Content-Type 返回 415 也完全正确。但看看它们的结构。三个响应是带有 errors 映射的 problem+json,而数组的情况返回了一种完全不同的格式 {status, error, message},内容类型是普通的 JSON。按一种方式解析错误的客户端,遇到另一种就会出错。当一些错误来自框架、另一些来自应用代码时就会出现这种情况,这也是 API 测试中最常见的发现之一。把你在一个 API 中看到的各种错误结构收集起来,并排比较。

错误中不应该包含什么

下面是 /api/Books登录后查看 在期望数字的地方收到字符串时的情况:

Terminal
curl -X POST https://api.testingapis.com/api/Books \
  -H "Content-Type: application/json" \
  -d '{"title": "x", "author": "y", "pages": "many"}'

HTTP/1.1 400 Bad Request

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "item": ["The item field is required."],
    "$.pages": ["The JSON value could not be converted to System.Int32. Path: $.pages | LineNumber: 0 | BytePositionInLine: 40."]
  },
  "traceId": "00-d027cc55a2925746a1bf93cc96d2306d-02234fa55d940604-00"
}
这个示例使用的是面向已登录成员的端点。登录创建免费账户

状态码是对的,内容却有两个问题。消息中提到了 System.Int32,这让外人知道服务器运行的是 .NET,而且留给客户端的是一个类型名,而不是“pages 必须是整数”。另外,多出来的错误 "The item field is required." 引用的是一个内部参数名,与客户端发送的任何内容都无关。在这里两者都不危险,但在真实的产品中,你会把两者都报告出来。

错误中绝对不能出现的是:堆栈跟踪、SQL 片段、文件路径、内部主机名以及密钥的值。相反,traceId 很有用:它能让技术支持找到对应的服务器日志,而不会暴露任何东西。

该用哪个状态码

团队常常为 400 还是 422 争论不休。一种常见的划分是:服务器读不懂的请求体(JSON 格式错误、类型错误)用 400,可以读懂但违反规则的请求体(年龄 17)用 422。/api/validation/register登录后查看 只在完全无法解析请求体时返回 400,其他所有情况都返回 422,包括年龄字段传了字符串。/api/validation/annotations登录后查看 则一律使用 400。只要一致并且有文档说明,两种做法都可以接受;而同一个 API 的两个端点做法不同,就值得问一问。

状态码对错误输入的含义
400 Bad Request请求格式错误或无效。客户端必须修改它。
415 Unsupported Media TypeContent-Type 不是该端点接受的类型。
422 Unprocessable Content请求体可以解析,但其中的值违反了规则。
409 Conflict本身有效,但与当前状态冲突,比如重复。(注册端点对已被占用的用户名使用 422。)
500 Internal Server Error对错误输入而言永远不正确。服务器在本该拒绝的输入上崩溃了。

最后一行是唯一没有例外的规则。只要你发送的任何输入导致了 500,无论这个输入多么奇怪,你都发现了一个 bug。服务器本该拒绝它,却倒下了。你可以在 /api/validation/problem/internal登录后查看 看到一个格式规范的 500 是什么样子,其他示例 problem 文档列在这里:

GET/api/validation/problem
这个请求使用的是面向已登录成员的端点。登录创建免费账户

练习

测试 POST /api/validation/annotations登录后查看。它的有效示例在参考页面上。它使用的是框架内置的校验,而不是手写的规则。

  • 找出 username、age 和 tags 的边界。
  • 试三个无效的和两个有效的电话号码与网站地址。
  • 向 /annotations 和 /register 都发送一个无效的请求体,并比较错误响应。它们各自使用什么状态码?与你发送的字段名相比,errors 中的字段名是怎么拼写的?
  • 在 age 期望数字的地方发送一个字符串。消息是否泄露了什么?

把两个端点之间的差异写下来,就像要提交给开发者一样:你发送了什么、返回了什么,以及你期望的是什么。

练习与延伸