返回 文章 build CMS 文章

Rails 测试自动化:用 Mistral Vibe 构建自主代理为开发者补上测试

用 Vibe 代理自动为 Rails 项目生成和优化 RSpec 测试,解决大型单体应用测试缺失问题。

RailsRSpec自动化测试AI代理
成长分 / 100 68 综合收获、行动、留存与影响

Rails 测试自动化:用 Mistral Vibe 构建自主代理为开发者补上测试
为什么值得读了解如何用上下文工程(AGENTS.md)和技能文件(SKILLS)引导编码代理生成高质量测试。

学习将 RuboCop 和 SimpleCov 作为自定义工具集成到代理工作流中,确保测试可运行且符合规范。

关键洞察
  1. 代理通过仓库级 AGENTS.md 文件获得逐步执行计划,质量分数从 0.68 提升到 0.74。
  2. 为不同文件类型(模型、控制器等)创建专门的技能文件,避免通用指令导致测试质量下降。
  3. 将 SimpleCov 与 RSpec 执行作为最后一步,强制代理运行每个测试,只有三分之一的测试首次通过,但代理能自我修正。
转成行动

深入阅读

正文与原文对照

原文保真覆盖:全文原文字符:15849

在大多数大型 Rails 单体应用中,组织优先考虑编写新功能,而不是为它们编写测试。随着时间的推移,越来越多的代码未经测试,迫使团队花费更多时间调试令人痛苦的 bug。

我们构建了一个自主代理来弥补这一差距。它读取 Rails 源文件,生成或改进 RSpec 测试,根据样式规则和覆盖率目标验证它们,并在 CI/CD 管道中运行,无需人工干预。为了在这种规模的代码库上运行,它并行工作:多个实例同时处理不同的文件。

代理眼中的 RSpec

Ruby 是动态类型的:没有编译步骤,因此错误在运行时才会显现。对于我们的代理来说,这意味着验证测试语法的唯一方法就是执行它。 RSpec 是标准的 Rails 测试框架,它使测试具有表现力和可读性,但其领域特定语言很容易出错。

当代理读取 Ruby on Rails 代码库时,它会读取五种主要文件类型(模型、序列化器、控制器、邮件程序、辅助程序),每种文件结构不同(因此测试方式也不同)。代理需要针对每种类型的不同指令。

一个好处是:从源文件到规范文件的映射几乎是 1:1 的。一般约定是:

然而,该规则有一些例外,例如 app/controllers/

有时会映射到 spec/requests/

,或者有时单个源文件可以有多个规范文件,在这种情况下,约定是:

这种直接的映射使得定位任何给定文件的测试,或识别完全缺少测试的文件变得容易。

对我们的代理来说,更难的地方在于,为了避免重复代码,RSpec 严重依赖共享上下文:工厂、固定装置、数据库模式……

工厂:用于创建具有预定义属性的测试对象的可重用模板,使得生成一致的测试数据变得容易。固定装置:预加载测试数据库记录的静态数据文件,为测试提供固定的基线。

如果工厂文件不存在,代理会创建它;如果存在,代理会重用它。因为工厂在许多测试中共享(与规范文件不同),粗心的更改很容易破坏其他地方的测试,所以对这些文件的更新必须谨慎进行。

使用 Vibe 构建代理

我们在 Vibe 之上构建了代理,这是 Mistral 的开源编码助手。默认系统提示对于这个项目来说已经足够,因此我们专注于三个杠杆:仓库级上下文、专业技能和自定义工具。

上下文工程

上下文工程是我们方法的核心。Vibe 支持仓库级的 AGENTS.md

文件:当在根目录下有此文件的仓库上运行时,其内容会自动附加到系统提示中。

我们使用的 AGENTS.md

提供了有关目标仓库的基本细节,但主要是为代理提供了逐步执行计划:

