有效的JSON,但契约失效:针对有效载荷回退问题的多层检测机制
学会检测能够正确解析的 JSON 载荷回归问题:语义差异对比、聚焦式的 JSON Schema、Node 中的业务规则断言,以及生成类型的局限性。
来源: Catch JSON Contract Regressions Before They Reach Your App
响应可能以HTTP 200状态返回,解析时也不会出现任何错误,但仍可能导致应用程序出错。语法验证仅能证明该文本是JSON格式,却无法说明其内容是否仍符合代码的预期。本指南将构建一个简单的分层回归检测机制(语义比较、针对性的架构规范以及明确的业务规则),从而让协议违反问题以清晰的信息形式呈现,而非变成下游的奇怪故障。
虽能解析但仍失败的负载数据
以订单服务为例。接口响应成功,JSON也能正确解析,但用于计算商品数量的代码却输出了"021"而非3。这个场景虽是虚构的,但这类故障模式很常见:在数据量较大的情况下,字符串"2"与数字2很容易被忽略,两者都是合法的JSON格式,但只有其中一种能符合合同要求。
下面的示例数据代表即将处理的订单。仔细观察第一项商品:其quantity字段是带引号的字符串,而第二项商品则使用纯数字。其余所有内容都是合理的订单数据,包括null的配送说明以及预先计算好的totalCents值。
{
"orderId": "ORD-1042",
"createdAt": "2026-09-21T09:30:00Z",
"customer": {"id": "C-017", "name": "Avery Example", "vip": false},
"items": [
{"sku": "MUG-BLUE", "quantity": "2", "unitPriceCents": 1250},
{"sku": "NOTE-A5", "quantity": 1, "unitPriceCents": 650}
],
"shipping": {"city": "Exampleton", "instructions": null},
"paid": true,
"totalCents": 3150
}
严格的语法检查能够毫无问题地接受这份文档,而这正是合理的结果:该文本符合RFC 8259中规定的JSON语法,其中字符串、数字、布尔值以及null都属于有效值。不过该语法无法识别应用程序添加的额外规则:
quantity必须为大于零的整数paid必须为布尔值- 每个数量乘以其单价后的总和必须等于
totalCents
假设此订单的上一版本仅在两处有所不同:quantity 的值为数字 2,而 paid 的值为 false。支付状态标志变为 true 是预期中的状态变化;而 quantity 从数字变为字符串则属于功能退化。
按路径而非文本行显示差异值
在比较负载数据时,有两种截然不同的查询方式。语义对比会解析两份文档,并比对每个路径上的值。它认为对象键的顺序无关紧要,因此交换customer.id和customer.name的位置并不算变化;但它认为数组的顺序很重要,因此将某个元素移动到另一个索引位置则会被视为变化。对于这个测试用例,语义差异报告恰好列出两个路径:$.items[0].quantity和$.paid。这就回答了“哪些数据发生了变化?”这个问题。
文本差异对比回答的是另一个问题:“哪些字符或行发生了变化?”它仍然是有用的证据,先统一两侧的缩进和格式可以去除很多无关信息。但文本的相同或不同并不能证明两个文档代表或不代表相同的 JSON 值,因为空白字符、键的顺序以及数字格式都可能有所不同,而不会改变其含义。
在纯 Node 环境中复现这些检查
你无需任何外部服务来验证这些事实。下面的脚本会通过 import.meta.url 读取其所在目录下的 order.json,使用 structuredClone 重建之前的版本,并借助 node:assert/strict 检查其中的差异。随后它会演示该错误,对副本进行修复,并验证计算出的总计是否与 totalCents 相符这一业务规则。将其保存为 check-order.mjs,放在测试用例的旁边,然后运行 node check-order.mjs。
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
const incoming = JSON.parse(
readFileSync(new URL('./order.json', import.meta.url), 'utf8')
);
const before = structuredClone(incoming);
before.items[0].quantity = 2;
before.paid = false;
assert.equal(before.items[0].quantity, 2);
assert.equal(incoming.items[0].quantity, '2');
assert.equal(before.paid, false);
assert.equal(incoming.paid, true);
const naiveCount = incoming.items.reduce(
(sum, item) => sum + item.quantity,
0
);
assert.equal(naiveCount, '021');
const corrected = structuredClone(incoming);
corrected.items[0].quantity = 2;
const count = corrected.items.reduce((sum, item) => sum + item.quantity, 0);
const computedTotal = corrected.items.reduce(
(sum, item) => sum + item.quantity * item.unitPriceCents,
0
);
assert.equal(count, 3);
assert.equal(computedTotal, corrected.totalCents);
console.log('Contract checks passed');
naiveCount 这一实现很有特点。计算从数字 0 开始,接着加上字符串 "2",最终得到字符串 "02",这是因为只要操作数中有一个是字符串,+ 运算符就会转为连接操作。下一步再追加 1,这样就得到了 "021"。数值类型的初始值不会强制转换后续的操作数;一旦有字符串进入累加器,之后的所有内容都会被连接在一起。
需要注意的是,脚本中并未推荐某种做法。将测试用例的参数改为数值是一种实现正确契约的方式,而非要求必须用 Number() 包装所有传入的字段。隐式的强制转换会掩盖数据生成端的错误。要么修复生成字符串的服务,要么在数据进入系统的边界处明确制定并记录相应的标准化规则。
将类型规则转换为模式
普通规则很容易被遗忘,但可执行的规则则不会。下面的 2020-12 草稿模式编码了此次回归测试中需要满足的要求。它要求必须包含 orderId、items、paid 和 totalCents,至少需要一个商品项,并且将 quantity 限制为最小值为 1 的整数,同时将 unitPriceCents 限制为非负整数。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["orderId", "items", "paid", "totalCents"],
"properties": {
"orderId": {"type": "string"},
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["sku", "quantity", "unitPriceCents"],
"properties": {
"sku": {"type": "string"},
"quantity": {"type": "integer", "minimum": 1},
"unitPriceCents": {"type": "integer", "minimum": 0}
}
}
},
"paid": {"type": "boolean"},
"totalCents": {"type": "integer", "minimum": 0}
}
}
使用该模式验证传入的数据时,会出现类似 $.items[0].quantity: expected type integer 的错误信息。将该值改为 2 后,验证即可通过。正是这种精准的定位,才符合回归测试的要求。
明确了解你的验证器实际覆盖的内容
验证器的实现程度各不相同。有些验证器会刻意跳过对远程 $ref 目标的解析、pattern 正则表达式的评估以及 format 关键字的校验,而根据规范,format 应被视为注释而非校验条件。请确保在测试中使用的验证器能够覆盖生产环境契约所依赖的相同关键字,否则架构看起来可能比实际执行的校验更为严格。
值得单独测试的边界情况
一个良好的契约会因不同原因拒绝无效输入,这些情况都应能独立触发失败:
items完全缺失(违反required要求)items为空数组(违反minItems要求)quantity值为零(违反minimum要求)
paid 以字符串 "true" 的形式传递(type)同样重要的是应传递什么值:未付款的订单对应的值为 paid: false。required 关键字仅用于检查该属性是否存在,而非其值是否为真值。官方的 JSON Schema 指南说明了 required 如何与对象属性相关联,以及 每个数组元素如何根据 items 子模式进行验证。
Schema 故意省略的内容
这是一个针对性的模式,而非完整的订单规范。客户信息、运输方式、时间戳语义以及额外字段均无约束。算术规则也刻意被排除在外:各条目是否加总为 totalCents 属于应用逻辑,标准 JSON Schema 无法表达这一点,因此 Node 脚本会单独进行验证。将两者分开处理能让每种工具发挥其最佳功能。
生成的类型描述单个样本
人们很容易将数据直接粘贴到 JSON 转 TypeScript 生成器中,然后将输出视为契约。在经过修正的测试数据上运行时,此类生成器会在嵌套类型中产生诸如 quantity: number、createdAt: string 和 instructions: null 这样的结果。这些仅是对该单个样本的准确描述,仅此而已。
- JSON仅有一种数字类型,因此
number无法表示“正整数”。 - 仅凭一个示例无法判断哪些字段是可选的。
- 今天值为
null的字段,明天完全可能存储字符串。 - TypeScript类型在运行时会被忽略,且不会检查传入的数据字节;TypeScript手册中关于类型断言的部分明确指出,类型断言不会在运行时进行任何检查。
如果您需要一个既能用于运行时验证又能定义静态类型的统一规范,请阅读我们关于在前后端共享同一份Zod Schema的文章。
精度同样适用于边界情况
数字还会带来另一个陷阱。JavaScript会将{"id":9007199254740993}解析为9007199254740992,因为该值超出了Number.MAX_SAFE_INTEGER的上限,因此会被四舍五入为最接近的可表示双精度数。如果标识符必须保留所有数字,那么定义时就应该将其设为字符串。在JSON.parse之后试图修复已经为时过晚,因为那些数字早已丢失。
关键要点
- 保留经过处理的、已知有效的测试数据,切勿在测试数据中使用真实的凭证或个人信息。
- 将验证步骤视为层层递进的流程:严格的语法检查、语义值差异比对、模式验证,最后才是模式无法表达的业务规则。
- 只有在合同细节确定之后才生成TypeScript类型,切勿将其误认为是运行时验证手段。
$.items[0].quantity从整数变为字符串,导致验证失败且无法进行拼接”,而非仅说明“顺序有误”。