ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Hurl JSON Body 断言设计剖析:从文本级 Diff 走向语义化 JSONPath 错误

Hurl JSON Body 断言设计剖析:从文本级 Diff 走向语义化 JSONPath 错误 Hurl JSON Body 断言设计剖析从文本级 Diff 走向语义化 JSONPath 错误【免费下载链接】hurlHurl, run and test HTTP requests with plain text.项目地址: https://gitcode.com/GitHub_Trending/hu/hurlHurl 允许在响应体中直接编写 JSON 结构作为隐式断言本文基于仓库中的设计文档 docs/spec/runner/assert_json_body.md 展开剖析当前文本级比较实现的两个痛点、仓库中对应的源码实现细节以及设计文档提出的语义化 JSONPath 错误替代方案与逐项示例。读完本文你将理解 Hurl 中jsonpath断言在 body 场景下的语义、错误信息中行号与类型的映射规则以及该方案与完整 JSON Diff 工具如 jd之间的设计取舍。动机为什么 JSON body 断言不能只做文本比较设计文档开篇即点明现状目前 Hurl 对 JSON 响应是从文本textual角度而非语义semantic角度进行比较的。这种实现存在两个主要弊端下面逐一展开。弊端一等价 JSON 会因格式差异报错两个语义完全等价的 JSON仅仅因为缩进、空白不同或字段顺序不同就会触发断言失败。文档给出的示例GET http://localhost:8000/json { greeting: Hello }如果服务端把响应压缩成单行{greeting:Hello}运行hurl test.hurl就会得到error: Assert body value -- /tmp/test.hurl:3:1 | | GET http://localhost:8000/greeting | ... 3 | { | -{ | - greeting: Hello | -} | {greeting:Hello} | }注意这里错误标题是Assert body value输出的是带-/标记的统一 diffunified diff片段——这正是下文将要介绍的当前实现路径。弊端二真正不同时diff 被字段顺序污染当两个 JSON 确实不同时diff 虽然能指出差异但如果字段顺序不同错误信息会充满噪音难以定位真正变化的字段。文档示例GET http://localhost:8000/bob { name: Bob, age: 22 }服务端返回的 JSON 为{age: 20, name: Bob}字段顺序颠倒且age不同错误输出error: Assert body value -- /tmp/test2.hurl:4:1 | | GET http://localhost:8000/bob | ... 4 | name: Bob, | - name: Bob, | - age: 22 | -} | age: 20, | name: Bob | } | 整个对象块几乎都被标红读者很难一眼看出真正的差异只是age字段的取值。现状实现源码中的文本级 diff证据设计文档描述的现状在仓库源码中可以找到完整的对应实现。先看错误类型定义packages/hurl/src/runner/error.rs 中声明了两种与 body 断言相关的错误AssertBodyDiffError { hunks, body_source_info, ... }, AssertBodyValueError { actual, expected },两者在错误描述description()中都映射为同一句 Assert body value见 error.rs这解释了上面示例中错误标题的由来。AssertBodyDiffError持有的是hunksdiff 块集合而AssertBodyValueError只持有实际值与期望值的字符串。真正生成 diff 的逻辑位于 packages/hurl/src/runner/diff.rs它基于similarcrate 的TextDiff::from_lines做按行的文本 diff再转成 unified diff且context_radius(0)不保留上下文行最后把每个 diff 块封装为DiffHunk { content, start, source_line }pub fn diff(old: str, new: str) - VecDiffHunk { let text_diff TextDiff::from_lines(old, new); let mut unified_diff text_diff.unified_diff(); let unified_diff unified_diff.context_radius(0); ... for change in hunk.iter_changes() { let sign match change.tag() { ChangeTag::Delete -, ChangeTag::Insert , ChangeTag::Equal , }; ... } }在断言执行端packages/hurl/src/runner/assert.rs 的ImplicitBody分支完整实现了这段逻辑先比较actual expected相等则通过不相等时再判断use_diff(expected, actual)——需要生成 diff 就走AssertBodyDiffError并利用第一个 hunk 的source_line计算出错误在源文件中的行号否则退化为AssertBodyValueError。换句话说当前 Hurl 的 JSON body 隐式断言本质上仍是渲染后的文本相等性比较 行级 unified diff。这一点也由 error.rs 中的单元测试test_assert_error_newline佐证期望输出中包含Assert body value标题以及形如4 | pHello/p| 的 diff 渲染说明该错误路径是真实可达并被测试覆盖的。语义化方案复用 Hurl 已有的 JSONPath 错误语义既然文本级 diff 不理想设计文档提出的方案是当实际 JSON 与期望不同时生成 Hurl 标准的jsonpath断言错误而不是输出整段 diff。这样天然具备语义性——不关心空白与字段顺序只关心路径指向的值是否匹配。例如某个请求-- test.hurl:4:0 | | GET http://localhost:8000/modify | ... 4 | jsonpath $.age | actual: int 20 | expected: int 22错误信息直接给出路径、实际值与期望值且带有类型标注int比整段 diff 清晰得多。这种错误形式与 Hurl 现有的显式 JSONPath 断言docs/asserting-response.md 中的jsonpathassert完全一致。数组只在元素数量上报告差异对于数组当期望数组与实际数组大小不同时方案并不逐个元素展开 diff而是只报告count元素个数不匹配error: Assert JSON Body -- test.hurl:41:0 | | GET http://localhost:8000/add_json | ... 31 | jsonpath $.phone_numbers count 2 | actual: integer 3 | expected: integer 2设计文档特别说明如果数组新增或删除了某个元素逐个元素给出expected: not something之类的信息会难以理解not easy to understand因此在元素数量层面收敛错误语义最清晰。JSONPath 查询的底层实现语义化错误方案之所以可行是因为 Hurl 运行器本就内置了 JSONPath 查询能力。在 packages/hurl/src/runner/query.rs 中eval_query_jsonpath先从响应缓存BodyCache中取 JSON未解析则通过parse_cache_json用serde_json::from_str解析响应文本解析失败返回QueryInvalidJson错误随后交给filter::eval_jsonpath_json求值。这意味着JSON 响应会先被反序列化为 JSON 文档再查询天然与文本格式无关显式jsonpath断言与语义化 body 错误可以共享同一套查询与值比较基础设施由 integration/hurl/tests_ok/assert/assert_json.hurl 可见jsonpath断言支持、!、、count 、exists、isList、contains、nth 0 、[?(.iderror1)]过滤表达式等丰富谓词语义化错误方案可以完全复用这些谓词与类型系统int、integer、string、not something等。需要留意的是仓库中还存在一个影响 JSONPath 返回形态的选项--no-jsonpath-coercion见 docs/manual/hurl.md。默认开启的 JSONPath coercion 会把空结果返回为无值、单结果返回为标量关闭后 JSONPath 结果总是以数组返回use_jsonpath_coercion这一开关会一路传递到查询执行处assert.rs 中的options.use_jsonpath_coercion。在设计语义化 body 错误时这一行为同样会影响count n之类断言的语义值得实现时一并考虑。完整示例6 个语义化错误 Case 详解设计文档给出了一个经典的人物资料期望 JSON作为所有 case 的基准{ first_name: John, last_name: Smith, is_alive: true, age: 27, address: { street_address: 21 2nd Street, city: New York, state: NY, postal_code: 10021-3100 }, phone_numbers: [ { type: home, number: 212 555-1234 }, { type: office, number: 646 555-4567 } ], children: [ Catherine, Thomas, Trevor ], spouse: null }Case 1标量字段被修改age modified当age从 22 变成 20 时24 | jsonpath $.age | actual: int 20 | expected: int 22错误指向$.age这一路径实际值20与期望值22的类型均为int。Case 2字段被删除is_alive field deleted当is_alive字段整体消失时23 | jsonpath $.is_alive | actual: not something | expected: true实际值为not something表示该路径在响应 JSON 中不存在——这正是设计文档在数组场景中想避免的那种对单个元素使用not something表达但在字段缺失场景下它语义准确、清晰。Case 3字段被新增new country field added当响应多出了一个期望中没有的country字段时47 | jsonpath $.country | actual: spain | expected: not something设计文档特别指出这里的行号对应该字段若出现在源 Hurl 文件中应处于的行The line number matches the line for which it could be added in the source Hurl file。即错误行号不是从响应 JSON 推导而是映射回期望 JSON 在.hurl源文件中的位置保证用户能快速定位到自己的断言源码。Case 4嵌套数组元素被修改first phone number modified当第一个电话号码从212 555-1234变成210 555-1234时34 | jsonpath $.phone_numbers[0].number 212 555-1234 | actual: string 210 555-1234 | expected: string 212 555-1234这里展示了带下标[0]的路径表达式实际值与期望值都标注了string类型。Case 5数组元素被删除deleting a phone number当phone_numbers从 3 个元素变成 2 个时31 | jsonpath $.phone_numbers count 2 | actual: integer 2 | expected: integer 3Case 6数组元素被新增adding a phone number当phone_numbers从 2 个元素变成 3 个时31 | jsonpath $.phone_numbers count 2 | actual: integer 3 | expected: integer 2Case 5 与 Case 6 再次强调数组只报告元素个数差异且行号对应源 Hurl 文件中该数组的起始行The line number matches the start of the array in the source Hurl file。这样无论是增还是删报错位置都稳定、可预期用户修改断言时不需要去猜错误到底落在哪个元素上。边界与取舍为什么不叫JSON Diff设计文档在 Additional 一节明确划定了方案边界生成的错误信息并不能完整重建出实际 JSON因此不把该方案称为 JSON Diff。团队最初确实考虑过产出类似 jd 格式的完整 diffjd object1.json object2.json [age] - 20 22结论是字段值修改在这种格式下可读性不错但数组元素的增删在 Hurl 的输出格式中太难理解too hard to understand。因此最终采用逐路径报错 数组按数量报错的折中方案——信息量足以定位问题错误又足够聚焦。这个取舍也体现在错误渲染的实现上AssertBodyDiffError在fixme()中只取第一个 hunk渲染error.rs并保留// FIXME: this variant can not be called because message doesnt call it的注释——从源码结构看文本 diff 路径正在被有意收敛为语义化错误留出空间。而语义化方案所需的行号映射基础设施DiffHunk.source_line、Pos定位在 diff.rs 与 error.rs 中已经齐备错误位置由source_info.start.line source_line计算得出与设计文档行号匹配源 Hurl 文件的要求完全吻合。延伸阅读本文设计文档原文docs/spec/runner/assert_json_body.mdJSON body 断言语法隐式 body 断言与json多行语法docs/asserting-response.mdJSONPath 断言与谓词全表、count、exists、contains等docs/asserting-response.md运行器断言执行逻辑ImplicitBody分支见 packages/hurl/src/runner/assert.rs行级文本 diff 生成packages/hurl/src/runner/diff.rs错误类型与渲染Assert body valuepackages/hurl/src/runner/error.rsJSONPath 查询执行与 JSON 解析缓存packages/hurl/src/runner/query.rsJSONPath 断言语料集成测试integration/hurl/tests_ok/assert/assert_json.hurl--no-jsonpath-coercion选项说明docs/manual/hurl.md当前仓库中 JSON body 的隐式断言仍以文本级比较实现对应AssertBodyDiffError/AssertBodyValueError两条错误路径本文所剖析的设计方案为后续演进方向提供了明确的语义化路线图理解这套路径级报错、数组按数量收敛、行号回映射到源文件的错误模型将帮助你写出更健壮、更易定位失败的 Hurl 测试。【免费下载链接】hurlHurl, run and test HTTP requests with plain text.项目地址: https://gitcode.com/GitHub_Trending/hu/hurl创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进