1. 读取源文件2. 读取文档(如果存在)3. 检查是否已存在规范4. 根据源文件位置选择并读取恰好一个技能5. 查找现有模式、工厂和辅助程序6. 执行技能(提取 → 工厂 → 生成测试)7. 使用 Rubocop 工具验证8. 使用 SimpleCov 工具验证

每一步都包含了要做什么以及成功标准是什么的细节。我们还加入了一些 RSpec 的最佳实践,用于我们认为对引导智能体很重要的方面。例如:

- **绝不**使用 be_presentbe_truthybe_betweeninclude(:key)这些都很模糊。始终使用 eq(exact_value)``

我们发现智能体有时会跳过方法或留下未测试的边缘情况:它会生成一个看起来完整的规格,但悄悄忽略了源文件中的几个公共方法。为了解决这个问题,AGENTS.md

以强制自我审查结尾:智能体必须重新阅读源文件,并在完成前明确问自己“我测试了每个公共方法吗?数一数。”如果有任何遗漏,就返回去。

有了这个通用的 AGENTS.md

文件强制智能体遵循严格的规划,我们的质量分数从 0.68 提升到 0.74,这一切都来自一个包含框架级指令的 markdown 文件。

使用 SKILLS 文件:

回想我们 AGENTS.md 的第 4 步

4. 根据源文件位置选择并阅读恰好一个技能

一个通用的技能会产生平庸的结果:对测试模型文件足够精确的指令,对控制器文件来说就是错误的指令。

有效的方法是为每个类别创建一个单独的技能文件,再加上一个用于纯 Ruby 文件的技能文件。

以下是一个用于测试控制器的基本技能文件示例:

---name: "Generate Request Spec"description: "为 Rails 控制器生成 RSpec 请求测试。当源文件位于 app/controllers/ 时使用。"---
# 生成请求测试
## 文件范围
- `spec/requests//_spec.rb` — 从文件名中去掉 `_controller`- `spec/factories/.rb` — 如有需要则创建或更新
## 控制器的示例测试
# frozen_string_literal: truerequire 'rails_helper'
describe 'Admin::Users', type: :request do let(:user) { create(:user, :admin) } before { sign_in user }
# 未授权访问 — 每个 action 一个测试 describe '#authorized?' do let(:user) { create(:user) } it 'GET /admin/users redirects' do get '/admin/users' expect(response).to have_http_status(:redirect) end end
# 每个 action:正常路径 + 异常路径 describe 'POST /admin/users' do let(:valid_params) { { user: attributes_for(:user) } } let(:invalid_params) { { user: { email: '' } } }
context 'with valid params' do it 'creates a record' do expect { post '/admin/users', params: valid_params }.to change(User, :count).by(1) expect(response).to have_http_status(:created) end end
context 'with invalid params' do it 'returns unprocessable entity with errors' do post '/admin/users', params: invalid_params expect(response).to have_http_status(:unprocessable_entity) json = JSON.parse(response.body, symbolize_names: true) expect(json[:errors]).to include("Email can't be blank") end end endend
## 关键规则
- **断言内容,而不仅仅是状态:** 始终解析 JSON 并验证确切的值- **测试确切的错误消息:** `include("Email can't be blank")`,而不是 `be_present`- **验证状态变化:** 使用 `.by(1)` 并检查所创建记录的属性- **覆盖所有 action:** index、show、create、update、destroy 以及任何自定义 action- **对每个 action 测试认证:** 验证未授权用户得到正确的状态码- 只使用 `let`,绝不使用 `@instance_variables`;在 `follow_redirect!` 之前再次 `sign_in user`

添加自定义工具

Vibe 内置了用于读写文件、运行 bash 命令和编辑代码的工具。但它也支持自定义工具和 MCP,以扩展其操作空间。对于这个项目,自定义工具至关重要。

经过一些实验,我们选择了:

一个

RuboCop 代码检查工具,它运行 rubocop

命令来检测风格违规,以便智能体返回代码并修复它们。一个

