关于 rspec-best-practices
rspec-best-practices 是用于编写、审查和整理 RSpec 测试的技能,涵盖测试结构、工厂、模拟、确定性及 TDD 流程。
主要功能
- 01测试类型与结构规范
- 02以行为为中心的断言
- 03精简工厂与模拟实践
- 04确定性测试建议
- 05分步 TDD 流程
- 06输出规范与复用模板
适用场景
- 01为 Rails 功能编写 RSpec 测试
- 02审查并维护测试规范
- 03减少脆弱及时间相关测试
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ midudev/ autoskills --skill rspec-best-practices安装后在 Claude Code 中输入/rspec-best-practices确认已加载。
附带文件assets/spec_templates.mdEXAMPLES.md
详细介绍
RSpec 最佳实践
在编写、审查或清理 RSpec 测试时使用此技能。
核心原则: 优先确保行为可信,而不是耦合实现。好的规格应易于阅读、结果确定且维护成本低。
快速参考
| 方面 | 规则 |
|---|---|
| 规格类型 | Model:领域逻辑;Request:HTTP 端点(优先于 controller);Job:后台处理;Service/PORO:不使用 Rails 辅助方法;System:仅用于关键端到端测试(速度较慢) |
| 断言 | 测试行为,而非实现 |
| 工厂 | 尽量精简——只包含所需属性;可选状态使用 traits;优先使用 build/build_stubbed 而非 create |
| 模拟 | 对外部边界进行 stub,而非内部代码 |
| 隔离 | 每个示例彼此独立;不共享可变状态 |
| 命名 | 用 describe 描述类/方法,用 context 描述场景 |
| 服务规格 | 必需: 使用 describe '.call' 和 subject(:result) 表示主要调用 |
let 与 let! | 默认使用 let;仅当对象必须在示例运行前存在时才使用 let! |
| 外部服务模拟 | 使用 allow(ServiceClass).to receive(:method)——不要使用 instance_double;instance_double 仅用于注入的协作者 |
| 示例名称 | 使用现在时:it 'returns the user',绝不要写 it 'should ...';绝不包含单词 "and"——拆分为单独的示例 |
aggregate_failures | 在一个示例中断言多个相关项目时使用 |
TDD 工作流
使用 RSpec 推进新行为时,按以下顺序进行:
- 编写失败的规格——选择能够验证预期行为的最小规格类型(model > service > request > system)。
- 运行并确认失败消息——错误应与缺少代码有关,而不是设置问题。
- 实现使规格通过所需的最少代码。
- 重构——在保持测试套件通过的同时,清理重复代码并改进命名。
- 验证——提交前先运行相关的完整规格文件,再运行整个测试套件。
为 Rails 更改选择最佳的首个失败规格
| 更改类型 | 从以下规格开始 |
|---|---|
| 纯领域逻辑 | Model 或 PORO 服务规格 |
| HTTP 端点行为 | Request 规格 |
| 后台处理 | Job 规格 |
| 跨层用户旅程 | System 规格(谨慎使用) |
工厂设计
工厂应尽量精简。绝不要依赖工厂默认值实现业务逻辑——应显式设置或使用 traits。当 build/build_stubbed 足以满足需求时,避免使用 create。
服务规格(锚点模式)
RSpec.describe Invoices::MarkOverdue do
describe '.call' do
subject(:result) { described_class.call(invoice: invoice) }
context 'when the invoice is overdue and unpaid' do
let(:invoice) { create(:invoice, due_date: 2.days.ago, paid_at: nil) }
it 'marks the invoice overdue' do
expect { result }.to change { invoice.reload.overdue? }.from(false).to(true)
end
end
context 'when the invoice is already paid' do
let(:invoice) { create(:invoice, due_date: 2.days.ago, paid_at: 1.day.ago) }
it 'does not change the invoice' do
expect { result }.not_to change { invoice.reload.updated_at }
end
end
end
end
→ 完整示例:EXAMPLES.md | 可复制粘贴的模板:assets/spec_templates.md
共享示例
仅当同一行为契约适用于多个 subject,且无需为每个示例覆盖 let 时才使用。若每个 context 都需要不同的设置,则应避免使用——这说明抽象方式不合适。→ 示例见 EXAMPLES.md
每个示例只测一种行为——示例名称中绝不出现 "and"
it / specify 描述中的单词 "and" 表示该示例断言了两种行为。应将其拆分。每个示例只测一种行为。适用于所有规格类型——model、request、service、job、mailer、system。
# BAD — two assertions; if the first fails, the second never runs
it 'returns 201 and creates the record' do; end
it 'saves the order and sends the confirmation email' do; end
it 'updates the user and logs the change' do; end
# GOOD — one observable outcome per example
it 'returns 201' do; end
it 'creates the record' do; end
it 'saves the order' do; end
it 'sends the confirmation email' do; end
在完成任何规格前进行自查: 检查每个 it '...' / it "..." / specify '...' 字符串,查找单词 and(不区分大小写,按单词边界匹配)。每一处都必须拆分——即使是 'returns nil and does not raise' 这类“为了方便”而写的示例,也没有例外。
输出风格
当要求编写或审查 RSpec 规范时,输出必须满足以下每条规则。每条规则单独评分——违反一条就会导致整个检查不通过。
- 规范文件路径与源文件路径对应:
app/foo/bar.rb→spec/foo/bar_spec.rb。 - 每个规范文件的第一行必须是
# frozen_string_literal: true。 RSpec.describe使用完整常量路径(RSpec.describe Module::Class do),而非字符串。- 对每个被测试的方法使用
describe '#method'/describe '.class_method'。 - 对场景变化使用
context 'when ...'/context 'with ...'——绝不使用context来归类方法。 - 测试数据使用
let;只有在被测操作执行前必须创建对象时,才使用let!。 - 除非项目已经依赖
test-prof,否则不要使用let_it_be(请先检查Gemfile.lock)。 - 任何示例描述中都不能出现 "and"——每次出现都要拆分(见上文)。这是最容易遗漏的规则;返回规范前请明确检查一遍。
- 对调用
.call的服务 / PORO 规范,使用subject(:result) { ... }。 - 对任何依赖时间的断言,使用
travel_to/freeze_time——绝不要将Time.now设为过去的时间,也不要直接对Time.current进行桩替换。 - 在类方法层级模拟外部边界(
allow(SomeClient).to receive(:method));绝不模拟 ActiveRecord 查找器。
易波动测试与确定性断言
| 原因 | 修复方法 |
|---|---|
| 依赖时间的逻辑 | 使用 freeze_time / travel_to;绝不要用过去的日期图省事 |
| 状态泄漏 | 每个示例自行设置状态;避免使用 before(:all) |
| 异步任务 | queue_adapter = :test + have_enqueued_job;绝不以命令式方式断言副作用 |
| 外部 HTTP | 使用 WebMock / VCR;绝不允许 CI 中发起真实网络请求 |
| 数据库状态相互污染 | 使用事务性 fixture 或 DatabaseCleaner;绝不要在不同上下文间共享 let! |
| 竞态条件 | 使用明确的 Capybara 等待;避免使用 sleep |
| 断言不精确 | 优先使用 change.from().to(),而非最终状态;优先使用精确值,而非 be_truthy/be_falsey;绝不断言 updated_at |