读懂 JSON
JSON 的六种类型,以及会让测试栽跟头的值。
你要测试的大多数 API 都收发 JSON。它是一种表示结构化数据的文本格式,小到一个下午就能学会。语法很少是问题所在。问题在于 JSON 有几件事没有规定,不同的程序会用不同的方式填补这些空白。很多 bug 就藏在这些空白里,所以这一课花在它们身上的时间比语法还多。
六种值
一个 JSON 值总是下面几种之一:
| 类型 | 示例 | 说明 |
|---|---|---|
| 对象 | { "key": "value" } | 放在花括号里的具名字段。键总是用双引号括起来的字符串。 |
| 数组 | [1, 2, 3] | 放在方括号里的有序列表。元素可以是任何类型,也可以混合。 |
| 字符串 | "hello" | 用双引号括起来的文本。不允许用单引号。 |
| 数字 | 42, -7, 3.14159, 6.022e23 | 整数和小数共用一种类型。不能有前导零,没有 NaN,也没有 Infinity。 |
| 布尔值 | true, false | 小写,不加引号。 |
| null | null | 小写,不加引号。表示“没有值”。 |
这就是整个类型系统。没有日期类型,没有与小数分开的整数类型,也没有办法添加注释。这个 API 有一个端点,每种类型各返回一个:
/api/edge-cases/types{
"string": "hello",
"emptyString": "",
"integer": 42,
"negativeInteger": -7,
"float": 3.14159,
"exponent": 6.022e23,
"booleanTrue": true,
"booleanFalse": false,
"nullValue": null,
"array": [1, 2, 3],
"emptyArray": [],
"object": { "key": "value" },
"emptyObject": {},
"arrayOfObjects": [ { "id": 1, "name": "Alice" }, { "id": 2, "name": "Bob" } ],
"nestedArrays": [[1, 2], [3, [4, [5]]]],
"stringThatLooksLikeNumber": "123",
"stringThatLooksLikeBoolean": "true",
"stringThatLooksLikeNull": "null"
}看看最后三个字段。"123"、"true" 和 "null" 都是字符串,因为它们被引号括着。阅读响应的人看到的是一个数字、一个布尔值和一个 null,程序看到的却是三个字符串。检查响应时,既要检查值,也要检查类型。一个今天返回 "price": "249.99"、明天返回 "price": 249.99 的 API,即使数字看起来一样,也已经改变了它的契约。
指向一个值
写测试或 bug 报告时,你需要一种方式来说明你指的是哪个值。下面是第一课中商品的一部分:
{
"id": 1,
"title": "Wireless Noise-Cancelling Headphones",
"price": 249.99,
"category": "electronics",
"rating": { "rate": 4.3, "count": 127 },
"inStock": true,
"createdAt": "2025-06-12T10:30:00Z"
}常用的非正式写法是用点号进入对象,用带数字的方括号从数组中选取元素。计数从零开始。
| 路径 | 值 |
|---|---|
title | "Wireless Noise-Cancelling Headphones" |
rating.rate | 4.3 |
rating.count | 127 |
当整个响应是一个数组时,比如 /api/Products?limit=2,第一个商品的 ID 是 [0].id,第二个商品的评分是 [1].rating.rate。不同工具的写法略有不同。你会在 Postman、REST Assured 和许多测试库中看到的 JSONPath,用美元符号表示根:$.rating.rate、$[0].id。它还支持通配符,所以 $[*].price 表示“每个元素的价格”。AWS 命令行工具使用的 JMESPath 把同样的意思写成 [*].price。现在你还不需要深入学习其中任何一种。知道 $[0].id 表示“第一个元素的 id”,就足以读懂大多数测试代码。
null、缺失和空
它们看起来相似,意思却不同。假设一个用户没有中间名,API 至少可以用五种方式表达这一点:
"middleName": null:字段存在但没有值- 根本没有
middleName这个键 "middleName": "":一个空字符串"middleName": "null":四个字母的单词 null,通常是有代码对 null 调用了 toString 的 bug"middleName": "None"或"undefined":同样的 bug,来自 Python 或 JavaScript
这个端点把它们并排全部返回:
/api/edge-cases/nulls{
"nullValue": null,
"nestedNull": { "a": null, "b": { "c": null } },
"arrayOfNulls": [null, null, null],
"arrayWithNull": [1, null, 3],
"nullAsString": "null",
"undefinedAsString": "undefined",
"noneAsString": "None",
"emptyString": "",
"emptyArray": [],
"emptyObject": {},
"zero": 0,
"false": false
}哪一种才是对的,取决于 API 的文档,而文档往往没有说明。你能测试的是一致性。如果一个商品有 "discount": null,另一个商品却根本没有 discount 键,那么读取 product.discount.amount 的客户端会在其中一个上崩溃,在另一个上则不会。还要留意用 0 和 false 来表示“未知”的情况。库存为 0 和没人知道库存是多少,是两回事。
数字
JSON 对数字的大小和精度没有任何限制,但读取 JSON 的程序有。JavaScript 把每个数字都存为 64 位浮点数,它只能精确表示不超过 9,007,199,254,740,991 的整数。超过这个值,它会悄悄四舍五入。
/api/edge-cases/numbers{
"int64Max": 9223372036854775807,
"int64MaxAsString": "9223372036854775807",
"maxSafeInteger": 9007199254740991,
"beyondSafeInteger": 9007199254740993,
"beyondSafeIntegerAsString": "9007199254740993",
"pointOnePlusPointTwo": 0.30000000000000004,
"money": 19.99,
"moneyAsString": "19.99",
"trailingZeros": 1.50,
"negativeZero": -0,
"numberAsString": "42",
"nanAsString": "NaN"
}分别用 JavaScript 和 Python 解析这个响应,然后比较:
JSON.parse(body).beyondSafeInteger // 9007199254740992
JSON.parse(body).int64Max // 9223372036854776000json.loads(body)["beyondSafeInteger"] # 9007199254740993
json.loads(body)["int64Max"] # 9223372036854775807JavaScript 把 9007199254740993 变成了 9007199254740992,没有任何报错。Python 则保持了精确值。如果一个 API 使用 64 位整数作为 ID(Twitter 的 ID 就是这样,很多数据库也是),JavaScript 客户端最终可能会请求错误的记录。这就是为什么谨慎的 API 会像上面的 int64MaxAsString 那样,把大 ID 作为字符串发送。测试带有大数字 ID 的 API 时,要检查浏览器客户端实际收到的是什么。
小数也有类似的问题。0.1 + 0.2 在几乎所有语言中都等于 0.30000000000000004,因为二进制浮点数无法精确表示 0.1。涉及金钱时这就很重要了。有些 API 把价格作为数字(19.99)发送,有些作为字符串("19.99"),还有些作为整数形式的分(1999)。哪种都可行。测试应该检查合计是否精确到分,而不是“差不多就行”。
同一个响应中还有两个小问题。1.50 和 1.5 解析后是同一个数字,所以比较原始文本的测试会失败,而比较值的测试会通过。还有,-0 是合法的 JSON,但 JavaScript 的 JSON.stringify(-0) 会写成 0,所以它经不起一次来回转换。
日期是字符串
JSON 没有日期类型,所以日期以字符串或数字的形式传递,每个 API 都会选择自己的格式。下面是用多种方式写出的同一个时刻:
/api/edge-cases/dates{
"iso8601Utc": "2025-07-15T09:30:00Z",
"iso8601PositiveOffset": "2025-07-15T15:00:00+05:30",
"iso8601NoZone": "2025-07-15T09:30:00",
"dateOnly": "2025-07-15",
"unixSeconds": 1752571800,
"unixMilliseconds": 1752571800000,
"usFormat": "07/15/2025",
"euFormat": "15/07/2025",
"invalidLeapDay": "2025-02-29T00:00:00Z",
"nullDate": null,
"emptyDate": ""
}你最常看到的格式是带时区的 ISO 8601,比如 2025-07-15T09:30:00Z。Z 表示 UTC。当 API 返回日期时,要检查这些:
- 带有时区。没有时区的
2025-07-15T09:30:00表示某个地方的 9:30,而每个客户端猜的“某个地方”都不一样。 - Unix 时间戳写清楚是秒还是毫秒。
1752571800和1752571800000是同一个时刻,把它们搞混会让你回到 1970 年,或者跑到公元 57500 年。 07/15/2025和15/07/2025之所以没有歧义,只是因为 15 不可能是月份。03/04/2025是三月还是四月,取决于谁来读。- 像
2025-02-29这样不可能存在的日期,有些解析器会接受,有些会拒绝。试着把它们发送给一个接受日期作为输入的 API。
键的顺序和重复的键
对象的键没有顺序。{"a": 1, "b": 2} 和 {"b": 2, "a": 1} 是同一个对象。大多数服务器恰好每次都按同样的顺序发送键,所以人们很想把整个响应当作字符串来比较。等到某天库升级重排了键的顺序,这个测试就会失败,而实际上什么都没出错。应该比较解析后的值。另一方面,数组的顺序是有意义的:[1, 2] 和 [2, 1] 是不同的数组。
重复的键是一种更奇怪的情况。JSON 标准说键“应该”唯一,而不是“必须”唯一,所以下面这样在技术上是允许的:
/api/edge-cases/duplicate-keys{
"id": 1,
"name": "first value",
"name": "second value",
"nested": { "flag": true, "flag": false },
"id": 2
}JavaScript 的 JSON.parse 和 Python 的 json.loads 都会保留最后一个值,得到 {"id": 2, "name": "second value", "nested": {"flag": false}}。其他解析器则会保留第一个值,或者拒绝整个文档。如果网关检查的是第一个 role 键,而后端读取的是第二个,这个差异就会变成一个安全漏洞。在响应中你不常遇到重复的键,但在测试输入处理时,值得在请求里发送它们。
当 JSON 损坏时
有时服务器发送的东西根本不是合法的 JSON:被截断的响应体、HTML 错误页、堆栈跟踪。下面这个响应声称自己是 JSON,其实不是:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id": 1, "name": "Alice", "tags": ["a", "b",], "active": tru"b" 后面多了一个逗号,而且响应体在 true 中间就断了。状态码是 200,Content-Type 响应头写着 application/json,所以客户端会尝试解析并失败。Node.js 报告 Unexpected token ']',Python 报告 Illegal trailing comma before end of array。在真实的 API 中,这通常意味着服务器在写响应的途中崩溃了,或者被代理截断了。对测试的启示是:每次检查都要解析响应体。响应体无法解析的 200 就是失败,而只看状态码的测试会漏掉它。
阅读大型响应
真实的响应常常有几百行。在检查细节之前,先弄清结构:顶层是对象还是数组,有多少个元素,一个元素有哪些字段。然后完整地读一个元素。大多数 JSON bug 仅从结构上就能看出来,比如只有一个结果时,列表变成了单个对象返回;又比如某个端点的对象外面多包了一层 data 键,另一个端点却没有。