读懂 JSON

JSON 的六种类型,以及会让测试栽跟头的值。

你要测试的大多数 API 都收发 JSON。它是一种表示结构化数据的文本格式,小到一个下午就能学会。语法很少是问题所在。问题在于 JSON 有几件事没有规定,不同的程序会用不同的方式填补这些空白。很多 bug 就藏在这些空白里,所以这一课花在它们身上的时间比语法还多。

六种值

一个 JSON 值总是下面几种之一:

类型示例说明
对象{ "key": "value" }放在花括号里的具名字段。键总是用双引号括起来的字符串。
数组[1, 2, 3]放在方括号里的有序列表。元素可以是任何类型,也可以混合。
字符串"hello"用双引号括起来的文本。不允许用单引号。
数字42, -7, 3.14159, 6.022e23整数和小数共用一种类型。不能有前导零,没有 NaN,也没有 Infinity。
布尔值true, false小写,不加引号。
nullnull小写,不加引号。表示“没有值”。

这就是整个类型系统。没有日期类型,没有与小数分开的整数类型,也没有办法添加注释。这个 API 有一个端点,每种类型各返回一个:

GET/api/edge-cases/types
这个请求使用的是面向已登录成员的端点。登录创建免费账户
JSON
{
  "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 报告时,你需要一种方式来说明你指的是哪个值。下面是第一课中商品的一部分:

JSON
{
  "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.rate4.3
rating.count127

当整个响应是一个数组时,比如 /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

这个端点把它们并排全部返回:

GET/api/edge-cases/nulls
这个请求使用的是面向已登录成员的端点。登录创建免费账户
JSON
{
  "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 的整数。超过这个值,它会悄悄四舍五入。

GET/api/edge-cases/numbers
这个请求使用的是面向已登录成员的端点。登录创建免费账户
JSON(节选)
{
  "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 解析这个响应,然后比较:

JavaScript
JSON.parse(body).beyondSafeInteger   // 9007199254740992
JSON.parse(body).int64Max            // 9223372036854776000
Python
json.loads(body)["beyondSafeInteger"]   # 9007199254740993
json.loads(body)["int64Max"]            # 9223372036854775807

JavaScript 把 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 都会选择自己的格式。下面是用多种方式写出的同一个时刻:

GET/api/edge-cases/dates
这个请求使用的是面向已登录成员的端点。登录创建免费账户
JSON(节选)
{
  "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 标准说键“应该”唯一,而不是“必须”唯一,所以下面这样在技术上是允许的:

GET/api/edge-cases/duplicate-keys
这个请求使用的是面向已登录成员的端点。登录创建免费账户
JSON
{
  "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 键,另一个端点却没有。

练习

发送 GET /api/Countries/code/IN登录后查看,根据响应回答下面的问题,每个路径都用本课的点号写法写出来:

  • population 是什么类型?callingCode 又是什么类型?它们为什么可能不同?
  • 货币符号的路径是什么?
  • 第二种语言的路径是什么?
  • flag 字段是一个表情符号。用 curl 查看原始响应,并和浏览器显示的内容比较。有什么不同?这对客户端有影响吗?

然后打开 /api/edge-cases/strings登录后查看,找出至少两个在浏览器里看起来没问题、却可能让存储或显示它们的程序出错的值。

练习与延伸