首页
/ RealWorld Bruno 测试集合详解:用 GUI 化 API 测试工作流验证你的后端实现

RealWorld Bruno 测试集合详解:用 GUI 化 API 测试工作流验证你的后端实现

2026-09-04 09:47:09作者:裴麒琰

在构建 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 类型、认证)、headersbody:jsonassert(状态码断言)与 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,但断言要求 titleslugtagList 全部原样保留、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.mderror-handling.md
  • 想维护测试本身:修改 specs/api/hurl/ 下的 Hurl 文件后执行 make bruno-generate,再用 make bruno-check 验证同步状态。

Bruno 集合 + Hurl 源文件 + 生成/校验脚本,构成了 RealWorld 后端规范的可执行部分:它把“规范文档说了什么”变成了“测试断言了什么”,让任何新实现都能在被合并前通过同一把尺子度量。

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

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
982
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384