SimpleCov 工具与 RSpec 集成,同时检查代码覆盖率和测试正确性。将其作为最后一步是关键:它保证智能体编写的每个测试都实际运行。

下面是我们实现 Rubocop 代码检查工具的简化版本:

import asynciofrom pathlib import Path
from pydantic import BaseModelfrom vibe.core.tools.base import BaseTool, ToolError
class RubocopLintResult(BaseModel): command: str output: str success: bool exit_code: int | None = None
class RubocopLint(BaseTool): description = "Lint spec files using Rubocop."
async def run(self, spec_path: str): spec_path = Path(spec_path)
cmd = ["rubocop", "--format", "simple", str(spec_path)] proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE )
try: stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60) except TimeoutError: proc.kill() await proc.wait() raise ToolError("Rubocop timed out after 60s")
output = stdout.decode("utf-8", errors="ignore")
yield RubocopLintResult( command=" ".join(cmd), output=output, success=proc.returncode == 0, exit_code=proc.returncode )

SimpleCov 工具遵循相同的模式,但做得更多:它使用 RSpec 运行 spec 文件,收集每个测试的通过/失败结果,并报告源文件的覆盖率百分比。智能体可以获知哪些测试失败了(附带错误信息),以及源文件中哪些行从未被执行过。这为它在需要时进行自我纠正提供了足够的信息。

当我们最初接入这个工具时,只有大约三分之一的生成测试在首次执行时通过。智能体在几次迭代内自我纠正了所有失败。如果没有这一步,其中一些测试根本不会运行。

衡量测试质量

一个好的单元测试遵循 Arrange-Act-Assert 模式:设置状态、执行一个行为、验证结果。它应该使用精确的断言,覆盖正常路径和错误路径,并产生能清晰指出哪里出错的失败信息。

基于工具的指标

现有的 Ruby on Rails 工具让我们可以轻松获取多项指标:

RSpec:Ruby 最流行的测试框架。表达力强的语法,用于编写可读且结构良好的测试。RuboCop:一个静态 Ruby 代码分析器和格式化工具,帮助执行风格指南并保持代码一致性。SimpleCov:一个 Ruby 代码覆盖率工具,衡量测试套件执行了多少代码。

从这些工具中,我们可以提取出几个有用的定量指标:通过的测试比例、每个文件的 RuboCop 违规数量、单元测试套件覆盖的代码比例,以及执行速度。这些指标为衡量测试套件的整体表现提供了坚实的基线。

但仅凭这些数字无法判断一个测试文件是否真的优秀。我们该如何比较两个同样零 RuboCop 违规、100% 覆盖率且所有测试都通过的文件呢?定量指标可能看起来完全相同,但测试质量上的定性差异仍然可能存在。

LLM 作为评判者

LLM 作为评判者的理念是向 LLM 提问:“所有错误条件都测试了吗?”或“这是一个好的测试吗?”,并根据明确的标准获得 0 到 1 的评分。

示例提示词:

## 好的测试
精确值:`eq(100)`,而不是 `be_present` 或 `be_a(Float)`。状态变化:`change { User.count }.by(1)`。错误路径:`raise_error(ArgumentError, "message")`。边界情况:测试阈值处的精确值(例如 `page(0)`)。公共接口:不使用 `.send(:private_method)`。
## 坏的测试
模糊断言:`be_truthy`、`be_an(Array)`。“不崩溃”测试:`not_to raise_error`。测试私有方法。过度模拟(测试的是模拟对象,而不是行为)。没有对返回值进行断言。
## 评分规则
- 1.0:对返回值/状态有精确断言 + 覆盖所有关键路径(正常、错误、边界)。- 0.8:对所有被测试路径都有精确断言,但完全遗漏了 1-2 个边缘情况。- 0.6:覆盖了所有关键路径,但有 1-2 个测试使用了模糊断言(例如用 `be_truthy` 而不是 `eq(expected)`)或有轻微的过度模拟。- 0.4:部分测试有精确断言;其他测试则模糊、过度模拟,或跳过返回值检查。- 0.2:大多数测试缺乏有意义的断言:模糊匹配器、被桩替换的逻辑、没有验证实际输出。- 0.0:测试在结构上就是坏的:测试私有方法、没有真正的断言,或验证的是模拟对象而不是行为。

