首页
/ RealWorld API 测试规范详解:Hurl 测试套件、Bruno 自动生成与契约校验全流程

RealWorld API 测试规范详解:Hurl 测试套件、Bruno 自动生成与契约校验全流程

2026-09-04 20:18:47作者:柏廷章Berta

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 表达式断言。除 == 比较外,还支持 isStringnot isEmptycount == Ncontainsmatches "正则"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.mddocs/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 == / >=isStringisIntegerisCollectionisListisObjectisBooleannot isEmptynot existscontainsmatches "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.hurlerrors-auth/),文件内每个请求映射为 NN-名称.brufileName:两位数序号 + 注释的 slug 化),例如 bruno/auth/01-register.brubruno/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 验收的完整步骤是:

  1. openapi.yml 为契约对齐端点、请求/响应结构与错误格式;

  2. 启动后端,确认 API 基地址(注意是否含 /api 前缀);

  3. 运行 Hurl 全量套件:

    HOST=http://localhost:3000/api ./run-api-tests-hurl.sh
    

    或只跑某一业务域:./run-api-tests-hurl.sh hurl/articles.hurl

  4. 需要交互式排查时,用 Bruno 应用打开 bruno/ 集合,或 HOST=... ./run-api-tests-bruno.sh auth 只批跑指定文件夹;

  5. 若你维护的是"生成物 + 源头"双份资产,改任何请求后执行 make bruno-generate 重新生成,CI 的 make bruno-check 会保证两者不再分叉。

小结

specs/api/ 用"单一事实来源 + 自动生成 + CI 字节级校验"的组合解决了 API 验收测试的经典问题:同一套业务场景既要有可进 CI 的无头批跑(Hurl),又要有可交互调试的请求集合(Bruno),而两者的一致性由 hurl-to-bruno.js 这条生成流水线强制保证。host/uid 双变量让测试对任意实例、任意端口可移植且可重复执行;业务流与 errors_* 错误流分离的文件组织,则让"正例验收"和"错误码契约"可以独立运行、独立排查。

登录后查看全文
热门项目推荐
相关项目推荐