RealWorld API 测试规范详解:Hurl 测试套件、Bruno 自动生成与契约校验全流程
RealWorld("The mother of all demo apps")仓库中,specs/api/ 目录定义了整套后端 API 的验收测试方案:以 Hurl 测试套件为唯一事实来源(source of truth),并通过自动生成流水线同步出一份可交互使用的 Bruno 请求集合。读完本篇,你可以直接在自己的 RealWorld 后端实现上运行完整的 API 验收测试(Hurl 或 Bruno 两种方式),理解 host/uid 变量机制、断言与捕获的写法,以及 Hurl 到 Bruno 的转换与 CI 同步校验是如何实现的。
specs/api 目录结构:一次看懂整套 API 规范资产
specs/api/README.md 是这套资产的入口文档,目录下的文件各司其职:
| 文件/目录 | 作用 |
|---|---|
| openapi.yml | RealWorld 后端的 OpenAPI 契约定义,实现者据此对齐端点与数据结构 |
| hurl/ | Hurl 测试套件,事实来源(source of truth),按业务域拆分为 13 个 .hurl 文件 |
| bruno/ | Bruno 请求集合,由 Hurl 套件自动生成,不应手工修改 |
| hurl-to-bruno.js | Hurl → Bruno 生成/校验脚本(bun 执行) |
| run-api-tests-hurl.sh | Hurl 测试运行入口脚本 |
| run-api-tests-bruno.sh | Bruno 测试运行入口脚本 |
两条执行命令对应两种工具链,README 给出的原始用法为:
HOST=http://localhost:3000/api ./run-api-tests-hurl.sh
HOST=http://localhost:3000/api ./run-api-tests-bruno.sh
Note: Hurl 文件是事实来源。Bruno 集合通过
make bruno-generate生成,并在 CI 中通过make bruno-check保持同步(原文档说明,见 Makefile 中的对应 target)。
Hurl 测试运行器:变量机制与执行参数
run-api-tests-hurl.sh 是一个约 20 行的 Bash 脚本,逐行看它的行为:
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
HOST="${HOST:-http://localhost:8000}"
UID_VAL="${UID_VAL:-$(date +%s)$$}"
echo "Running Hurl tests against $HOST with uid=$UID_VAL"
FILES=("$@")
if [ ${#FILES[@]} -eq 0 ]; then
FILES=("$DIR"/hurl/*.hurl)
fi
hurl --test \
--jobs 1 \
--variable "host=$HOST" \
--variable "uid=$UID_VAL" \
"${FILES[@]}"
关键设计点:
HOST环境变量:被测后端地址,默认http://localhost:8000。注意各实现的后端端口不同(Node 示例默认 3000,故 README 示例传入http://localhost:3000/api;部分实现的 API 路径不带/api前缀,此时直接传http://localhost:3000即可,见 specs/api/hurl/ 各文件中{{host}}的拼接方式)。UID_VAL隔离变量:默认由date +%s时间戳拼接$$(当前进程 PID)生成,形如17123456782341。所有测试账号以auth_{{uid}}命名,保证多次运行互不冲突、重复执行不会因用户名/邮箱重复而失败。--jobs 1串行执行:同一文件内的请求存在强依赖(后一个请求捕获前一个请求的 token),必须串行;--test开启测试模式(任一断言失败即退出码非 0),set -euo pipefail使脚本在任一失败时立即终止。- 可选的位置参数:不带参数时执行
hurl/目录下全部.hurl文件;也可以只跑其中几个文件做定向排查,例如./run-api-tests-hurl.sh hurl/auth.hurl。
hurl/ 目录内还有一份内容完全相同的辅助脚本 run-hurl-tests.sh,供在 hurl/ 目录内直接调用(其 FILES 默认值指向当前目录的 *.hurl)。
读懂一个 Hurl 测试文件:以 auth.hurl 为例
hurl/auth.hurl 覆盖了用户注册、登录、获取当前用户、更新资料等端点。文件开头的完整示例(L1-L23):
# Register
POST {{host}}/api/users
{
"user": {
"username": "auth_{{uid}}",
"email": "auth_{{uid}}@test.com",
"password": "password123"
}
}
HTTP 201
[Asserts]
jsonpath "$.user.username" == "auth_{{uid}}"
jsonpath "$.user.email" == "auth_{{uid}}@test.com"
jsonpath "$.user.bio" == null
jsonpath "$.user.image" == null
jsonpath "$.user.token" isString
jsonpath "$.user.token" not isEmpty
[Captures]
reg_token: jsonpath "$.user.token"
# Login
POST {{host}}/api/users/login
{
"user": {
"email": "auth_{{uid}}@test.com",
"password": "password123"
}
}
HTTP 200
[Asserts]
jsonpath "$.user.username" == "auth_{{uid}}"
...
[Captures]
token: jsonpath "$.user.token"
Hurl 的语法要素在这里全部体现:
- 请求块:
METHOD {{host}}/path行 + 缩进的 JSON body;HTTP 201行本身就是状态码断言。 - [Asserts] 节:
jsonpath表达式断言。除==比较外,还支持isString、not isEmpty、count == N、contains、matches "正则"、not exists等(从 hurl-to-bruno.js 的断言转换器支持的算子清单可见全集,见下文)。 - [Captures] 节:把响应中的值捕获为变量(如登录后的
token),供后续请求的Authorization: Token {{token}}头引用——这是整条测试链的"胶水"。 # 注释行充当每个请求的逻辑标题,转换器会把它变成 Bruno 请求的name。
值得注意的业务语义验证也在断言里:
- L81-L98:把
bio更新为空字符串后断言$.user.bio == null,即规范约定空字符串必须被规范化为null; - L112-L129:把
bio设为显式null应被接受(可空字段); - L216-L246:更新
username/email后,用响应中新的 token 再发一次GET /api/user,验证改名后的凭据依然有效且数据已持久化。
这种"修改 → 读回验证"的模式贯穿全套测试,例如 hurl/favorites.hurl 中收藏后重新拉取文章确认 favorited 状态、hurl/feed.hurl 中关注创作者后检查其文章是否进入信息流等。
测试套件的覆盖范围:业务流与错误流分离
hurl/ 下的 13 个文件按业务域切分:
| 文件 | 覆盖内容 |
|---|---|
| auth.hurl | 注册、登录、获取/更新当前用户,含空串→null 规范化、token 更新等边界 |
| articles.hurl | 文章 CRUD、按作者/标签检索 |
| comments.hurl | 评论增删查 |
| profiles.hurl | 个人主页、关注/取关 |
| feed.hurl | 关注信息流与 limit/offset 分页 |
| pagination.hurl | 列表接口的 limit/offset 翻页正确性 |
| tags.hurl | 标签聚合查询 |
| favorites.hurl | 收藏/取消收藏及按收藏者过滤文章 |
| errors_auth.hurl | 认证错误:空用户名/邮箱/密码、重复注册、密码错误、无 token 访问等 401/400/422 场景 |
| errors_articles.hurl | 文章端点的错误场景:无权限、未知 slug、空 title/description/body |
| errors_comments.hurl | 评论错误场景 |
| errors_profiles.hurl | 未知用户 404、无权限 401 等 |
| errors_authorization.hurl | 跨用户越权:B 用户尝试删除/修改 A 用户资源应得 403 |
正交流与错误流分开成文件,意味着排查某个域的失败时可以只跑对应的 .hurl 文件;errors_* 文件则专门锁定各实现必须统一的错误码与错误响应格式(响应格式约定见 docs/src/content/docs/specifications/backend/api-response-format.md 与 docs/src/content/docs/specifications/backend/error-handling.md)。
Bruno 测试运行器:CLI 批跑与交互式使用
run-api-tests-bruno.sh 的执行逻辑:
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
HOST="${HOST:-http://localhost:8000}"
BRUNO_SANDBOX="${BRUNO_SANDBOX:-safe}"
echo "Running Bruno tests against $HOST"
FOLDERS=("$@")
if [ ${#FOLDERS[@]} -eq 0 ]; then
for entry in "$DIR"/bruno/*/; do
name="$(basename "$entry")"
[ "$name" = "environments" ] && continue
FOLDERS+=("$name")
done
fi
cd "$DIR/bruno"
for folder in "${FOLDERS[@]}"; do
echo ""
echo "--- bun x @usebruno/cli run $folder ---"
bun x @usebruno/cli run "$folder" --env local --env-var "host=$HOST" --sandbox "$BRUNO_SANDBOX"
done
行为要点:
- 默认遍历
bruno/下所有业务文件夹,跳过environments/(它只存放环境定义,不是测试);也可以传位置参数只跑指定文件夹,例如./run-api-tests-bruno.sh auth articles。 - 每个文件夹一次
bun x @usebruno/cli run调用,--env local指定使用 environments/local.bru(其中默认host: http://localhost:3000),--env-var "host=$HOST"在运行时覆盖该默认值,--sandbox safe(可用BRUNO_SANDBOX环境变量调整)限制 post-response 脚本的执行权限。 - 由于 Bruno 集合里的断言写在
script:post-response中,运行 Bruno 集合需要bun环境(脚本通过bun x @usebruno/cli拉起 CLI)。
除 CI 批跑外,README 还提示可以直接用 Bruno 桌面应用打开 bruno/ 文件夹,交互式地逐个发送和检查请求——这对调试单个端点很方便。注意 collection.bru 中有集合级 pre-request 脚本:若集合变量 uid 未设置,会自动生成一个 时间戳+随机串,与 Hurl 侧的 UID_VAL 机制对应。
生成与同步流水线:Hurl 是唯一事实来源
README 的核心声明是:"The Hurl files are the source of truth. The Bruno collection is generated with make bruno-generate and kept in sync via CI (make bruno-check)." 这条流水线的实现全部在 hurl-to-bruno.js(用 bun 执行,--check 参数切换校验模式)。
Hurl 文件解析器:一个逐行状态机
parseHurlFile 把 .hurl 文本解析为结构化请求数组(method/url/headers/body/statusCode/asserts/captures),状态机为 IDLE → HEADERS → BODY → RESPONSE → ASSERTS/CAPTURES。两个值得注意的实现细节:
- JSON body 边界靠花括号深度计数(L123-L135):从首个顶层
{开始累计{/},深度归零即 body 结束——因此 body 内部的空行会被原样保留; #注释行触发请求切分(L56-L70),注释文本成为该请求的命名来源;同时也支持无注释的连续请求(L76-L82 的 back-to-back 处理)。
断言与变量的语法映射
assertToJs 把 Hurl 的 jsonpath 断言逐条翻译为 Bruno post-response 脚本里的 Chai 风格断言,支持的算子全集是:==/!=/>=、count == / >=、isString、isInteger、isCollection、isList、isObject、isBoolean、not isEmpty、not exists、contains、matches "regex"。例如 jsonpath "$.user.token" not isEmpty 会变成 expect(res.body.user.token).to.not.eql("");。
变量模板由 transformValue 处理:{{slug}} 整体是变量时转成 bru.getVar("slug");"auth_{{uid}}" 这种文本+变量混合时拼成字符串连接表达式;裸值 null/true/数字则转为字面量。[Captures] 中的 reg_token: jsonpath "$.user.token" 则生成 bru.setVar("reg_token", res.body.user.token);。
生成的集合结构
generateCollection 的输出与仓库中 bruno/ 目录的实际内容一一对应:
- bruno/bruno.json:集合元数据(
name: "RealWorld API",type: "collection"); - bruno/collection.bru:集合级 pre-request,自动生成
uid集合变量(对应 Hurl 侧的UID_VAL); - bruno/environments/local.bru:本地环境默认
host: http://localhost:3000; - 每个
.hurl文件映射为一个文件夹(文件名下划线转连字符,如errors_auth.hurl→errors-auth/),文件内每个请求映射为NN-名称.bru(fileName:两位数序号 + 注释的 slug 化),例如bruno/auth/01-register.bru、bruno/auth/02-login.bru。请求的meta.seq保持 Hurl 中的原始顺序,保证 CLI 批跑时链路依赖不被打乱。
check 模式:CI 里的字节级同步校验
checkMode 的机制是:把 Hurl 套件重新生成到临时目录,然后与已提交的 bruno/ 目录逐文件、逐字节对比,差异分为 missing(缺文件)/extra(多余文件)/changed(内容不同)三类;只要有任何差异就打印提示 Run 'bun api/hurl-to-bruno.js' to regenerate. 并以退出码 1 失败。配合 CI 中的 make bruno-check,任何人直接手改 Bruno 集合都会被拦截——修改 Bruno 请求的正确姿势是先改 Hurl 源头,再重新生成(Makefile 中的 target 见 Makefile)。
后端实现者视角:如何用它验证你的实现
综合上述内容,对一个新的 RealWorld 后端实现做 API 验收的完整步骤是:
-
以 openapi.yml 为契约对齐端点、请求/响应结构与错误格式;
-
启动后端,确认 API 基地址(注意是否含
/api前缀); -
运行 Hurl 全量套件:
HOST=http://localhost:3000/api ./run-api-tests-hurl.sh或只跑某一业务域:
./run-api-tests-hurl.sh hurl/articles.hurl; -
需要交互式排查时,用 Bruno 应用打开 bruno/ 集合,或
HOST=... ./run-api-tests-bruno.sh auth只批跑指定文件夹; -
若你维护的是"生成物 + 源头"双份资产,改任何请求后执行
make bruno-generate重新生成,CI 的make bruno-check会保证两者不再分叉。
小结
specs/api/ 用"单一事实来源 + 自动生成 + CI 字节级校验"的组合解决了 API 验收测试的经典问题:同一套业务场景既要有可进 CI 的无头批跑(Hurl),又要有可交互调试的请求集合(Bruno),而两者的一致性由 hurl-to-bruno.js 这条生成流水线强制保证。host/uid 双变量让测试对任意实例、任意端口可移植且可重复执行;业务流与 errors_* 错误流分离的文件组织,则让"正例验收"和"错误码契约"可以独立运行、独立排查。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00