这里的“评分规则”部分尤为重要,因为它强制要求分数在多次运行中更加一致。没有它,代理将缺乏对评分严格或宽松程度的明确定义。

这样的分数不如工具的输出或彻底的开发者审查那样权威。但它很好地表明了当前版本代理的表现如何,尤其是在整个仓库中的每个文件上大规模运行时。

LLM 作为评判者的评估局限性:

任何可以转化为文本的内容都可以用 LLM 作为评判者来评分,这使其用途广泛。但分数输出不是确定性的。即使使用 temperature=0,由于内核操作顺序,也会存在差异。在实践中,同一评估的输出总是接近的,但这种缺乏确定性的情况在某些用例中可能是个问题。

对于我们的用例,由于我们不是对单个测试评分,而通常是一次对几十到几百个测试评分,这是一个可接受的局限性。聚合分数保持了一致。

缺失括号问题

LLM 作为评判者还有另一个核心问题:一个测试可能质量很高但没有运行,或者其背后的逻辑完全错误。

示例:

RSpec.describe User, "#full_name" do it "combines first and last name with a space" do user = User.new(first_name: "Ada", last_name: "Lovelace"
expect(user.full_name).to eq("Ada Lovelace") endend

这个测试看起来很棒。它测试了一个行为,有清晰的描述,读起来像文档,遵循了Arrange-Act-Assert模式,并且如果出错会给出有用的失败信息。

我们使用之前定义的评分提示,通过多个模型运行了这个测试。给出的平均分是0.75。开发者快速看一眼也可能会同意。

但这个测试有一个核心缺陷:它无法运行。 全是因为在User.new()上缺少了一个右括号)。这是一个关键缺陷,使得这个测试绝对不适合。

这个例子很容易修复。但还有很多其他可能导致语法错误的情况:

版本错误:使用了不兼容的Ruby或Rails版本的方法或语法不存在的方法:某些方法可能已被移除、重命名或从未存在过。工厂/夹具错误:引用了缺失的工厂、错误的属性或缺失的关联缺少依赖项:使用了测试环境中未包含的gem或模块

实验

我们进行了一项实验,以检查代理能否应对真实的代码库。目标仓库包含275个源文件。其中一半已有测试覆盖,另一半没有。

我们将代理指向每个文件,无论是否已有测试。对于未覆盖的文件,它成功地从零生成了规格。对于已有测试的文件,它重写并改进了它们。

我们用LLM作为评判者,对每个规格文件在修改前后进行评分。已测试文件的总体得分从0.49上升到0.74,覆盖率达到了100%。仍有改进空间,但结果验证了这种方法。

指标 结果
处理的文件 275
通过的测试 100%
平均行覆盖率(SimpleCov) 100%
自我修正后的RuboCop违规 0
LLM作为评判者的得分 0.74

按文件类型

并非所有文件类型都容易测试。模型得分最高:它们的业务逻辑是自包含的,测试模式可预测。控制器更难,HTTP请求处理引入了更多出错空间。

文件类型 LLM作为评判者
模型 0.81
控制器 0.67
序列化器 0.80

它能运行吗?

将SimpleCov与RSpec执行捆绑作为最后一步,强制代理运行它编写的每个测试,这是最具影响力的决定。只有三分之一的测试在首次执行时通过。代理在几次迭代内自我修正了所有失败。

如果没有这一步,LLM作为评判者会将这些无法运行的测试评为高质量:这就是大规模下的缺失括号问题。

Vibe是开源的。这里描述的代理、工具和技能都运行在它之上。现在就试试吧。