RealWorld Bruno 测试集合详解:用 GUI 化 API 测试工作流验证你的后端实现
在构建 RealWorld(Medium 克隆演示应用)后端时,最难的是保证实现与官方 API 规范完全一致。RealWorld 官方提供了一份开箱即用的 Bruno API 测试集合(位于 specs/api/bruno/),你可以在开发过程中用它逐个击破 Articles、Auth、Comments、Favorites、Feed 等全部端点:既可以用 run-api-tests-bruno.sh 一键跑完所有断言,也可以直接把 bruno/ 目录拖进 Bruno 客户端交互式调试。读完后,你将掌握 Bruno 集合的目录组织、.bru 请求文件语法、本地化运行方式,以及该集合由 Hurl 测试套件自动生成并通过 CI 保持同步的完整机制。
官方 Bruno 集合的定位
RealWorld 仓库为所有实现者内置了一套 API 测试资产。其中 Bruno 集合的作用(见 bruno 文档):
- 边开发边验证:在实现各端点时随时用集合中的请求对照你的 API 响应,快速发现字段缺失、状态码错误、null 归一化不符合规范等问题;
- 双模式使用:命令行批量执行(CI/本地回归),或打开
bruno/文件夹用 Bruno 桌面应用逐条交互式发请求、查看响应; - 单一事实来源(Source of Truth)是 Hurl:Bruno 集合由 Hurl 测试套件(
specs/api/hurl/)自动转换生成,并通过 CI 保持两者同步。换句话说,Bruno 是同一套测试用例的 GUI 化投影,而不是另一份独立维护的测试。
这与 Hurl 文档的说法相互印证:Hurl 文档中明确“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)”。
集合的目录结构与核心文件
集合根目录 specs/api/bruno/ 按 API 资源域划分文件夹(每个 .bru 文件是一个带断言的测试请求,文件名前缀数字即执行顺序):
| 目录 | 覆盖的规范域 | 典型用例 |
|---|---|---|
specs/api/bruno/auth/ |
注册/登录/当前用户/更新用户 | bio 空串归一化为 null、密码长度规则、PUT /api/user |
specs/api/bruno/articles/ |
文章 CRUD 与列表过滤 | 更新 body 后 tagList 保留、空数组清空标签、tagList 传 null 应被拒绝 |
specs/api/bruno/comments/ |
评论增删查 | 选择性删除(删一条、验证另一条仍在) |
specs/api/bruno/favorites/ |
收藏/取消收藏 | 收藏持久化验证、按收藏者过滤文章列表 |
specs/api/bruno/feed/ |
信息流 | 新用户 feed 为空、follow 后出现被关注者的文章、limit/offset |
specs/api/bruno/profiles/ |
用户资料与关注 | 匿名/登录获取资料、关注/取关及持久化验证 |
specs/api/bruno/tags/ |
标签聚合 | 创建带标签文章后 GET /api/tags |
specs/api/bruno/pagination/ |
分页 | limit=1 时最新优先、offset 翻页 |
specs/api/bruno/errors-*/ |
错误码矩阵 | 401 未认证、403 越权、404 未知 slug、422 校验失败等 |
specs/api/bruno/environments/ |
环境配置 | 非测试用例,运行脚本会自动跳过 |
集合根下还有两个关键文件:
1. 集合元数据 bruno.json:
{
"version": "1",
"name": "RealWorld API",
"type": "collection"
}
2. 集合级前置脚本 collection.bru——这是整套测试“可重复运行”的关键:
script:pre-request {
if (!bru.getVar("uid")) {
bru.setVar("uid", Date.now().toString() + Math.random().toString(36).substring(2, 6));
}
}
它在全局生成一个唯一 uid(时间戳 + 随机串),所有请求都用 auth_{{uid}}、art_{{uid}} 这类带唯一后缀的用户名/邮箱注册,避免多次运行之间因重名、重邮而互相污染。
3. 环境文件 environments/local.bru:
vars {
host: http://localhost:3000
}
所有请求 URL 都写成 {{host}}/api/... 形式,把目标后端地址收敛到这一个变量上,这也是运行脚本能一行命令切换被测服务地址的原因。
在本地对自建后端运行 Bruno 测试
原文档给出的操作指引是:本地运行这份 Bruno 集合时,参照 specs/api/README.md 的说明。具体命令如下(假设你的后端跑在 http://localhost:3000/api):
HOST=http://localhost:3000/api ./run-api-tests-bruno.sh
脚本 run-api-tests-bruno.sh 的完整行为值得逐行了解,因为它同时是 CI 和本地回归的入口:
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
HOST="${HOST:-http://localhost:8000}" # 被测后端地址,默认 8000 端口
BRUNO_SANDBOX="${BRUNO_SANDBOX:-safe}" # CLI 沙箱模式,默认 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
要点归纳:
- 可覆盖的环境变量:
HOST(默认http://localhost:8000)和BRUNO_SANDBOX(默认safe); - 默认全量、支持按域过滤:不传参数时遍历
bruno/下所有子目录(跳过environments);传入目录名则只跑指定域,例如./run-api-tests-bruno.sh auth articles; - 执行器:通过
bun x @usebruno/cli run <folder>逐文件夹运行,--env local选中 environments/local.bru,--env-var "host=$HOST"在运行时覆盖环境中的host变量; - 失败即中断:
set -euo pipefail保证任一请求断言失败时脚本立即以非零码退出,可直接接入 CI。
另外,也可以不用命令行,而是直接把 bruno/ 文件夹打开进 Bruno 桌面应用,交互式发送请求、检查响应——这对调试单个失败用例、查看请求头/正文细节非常方便。若偏好纯文本测试,同一规范域还有对应的 Hurl 集合 与 run-api-tests-hurl.sh 可用。
读懂 .bru 文件:请求、断言与状态传递
Bruno 的 .bru 文件由若干块(block)组成:meta(名称/类型/序号)、HTTP 方法块(URL、body 类型、认证)、headers、body:json、assert(状态码断言)与 script:post-response(JS 后置脚本,做变量捕获和深度断言)。两个真实例子可以说明套路:
注册(auth/01-register.bru)——建立后续用例的身份:
post {
url: {{host}}/api/users
body: json
auth: none
}
body:json {
{
"user": {
"username": "auth_{{uid}}",
"email": "auth_{{uid}}@test.com",
"password": "password123"
}
}
}
assert {
res.status: eq 201
}
script:post-response {
bru.setVar("reg_token", res.body.user.token);
expect(res.body.user.username).to.eql("auth_" + bru.getVar("uid"));
expect(res.body.user.email).to.eql("auth_" + bru.getVar("uid") + "@test.com");
expect(res.body.user.bio).to.be.null;
expect(res.body.user.image).to.be.null;
expect(typeof res.body.user.token).to.eql("string");
expect(res.body.user.token).to.not.eql("");
}
注意它不止校验状态码 201,还逐字段断言 bio/image 初始为 null、token 非空字符串——这正是 RealWorld 规范中对空值语义的严格要求。后置脚本把 token 存进集合变量,供同域后续请求(如 03-get-current-user.bru)通过 Token {{reg_token}} 头复用。
更新文章正文(articles/10-update-article-body.bru)——体现“未传字段必须保留”的规范:
put {
url: {{host}}/api/articles/{{slug}}
body: json
auth: none
}
headers {
Authorization: Token {{token}}
}
body:json {
{
"article": {
"body": "Updated body content"
}
}
}
assert {
res.status: eq 200
}
script:post-response {
expect(res.body.article.title).to.eql("Test Article " + bru.getVar("uid"));
expect(res.body.article.slug).to.eql(bru.getVar("slug"));
expect(res.body.article.body).to.eql("Updated body content");
expect(res.body.article.tagList).to.include("d_" + bru.getVar("uid"));
expect(res.body.article.tagList).to.include("t_" + bru.getVar("uid"));
expect(res.body.article.updatedAt).to.not.eql(bru.getVar("updated_at"));
}
只更新了 body,但断言要求 title、slug、tagList 全部原样保留、updatedAt 发生变化——这就是测试集合把规范里最容易被实现遗漏的语义(部分更新、时间戳刷新)固化成可执行断言的方式。类似的边界覆盖在集合中随处可见,例如 articles/15-update-article-taglist-null-should-be-rejected.bru(tagList 传 null 必须被拒绝)、errors-auth/18-update-password-shorter-than-8-chars-should-reject.bru(密码少于 8 位必须 422)。
Hurl 是唯一事实来源:生成与同步机制
Bruno 集合不是手工维护的,它的生成与校验由仓库根目录的 Makefile 中两个目标控制:
bruno-generate:
bun specs/api/hurl-to-bruno.js
bruno-check:
bun specs/api/hurl-to-bruno.js --check
转换脚本 hurl-to-bruno.js 的工作原理(从源码结构看):
- 扫描
specs/api/hurl/下全部.hurl文件,用一个行级状态机解析每个请求:#注释行作为请求分界并生成请求名,GET/POST/PUT/DELETE/PATCH <url>行确定方法与地址,随后的Key: Value行收集请求头,{触发的多行内容按大括号深度配对为 JSON body; - 把解析结果映射为 Bruno 语法:请求头写入
headers {}块,Hurl 的max-status/contains等期望映射为assert {}(如res.status: eq 201)与script:post-response中的expect(...)深度断言,变量捕获(Hurl 的varname = path)映射为bru.setVar(...); --check模式生成到临时目录(tmpdir)后与现有bruno/目录比对,不一致即失败——这就是 CI 中“kept in sync”的实现:任何只改了 Hurl 却没重新生成 Bruno 集合的提交都会在检查中暴露出来。
对实现者的实际意义是:修 Bug 或补充边界用例时应改 Hurl 文件,再运行 make bruno-generate 重新生成 Bruno 集合,两者由同一套断言语义驱动,不会出现“GUI 里看到的请求”与“CI 跑的断言”不一致的漂移。
小结与延伸阅读
- 想跑全量:
HOST=<你的后端> ./run-api-tests-bruno.sh,或按域过滤./run-api-tests-bruno.sh auth articles; - 想单步调试:用 Bruno 应用打开 specs/api/bruno/,逐个查看每个
.bru的断言细节; - 想理解用例覆盖的规范原文:对照 openapi.yml 与各域文档,如 endpoints.md、error-handling.md;
- 想维护测试本身:修改 specs/api/hurl/ 下的 Hurl 文件后执行
make bruno-generate,再用make bruno-check验证同步状态。
Bruno 集合 + Hurl 源文件 + 生成/校验脚本,构成了 RealWorld 后端规范的可执行部分:它把“规范文档说了什么”变成了“测试断言了什么”,让任何新实现都能在被合并前通过同一把尺子度量。
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 StartedRust0622
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