测试最佳实践
测试设计
在GitLab中,测试是一等公民,而非事后才考虑的内容。当我们设计功能时,我们重视测试的设计,如同重视功能本身的设计一样。
当实现一个功能时,我们会思考如何以正确的方式开发合适的功能。这有助于我们将范围缩小到一个可管理的水平。而当为一个功能编写测试时,我们必须思考如何编写合适的测试,但随后要覆盖测试可能失败的每一种重要方式。这可能会迅速扩大我们的范围至难以管理的程度。
测试启发式方法可以帮助解决这个问题。它们能简洁地应对许多常见bug在我们的代码中的表现形式。在设计测试时,花时间回顾已知的测试启发式方法,以指导我们的测试设计。我们可以在手册的测试工程部分找到一些有用的启发式方法文档。
RSpec
要运行RSpec测试:
# 运行某个文件的测试
bin/rspec spec/models/project_spec.rb
# 运行该文件第10行的示例测试
bin/rspec spec/models/project_spec.rb:10
# 运行与示例名称包含该字符串相匹配的测试
bin/rspec spec/models/project_spec.rb -e associations
# 运行所有测试,对GitLab代码库来说这可能需要数小时!
bin/rspec使用Guard持续监控变更并仅运行匹配的测试:
bundle exec guard当同时使用spring和guard时,使用SPRING=1 bundle exec guard来利用spring。
通用准则
- 使用单个顶层
RSpec.describe ClassName块。 - 使用
.method描述类方法,使用#method描述实例方法。 - 使用
context测试分支逻辑(RSpec/AvoidConditionalStatementsRuboCop Cop - MR)。 - 尝试使测试的顺序与类中的顺序一致。
- 尝试遵循四阶段测试模式,使用换行符分隔各个阶段。
- 使用
Gitlab.config.gitlab.host而非硬编码'localhost'。 - 对于测试中的字面量URL,使用
example.com、gitlab.example.com。这将确保我们不使用任何真实URL。 - 不要断言序列生成属性的绝对值(参见陷阱)。
- 避免使用
expect_any_instance_of或allow_any_instance_of(参见陷阱)。 - 不要向hook传递
:each参数,因为这是默认行为。 - 在
before和afterhook中,优先选择作用域为:context而非:all。 - 当使用
evaluate_script("$('.js-foo').testSomething()")(或execute_script)作用于给定元素时,先使用Capybara匹配器(例如find('.js-foo'))以确保该元素确实存在。 - 使用
focus: true隔离你想要运行的测试部分。 - 当测试中有多个期望时,使用
:aggregate_failures。 - 对于空的测试描述块,如果测试本身不言自明,则使用
specify而非it do。 - 当你需要一个实际不存在的ID/IID/访问级别时,使用
non_existing_record_id/non_existing_record_iid/non_existing_record_access_level。使用123、1234甚至999是脆弱的,因为这些ID可能在CI运行期间的实际数据库中存在。
应用代码的预加载
默认情况下,应用代码:
- 在
test环境中不会预加载。 - 在CI/CD中(当
ENV['CI'].present?时)会预加载,以便暴露潜在的加载问题。
如果你需要在执行测试时启用预加载,请使用GITLAB_TEST_EAGER_LOAD环境变量:
GITLAB_TEST_EAGER_LOAD=1 bin/rspec spec/models/project_spec.rb如果你的测试依赖于正在加载的所有应用代码,请添加:eager_load标签。这确保了应用代码在测试执行前被预加载。
Ruby警告
我们在运行规格测试时默认启用了弃用警告。让开发者更明显地看到这些警告有助于升级到更新的Ruby版本。
你可以通过设置环境变量SILENCE_DEPRECATIONS来静音弃用警告,例如:
静默所有弃用警告
SILENCE_DEPRECATIONS=1 bin/rspec spec/models/project_spec.rb
### 测试顺序
所有新的 spec 文件以 [随机顺序](https://gitlab.com/gitlab-org/gitlab/-/issues/337399) 运行,以暴露依赖于测试顺序的不稳定测试。
当启用随机顺序时:
- 字符串 `# order random` 会添加到示例组描述下方。
- 所使用的种子会在测试套件摘要下方的 spec 输出中显示。例如,`Randomized with seed 27443`。
若需查看仍按固定顺序运行的 spec 文件列表,请参阅 [`rspec_order_todo.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/spec/support/rspec_order_todo.yml)。
若要让 spec 文件以随机顺序运行,可通过以下方式检查其顺序依赖关系:
```shell
scripts/rspec_check_order_dependence spec/models/project_spec.rb如果测试通过了检查,该脚本会将它们从 rspec_order_todo.yml 中自动移除。
如果测试未通过检查,则必须先修复这些问题,才能以随机顺序运行。
测试不稳定性
请查阅 “Unhealthy tests 页面” 以获取更多关于避免不稳定测试流程的信息。
测试缓慢性
GitLab 拥有庞大的测试套件,若无 并行化,可能需要数小时才能完成运行。我们不仅要编写准确有效的测试,还需努力让测试同时保持快速。
测试性能对维护质量与速度至关重要,直接影响 CI 构建时长及固定成本。我们需要全面、正确且快速的测试。在此你可以找到一些可用的工具和技术信息,帮助你实现这一目标。
请查阅 “Unhealthy tests 页面” 以获取更多关于避免缓慢测试流程的信息。
不要请求你不需要的功能
我们通过标注示例或父上下文来轻松添加功能。示例如下:
- 功能测试中的
:js,会运行完整的支持 JavaScript 的无头浏览器。 :clean_gitlab_redis_cache为示例提供干净的 Redis 缓存。:request_store为示例提供请求存储。
我们应减少测试依赖,避免不必要的功能也能减少所需设置量。
:js 尤其需要避免。仅当功能测试要求浏览器中的 JavaScript 响应性时才应使用(例如点击 Vue.js 组件)。使用无头浏览器比解析应用返回的 HTML 响应慢得多。
分析:查看你的测试花费时间的地方
rspec-stackprof 可用于生成火焰图,展示测试耗时所在。
该 gem 会生成 JSON 报告,我们可以将其上传至 https://www.speedscope.app 进行交互式可视化。
安装
stackprof gem 已随 GitLab 预安装,我们还提供了生成 JSON 报告的脚本 (bin/rspec-stackprof)。
# 可选:安装 `speedscope` 包以便轻松将 JSON 报告上传至 https://www.speedscope.app
npm install -g speedscope生成 JSON 报告
bin/rspec-stackprof --speedscope=true <your_slow_spec>
# 脚本结束时将显示报告名称。
# 将 JSON 报告上传至 speedscope.app
speedscope tmp/<your-json-report>.json如何解读火焰图
以下是解读和分析火焰图的一些有用提示:
- 火焰图有多种视图可选](https://github.com/jlfwong/speedscope#views)。当存在大量函数调用时(例如功能测试),
Left Heavy视图尤其有用。 - 你可以缩放!参见 导航文档
- 若你在优化缓慢的功能测试,可在搜索框中查找
Capybara::DSL#以查看执行的 Capybara 操作及其耗时!
参见 #414929 或 #375004 了解一些分析示例。
优化工厂使用
测试变慢的一个常见原因是对象的过度创建,从而导致计算和数据库时间的增加。工厂对开发至关重要,但它们让向数据库插入数据变得如此简单,以至于我们可能可以进行优化。
在这里需要记住的两个基本技巧是:
- 减少:避免创建对象,并避免持久化它们。
- 复用:共享对象,尤其是我们不检查的嵌套对象,通常可以共享。
为了避免创建对象,值得记住的是:
instance_double和spy比FactoryBot.build(...)更快。FactoryBot.build(...)和.build_stubbed比.create更快。- 当你可以使用
build、build_stubbed、attributes_for、spy或instance_double时,不要create对象。数据库持久化很慢!
使用 Factory Doctor 来查找给定测试中不需要数据库持久化的情况。
# 运行指定路径的测试
FDOC=1 bin/rspec spec/[path]/[to]/[spec].rb一个常见的改动是用 build 或 build_stubbed 代替 create:
# 旧
let(:project) { create(:project) }
# 新
let(:project) { build(:project) }Factory Profiler 可以帮助识别通过工厂进行的重复数据库持久化操作。
# 运行指定路径的测试
FPROF=1 bin/rspec spec/[path]/[to]/[spec].rb
# 用火焰图可视化
FPROF=flamegraph bin/rspec spec/[path]/[to]/[spec].rb大量创建工厂的一个常见原因是 factory cascades,这是当工厂创建和重新创建关联时产生的结果。它们可以通过 total time 和 top-level time 数值之间的明显差异来识别:
total top-level total time time per call top-level time name
208 0 9.5812s 0.0461s 0.0000s namespace
208 76 37.4214s 0.1799s 13.8749s project上表显示我们从未显式创建任何 namespace 对象(top-level == 0)——它们都是为我们隐式创建的。但我们最终得到了208个(每个项目对应一个),这花费了9.5秒。
为了在隐式父关联中对命名工厂的所有调用复用一个对象,可以使用 FactoryDefault:
RSpec.describe API::Search, factory_default: :keep do
let_it_be(:namespace) { create_default(:namespace) }这样,我们创建的每个项目都使用这个 namespace,而不必将其作为 namespace: namespace 传递。为了让它与 let_it_be 协同工作,必须明确指定 factory_default: :keep。这会将默认工厂保存在整个测试套件的每个示例中,而不是为每个示例重新创建它。
为了防止测试示例之间意外依赖,使用 create_default 创建的对象会被 frozen。
也许我们不需要创建208个不同的项目——我们可以创建一个并复用它。此外,我们可以看到我们创建的项目中只有大约1/3是我们要求的(76/208)。为项目设置默认值也有好处:
let_it_be(:project) { create_default(:project) }在这种情况下,total time 和 top-level time 的数值更接近匹配:
total top-level total time time per call top-level time name
31 30 4.6378s 0.1496s 4.5366s project
8 8 0.0477s 0.0477s 0.0477s namespace让我们聊聊 let
在测试中有多种方式来创建对象并将其存储在变量中。按效率从低到高排序如下:
let!会在每个示例运行前创建对象,并且每个示例都会创建一个新对象。只有在需要为每个示例创建干净的、未被显式引用的对象时,才应使用此选项。let是懒加载创建对象,直到对象被调用时才会创建。由于它为每个示例都创建新对象,因此通常效率较低。对于简单值来说,let是合适的;但在处理数据库模型(如工厂)时,更高效的变体效果更好。let_it_be_with_refind与let_it_be_with_reload类似,但前者会调用ActiveRecord::Base#find,而非ActiveRecord::Base#reload。通常reload比refind更快。let_it_be_with_reload会为同一上下文中的所有示例仅创建一次对象,但在每个示例结束后,数据库变更会被回滚,并调用object.reload将对象恢复至初始状态。这意味着你可以在示例之前或期间对对象进行修改。然而,存在某些情况下会发生状态泄漏到其他模型。在这些情况下,let可能是更简单的选择,尤其当仅有少量示例时。let_it_be会为同一上下文中的所有示例仅创建一次对象。对于无需在示例之间改变的对象而言,它是let和let!的绝佳替代方案。使用let_it_be能显著提升创建数据库模型的测试速度。详见 https://github.com/test-prof/test-prof/blob/master/docs/recipes/let_it_be.md#let-it-be 获取更多细节与示例。
小贴士:编写测试时,最好将 let_it_be 内部的对象视为不可变的,因为修改 let_it_be 声明内的对象存在一些重要的注意事项(1、2)。若要让 let_it_be 对象变为不可变,可考虑使用 freeze: true:
# 改动前
let_it_be(:namespace) { create_default(:namespace) }
# 改动后
let_it_be(:namespace, freeze: true) { create_default(:namespace) }详见 https://github.com/test-prof/test-prof/blob/master/docs/recipes/let_it_be.md#state-leakage-detection 了解 let_it_be 冻结功能的更多信息。
let_it_be 是最优化的选项,因为它仅实例化一次对象并在示例间共享该实例。如果你发现自己需要用 let 而非 let_it_be,可以尝试 let_it_be_with_reload。
# 旧写法
let(:project) { create(:project) }
# 新写法
let_it_be(:project) { create(:project) }
# 若需要在测试中对对象预期变更
let_it_be_with_reload(:project) { create(:project) }以下是一个 let_it_be 无法使用,但 let_it_be_with_reload 比 let 更高效的示例:
let_it_be(:user) { create(:user) }
let_it_be_with_reload(:project) { create(:project) } # 若使用 `let_it_be` 则测试会失败
context 'with a developer' do
before do
project.add_developer(user)
end
it 'project has an owner and a developer' do
expect(project.members.map(&:access_level)).to match_array([Gitlab::Access::OWNER, Gitlab::Access::DEVELOPER])
end
end
context 'with a maintainer' do
before do
project.add_maintainer(user)
end
it 'project has an owner and a maintainer' do
expect(project.members.map(&:access_level)).to match_array([Gitlab::Access::OWNER, Gitlab::Access::MAINTAINER])
end
end在工厂中存根方法
你应该避免在工厂中使用 allow(object).to receive(:method),因为这会使工厂无法与 let_it_be 配合使用,如常见测试设置 中所述。
相反,你可以使用 stub_method 来存根方法:
before(:create) do |user, evaluator|
# 存根一个方法。
stub_method(user, :some_method) { 'stubbed!' }
# 或者带有参数,包括命名参数
stub_method(user, :some_method) { |var1| "Returning #{var1}!" }
stub_method(user, :some_method) { |var1: 'default'| "Returning #{var1}!" }
end
# 恢复原始方法。
# 当使用 `let_it_be` 创建存根对象,并且希望在测试之间重置该方法时,这可能很有用。
after(:create) do |user, evaluator|
restore_original_method(user, :some_method)
# 或
restore_original_methods(user)
end当与 let_it_be_with_refind 结合使用时,stub_method 无法工作。这是因为 stub_method 会存根实例上的方法,而 let_it_be_with_refind 会在每次运行时为对象创建新实例。
stub_method 不支持方法存在性和方法参数数量检查。
stub_method 应该仅在工厂中使用。强烈不建议在其他地方使用。如果可用,请考虑使用 RSpec mocks。
存根成员访问级别
若要对像 Project 或 Group 这样的工厂存根 成员访问级别,请使用 stub_member_access_level:
let(:project) { build_stubbed(:project) }
let(:maintainer) { build_stubbed(:user) }
let(:policy) { ProjectPolicy.new(maintainer, project) }
it '允许 admin_project 能力' do
stub_member_access_level(project, maintainer: maintainer)
expect(policy).to be_allowed(:admin_project)
end如果测试代码依赖于持久化 project_authorizations 或 Member 记录,请不要使用此存根助手。改用 Project#add_member 或 Group#add_member。
其他分析指标
我们可以使用 rspec_profiling gem 来诊断,例如在运行测试时我们执行的 SQL 查询数量。
这可能是由于某些由测试触发的应用侧 SQL 查询造成的,该测试可能模拟了未受测的部分(例如,!123810)。
排查缓慢的功能测试
缓慢的功能测试通常可以像其他测试一样进行优化。然而,有一些特定的技术可以使排查过程更有成效。
查看 UI 中功能测试的行为
# 之前
bin/rspec ./spec/features/admin/admin_settings_spec.rb:992
# 之后
WEBDRIVER_HEADLESS=0 bin/rspec ./spec/features/admin/admin_settings_spec.rb:992有关更多信息,请参阅 在可见浏览器中运行 :js 规范。
使用分析工具时搜索 Capybara::DSL#
在使用 stackprof 火焰图 时,在搜索框中查找 Capybara::DSL# 以查看执行的操作以及耗时!
识别慢测试
运行带有性能分析的规范是开始优化规范的不错方式。可以通过以下命令实现:
bundle exec rspec --profile -- path/to/spec_file.rb该命令包含类似以下的信息:
最慢的10个示例(10.69秒,占总时间的7.7%):
Issue 表现得像一个可编辑的被提及对象,当被提及对象的文本被编辑时创建新的交叉引用笔记
1.62 秒 ./spec/support/shared_examples/models/mentionable_shared_examples.rb:164
Issue 相对定位表现得像一个支持相对定位的类 .move_nulls_to_end 设法将空值移到末尾,若无法创建足够空间则堆叠
1.39 秒 ./spec/support/shared_examples/models/relative_positioning_shared_examples.rb:88
Issue 相对定位表现得像一个支持相对定位的类 .move_nulls_to_start 设法将空值移到末尾,若无法创建足够空间则堆叠
1.27 秒 ./spec/support/shared_examples/models/relative_positioning_shared_examples.rb:180
Issue 表现得像一个可编辑的被提及对象 表现得像一个被提及对象 从其引用属性中提取引用
0.99253 秒 ./spec/support/shared_examples/models/mentionable_shared_examples.rb:69
Issue 表现得像一个可编辑的被提及对象 表现得像一个被提及对象 创建交叉引用笔记
0.94987 秒 ./spec/support/shared_examples/models/mentionable_shared_examples.rb:101
Issue 表现得像一个可编辑的被提及对象 表现得像一个被提及对象 当存在缓存 Markdown 字段时 在适当时候传入缓存的 Markdown 字段
0.94148 秒 ./spec/support/shared_examples/models/mentionable_shared_examples.rb:86
Issue 表现得像一个可编辑的被提及对象 当存在缓存 Markdown 字段时 当 Markdown 缓存过时时 持久化刷新后的缓存 以便不必每次都刷新
0.92833 秒 ./spec/support/shared_examples/models/mentionable_shared_examples.rb:153
Issue 表现得像一个可编辑的被提及对象 当存在缓存 Markdown 字段时 若有必要则刷新 Markdown 缓存
0.88153 秒 ./spec/support/shared_examples/models/mentionable_shared_examples.rb:130
Issue 表现得像一个可编辑的被提及对象 表现得像一个被提及对象 生成描述性反向引用
0.86914 秒 ./spec/support/shared_examples/models/mentionable_shared_examples.rb:65
Issue#related_issues 仅返回给定用户的授权相关 Issue
0.84242 秒 ./spec/models/issue_spec.rb:335
完成于 2 分 19 秒(文件加载耗时 1 分 4.42 秒)
277 个示例,0 失败,1 待定从这个结果中,我们可以看到规范中最昂贵的示例,为我们提供了一个起点。这里最昂贵的示例位于共享示例中;任何减少通常会有更大的影响,因为它们在多个地方被调用。
避免重复昂贵操作
虽然隔离的示例非常清晰,有助于发挥规范作为规格说明的作用,但下面的示例展示了如何组合昂贵操作:
subject { described_class.new(arg_0, arg_1) }
it '创建一个事件' do
expect { subject.execute }.to change(Event, :count).by(1)
end
it '设置 frobulance' do
expect { subject.execute }.to change { arg_0.reset.frobulance }.to('wibble')
end
it '调度后台任务' do
expect(BackgroundJob).to receive(:perform_async)
subject.execute
end如果 subject.execute 的调用成本很高,那么我们重复相同的操作只是为了进行不同的断言。我们可以通过合并示例来减少这种重复:
it '执行预期的副作用' do
expect(BackgroundJob).to receive(:perform_async)
expect { subject.execute }
.to change(Event, :count).by(1)
.and change { arg_0.frobulance }.to('wibble')
end这样做时要小心,因为这会为了性能提升而牺牲清晰度和测试独立性。
合并测试时,考虑使用 :aggregate_failures,以便获取完整的结果,而不仅仅是第一个失败。
如果你卡住了
我们有一个 backend_testing_performance 领域专家 来列出可以帮助重构慢速后端规范的人员。
要找到可以帮助的人,请在 工程项目页面 上搜索 backend testing performance,或直接查看 www-gitlab-org 项目。
功能类别元数据
依赖EE许可证的测试
你可以在上下文/规范块中使用 if: Gitlab.ee? 或 unless: Gitlab.ee? 来执行测试,这取决于是否运行了 FOSS_ONLY=1。
依赖SaaS的测试
你可以在上下文/规范块中使用 :saas RSpec元数据标签助手来测试仅在GitLab.com上运行的代码。这个助手会将 Gitlab.config.gitlab['url'] 设置为 Gitlab::Saas.com_url。
覆盖率
使用 simplecov 生成代码测试覆盖率报告。
这些报告会在CI中自动生成,但在本地运行测试时不会生成。若要在本地运行规格文件时生成部分报告,
请设置 SIMPLECOV 环境变量:
SIMPLECOV=1 bundle exec rspec spec/models/repository_spec.rb覆盖率报告会生成到应用根目录下的 coverage 文件夹中,你可以用浏览器打开这些报告,例如:
firefox coverage/index.html使用覆盖率报告以确保你的测试覆盖了100%的代码。
系统/功能测试
在编写新的系统测试之前, 考虑这篇关于其使用的指南
- 功能规格应命名为
ROLE_ACTION_spec.rb,例如user_changes_password_spec.rb。 - 使用描述成功和失败路径的场景标题。
- 避免添加无信息的场景标题,例如“successfully”。
- 避免重复功能标题的场景标题。
- 在数据库中仅创建必要的记录
- 测试一个快乐路径和一个不那么快乐的路径即可
- 其他可能的路径应由单元或集成测试进行测试
- 测试页面上显示的内容,而非ActiveRecord模型的内部。
例如,如果你想验证一条记录已被创建,添加期望其属性显示在页面上的断言,而不是
Model.count增加了1。 - 可以查找DOM元素,但不要滥用,因为这会使测试更脆弱
UI测试
测试UI时,编写模拟用户所见及与UI交互方式的测试。 这意味着优先使用Capybara的语义方法,并避免通过ID、类或属性进行查询。
以这种方式测试的好处有:
- 它确保所有交互元素都有可访问名称。
- 它更具可读性,因为它使用了更自然的语言。
- 它更稳定,因为它避免了通过ID、类和属性进行查询,而这些对用户是不可见的。
我们强烈建议你根据元素的文本标签进行查询,而不是通过ID、类名或 data-testid。
如果需要,你可以使用 within 将交互范围限定在页面的特定区域。
由于你可能要限定到一个像 div 这样的元素,它通常没有标签,
在这种情况下,你可以使用 data-testid 选择器。
你可以在功能测试中使用 be_axe_clean 匹配器来运行axe自动化可访问性测试。
外部化内容
对于RSpec测试,针对外部化内容的期望应调用相同的外部化方法以匹配翻译。例如,你应该在Ruby中使用 _ 方法。
详见 GitLab国际化 - 测试文件(RSpec) 了解详情。
操作
尽可能使用更具体的操作,如下所示。
# good
click_button _('提交审核')
click_link _('UI测试文档')
fill_in _('搜索项目'), with: 'gitlab' # 用文本填充输入框
select _('更新日期'), from: '排序方式' # 从选择输入中选择选项
check _('复选框标签')
uncheck _('复选框标签')
choose _('单选按钮标签')
attach_file(_('附加文件'), '/path/to/file.png')
# bad - 交互元素必须有可访问名称,因此
# 我们应该能够使用上述某个具体操作
find('.group-name', text: group.name).click
find('.js-show-diff-settings').click
find('[data-testid="submit-review"]').click
find('input[type="checkbox"]').click
find('.search').native.send_keys('gitlab')查找器
尽可能使用更具体的查找器,如下所示。
# good
find_button _('提交审核')
find_button _('提交审核'), disabled: true
find_link _('UI测试文档')
find_link _('UI测试文档'), href: docs_url
find_field _('搜索项目')
find_field _('搜索项目'), with: 'gitlab' # 找到带有文本的输入字段
find_field _('搜索项目'), disabled: true
find_field _('复选框标签'), checked: true
find_field _('复选框标签'), unchecked: true当找到的元素不是按钮、链接或字段时可以使用
find_by_testid(’element')
匹配器
尽可能使用更具体的匹配器,例如以下这些。
# 良好实践
expect(page).to have_button _('提交审核')
expect(page).to have_button _('提交审核'), disabled: true
expect(page).to have_button _('通知'), class: 'is-checked' # 断言“通知”GlToggle已被选中
expect(page).to have_link _('UI 测试文档')
expect(page).to have_link _('UI 测试文档'), href: docs_url # 断言链接有 href 属性
expect(page).to have_field _('搜索项目')
expect(page).to have_field _('搜索项目'), disabled: true
expect(page).to have_field _('搜索项目'), with: 'gitlab' # 断言输入字段包含文本
expect(page).to have_checked_field _('复选框标签')
expect(page).to have_unchecked_field _('单选按钮标签')
expect(page).to have_select _('排序方式')
expect(page).to have_select _('排序方式'), selected: '更新日期' # 断言选项被选中
expect(page).to have_select _('排序方式'), options: ['更新日期', '创建日期', '截止日期'] # 断言精确的选项列表
expect(page).to have_select _('排序方式'), with_options: ['创建日期', '截止日期'] # 断言部分选项列表
expect(page).to have_text _('某段落文本。')
expect(page).to have_text _('某段落文本。'), exact: true # 断言精确匹配
expect(page).to have_current_path 'gitlab/gitlab-test/-/issues'
expect(page).to have_title _('未找到')
# 当上述更具体的匹配器不可用时可以使用
expect(page).to have_css 'h2', text: '问题标题'
expect(page).to have_css 'p', text: '问题描述', exact: true
expect(page).to have_css '[data-testid="weight"]', text: 2
expect(page).to have_css '.atwho-view ul', visible: true与模态框交互
使用 within_modal 助手与GitLab UI 模态框交互。
include Spec::Support::Helpers::ModalHelpers
within_modal do
expect(page).to have_link _('UI 测试文档')
fill_in _('搜索项目'), with: 'gitlab'
click_button '继续'
end此外,对于只需要接受确认的确认模态框,可以使用 accept_gl_confirm。
这在将window.confirm()迁移到confirmAction时很有帮助。
include Spec::Support::Helpers::ModalHelpers
accept_gl_confirm do
click_button '删除用户'
end你也可以向 accept_gl_confirm 传递预期的确认消息和按钮文本。
include Spec::Support::Helpers::ModalHelpers
accept_gl_confirm('你确定要删除此用户吗?', button_text: '删除') do
click_button '删除用户'
end其他有用方法
通过查找方法检索元素后,可以对其调用多种element 方法,例如 hover。
Capybara 测试还提供了多种会话方法,例如 accept_confirm。
一些其他有用的方法如下所示:
refresh # 刷新页面
send_keys([:shift, 'i']) # 按 Shift+I 键进入 Issues 仪表板页面
current_window.resize_to(1000, 1000) # 调整窗口大小
scroll_to(find_field('评论')) # 滚动到元素你还可以在 spec/support/helpers/ 目录中找到许多 GitLab 自定义助手。
实时调试
有时你可能需要通过观察浏览器行为来调试 Capybara 测试。
你可以使用 live_debug 方法暂停 Capybara 并在浏览器中查看网站。当前页面会自动在你默认的浏览器中打开。
你可能需要先登录(当前用户的凭证会在终端显示)。
要恢复测试运行,请按任意键。
例如:
$ bin/rspec spec/features/auto_deploy_spec.rb:34
Running via Spring preloader in process 8999
Run options: include {:locations=>{"./spec/features/auto_deploy_spec.rb"=>[34]}}
当前示例已暂停以进行实时调试
当前用户凭证是:user2 / 12345678
按任意键恢复示例执行!
回到示例!
.
Finished in 34.51 seconds (files took 0.76702 seconds to load)
1 example, 0 failureslive_debug 仅适用于启用 JavaScript 的规范。
在可见浏览器中运行:js测试
使用 WEBDRIVER_HEADLESS=0 运行测试,示例如下:
WEBDRIVER_HEADLESS=0 bin/rspec some_spec.rb测试会快速完成,但这能让你了解发生了什么。
使用 live_debug 配合 WEBDRIVER_HEADLESS=0 会暂停打开的浏览器,且不会重新打开页面。这可用于调试和检查元素。
你也可以添加 byebug 或 binding.pry 来暂停执行并逐步调试测试。
屏幕截图
我们使用 capybara-screenshot gem在失败时自动截取屏幕截图。在CI中你可以将这些文件作为作业工件下载。
此外,你可以在测试的任何时间点通过添加以下方法手动截取屏幕截图。确保不再需要时删除它们!更多信息请参见https://github.com/mattheworiordan/capybara-screenshot#manual-screenshots。
在:js测试中添加screenshot_and_save_page以截取Capybara“看到”的内容,并保存页面源码。
在:js测试中添加screenshot_and_open_image以截取Capybara“看到”的内容,并自动打开图片。
由此生成的HTML转储缺少CSS。这导致它们与实际应用看起来非常不同。有一个小技巧可以添加CSS,使调试更容易。
快速单元测试
有些类与Rails隔离良好。你应该能够在不增加由Rails环境和Bundler的:default组的gem加载带来的开销的情况下测试它们。在这种情况下,你可以在测试文件中使用require 'fast_spec_helper'代替require 'spec_helper',你的测试应该会运行得很快,因为:
- 跳过gem加载
- 跳过Rails应用启动
- 跳过GitLab Shell和Gitaly设置
- 跳过测试仓库设置
使用fast_spec_helper的测试加载大约需要一秒钟,而常规spec_helper则需要30多秒。
fast_spec_helper还支持自动加载位于lib/目录中的类。如果你的类或模块只使用来自lib/目录的代码,则无需显式加载任何依赖项。fast_spec_helper还会加载所有ActiveSupport扩展,包括在Rails环境中常用的核心扩展。
请注意,在某些情况下,当代码使用gem或依赖项不在lib/中时,你可能仍需使用require_dependency加载某些依赖项。
例如,如果你想测试调用Gitlab::UntrustedRegexp类的代码(该类底层使用了re2库),你应该:
- 将
require_dependency 're2'添加到需要re2gem的库文件中,以明确此要求。这种方法更可取。 - 将其添加到测试本身。
或者,如果它是你的领域中许多不同的fast_spec_helper测试所需的依赖项,而你不想多次手动添加依赖项,你可以将其直接添加到fast_spec_helper中。为此,你可以创建一个spec/support/fast_spec/YOUR_DOMAIN/fast_spec_helper_support.rb文件,并从fast_spec_helper中引用它。你可以参考现有的示例。
对于RuboCop相关的测试,使用rubocop_spec_helper。
要验证代码及其测试是否与Rails隔离良好,请通过bin/rspec单独运行测试。不要使用bin/spring rspec,因为它会自动加载spec_helper。
维护fast_spec_helper测试
有一个实用脚本scripts/run-fast-specs.sh,可用于以多种方式运行所有使用fast_spec_helper的测试。该脚本有助于识别存在问题的fast_spec_helper测试,例如无法成功独立运行的测试。有关更多详细信息,请参阅该脚本。
subject 和 let 变量
GitLab 的 RSpec 测试套件大量使用了 let(及其严格的非惰性版本 let!)变量来减少重复。然而,这有时会牺牲清晰度,因此我们需要制定一些未来的使用准则:
let!变量优于实例变量。let变量优于let!变量。局部变量优于let变量。- 使用
let在整个规范文件中减少重复。 - 不要用
let定义仅由单个测试使用的变量;将其定义为测试的it块内的局部变量。 - 不要在顶级
describe块内定义仅在更深嵌套的context或describe块中使用的let变量。将定义尽可能靠近使用的地方。 - 尝试避免用一个
let变量覆盖另一个的定义。 - 不要定义仅由另一个
let变量的定义使用的let变量。改用辅助方法。 let!变量应仅在需要严格评估且定义顺序的情况下使用,否则let就足够了。记住let是惰性的,直到被引用时才会被评估。- 避免在示例中引用
subject。使用命名主题subject(:name)或let变量代替,这样变量就有上下文名称。 - 如果
subject从未被示例引用,那么可以接受不命名地定义subject。
通用测试设置
let_it_be 和 before_all 与 DatabaseCleaner 的删除策略不兼容。这包括迁移规范、Rake 任务规范以及带有 :delete RSpec 元数据标签的规范。有关更多信息,请参阅 问题 420379。
在某些情况下,无需为每个示例重新创建相同的对象进行测试。例如,测试同一项目的问题需要一个项目和该项目的访客,因此整个文件只需一个项目和用户即可。
尽可能不要使用 before(:all) 或 before(:context) 来实现这一点。如果这样做,您需要手动清理数据,因为这些钩子在数据库事务之外运行。
相反,可以通过使用 let_it_be 变量和来自 test-prof gem 的 before_all 钩子来实现这一点。
let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }
before_all do
project.add_guest(user)
end这会导致在此上下文中只创建一个 Project、User 和 ProjectMember。
let_it_be 和 before_all 也可用于嵌套上下文。通过事务回滚自动处理上下文后的清理。
注意,如果您修改了 let_it_be 块内定义的对象,则必须执行以下操作之一:
- 根据需要重新加载对象。
- 使用
let_it_be_with_reload别名。 - 指定
reload选项以在每个示例中重新加载。
let_it_be_with_reload(:project) { create(:project) }
let_it_be(:project, reload: true) { create(:project) }您也可以使用 let_it_be_with_refind 别名,或指定 refind 选项以完全加载新对象。
let_it_be_with_refind(:project) { create(:project) }
let_it_be(:project, refind: true) { create(:project) }注意,let_it_be 不能与具有存根的工厂一起使用,例如 allow。原因是 let_it_be 发生在 before(:all) 块中,而 RSpec 不允许在 before(:all) 中使用存根。有关更多详情,请参阅此 问题。要解决此问题,请使用 let,或将工厂更改为不使用存根。
let_it_be 不得依赖前置块
当在规范中间使用 let_it_be 时,确保它不依赖于 before 块,因为 let_it_be 将在 before(:all) 期间先执行。
在这个例子中,create(:bar) 运行了一个依赖于存根的回调:
let_it_be(:node) { create(:geo_node, :secondary) }
before do
stub_current_geo_node(node)
end
context 'foo' do
let_it_be(:bar) { create(:bar) }
...
end存根在 create(:bar) 执行时尚未设置,因此测试不稳定。
在这个例子中,before 不能用 before_all 替换,因为您无法在每测试生命周期之外使用 RSpec-mocks 的双精度或部分双精度。
因此,解决方案是使用 let 或 let! 代替 let_it_be(:bar)。
时间敏感测试
ActiveSupport::Testing::TimeHelpers 可用于验证与时间相关的功能。任何涉及或验证时间敏感内容的测试都应使用这些辅助方法,以避免临时性测试失败。
示例:
it \'is overdue\' do
issue = build(:issue, due_date: Date.tomorrow)
travel_to(3.days.from_now) do
expect(issue).to be_overdue
end
endRSpec 辅助方法
您可以使用 :freeze_time 和 :time_travel_to RSpec 元数据标签辅助方法来减少包装整个规范所需的样板代码量,这些规范使用了 ActiveSupport::Testing::TimeHelpers 方法。
describe \'需要冻结时间的规范\', :freeze_time do
it \'冻结时间\' do
right_now = Time.now
expect(Time.now).to eq(right_now)
end
end
describe \'需要将时间冻结到特定日期和/或时间的规范\', time_travel_to: \'2020-02-02 10:30:45 -0700\' do
it \'将时间冻结到指定日期和时间\' do
expect(Time.now).to eq(Time.new(2020, 2, 2, 17, 30, 45, \'+00:00\'))
end
end在底层实现中,这些辅助方法使用了 around(:each) 钩子和 ActiveSupport::Testing::TimeHelpers 方法的块语法:
around(:each) do |example|
freeze_time { example.run }
end
around(:each) do |example|
travel_to(date_or_time) { example.run }
end请记住,在任何示例运行前创建的对象(例如通过 let_it_be 创建的对象)将处于规范作用域之外。如果所有内容的时间都需要被冻结,也可以使用 before :all 来封装设置。
before :all do
freeze_time
end
after :all do
unfreeze_time
end时间戳截断
Active Record 时间戳由 Rails 的 ActiveRecord::Timestamp 模块 使用 Time.now 设置。时间精度是 操作系统依赖的,正如文档所述,可能包含小数秒。\n\n当 Rails 模型保存到数据库时,它们拥有的任何时间戳都会使用 PostgreSQL 中称为 timestamp without time zone 的类型存储,该类型具有微秒分辨率(小数点后六位)。因此,如果向 PostgreSQL 发送 1577987974.6472975,它会截断小数部分的最后一位,而是保存为 1577987974.647297。\n\n这可能导致一个简单的测试如下所示:
let_it_be(:contact) { create(:contact) }
data = Gitlab::HookData::IssueBuilder.new(issue).build
expect(data).to include(\'customer_relations_contacts\' => [contact.hook_attrs])出现类似以下的错误而失败:
expected {
"assignee_id" => nil, "...1 +0000 } to include {"customer_relations_contacts" => [{:created_at => "2023-08-04T13:30:20Z", :first_name => "Sidney Jones3" }]}
Diff:
@@ -1,35 +1,69 @@
-"customer_relations_contacts" => [{:created_at=>"2023-08-04T13:30:20Z", :first_name=>"Sidney Jones3" }],
+"customer_relations_contacts" => [{"created_at"=>2023-08-04 13:30:20.245964000 +0000, "first_name"=>"Sidney Jones3" }],解决方法是确保我们 .reload 对象从数据库获取正确精度的 timestamp:
let_it_be(:contact) { create(:contact) }
data = Gitlab::HookData::IssueBuilder.new(issue).build
expect(data).to include(\'customer_relations_contacts\' => [contact.reload.hook_attrs])此解释摘自 Maciek Rząsa 的博客文章。\n\n您可以查看 合并请求,了解此问题的发生情况,以及讨论该问题的 后端配对会话。
测试中的特性标志
本节已移至 使用特性标志进行开发。
纯净的测试环境
单个 GitLab 测试所执行的代码可能会访问和修改许多数据项。如果在测试运行前没有仔细准备,运行后没有清理,测试可能会以影响后续测试行为的方式更改数据。这必须不惜一切代价避免!幸运的是,现有的测试框架已经处理了大部分情况。
当测试环境确实被污染时,常见的结果是不稳定的测试。污染通常表现为顺序依赖性:先运行 spec A 再运行 spec B 会可靠地失败,但先运行 spec B 再运行 spec A 则会可靠地成功。在这种情况下,你可以使用 rspec --bisect(或手动成对二分 spec 文件)来确定哪个 spec 有问题。修复这个问题需要对测试套件如何确保环境纯净有所了解。继续阅读以了解更多关于每个数据存储的信息!
SQL 数据库
这部分由 database_cleaner gem 为我们管理。每个 spec 都被包裹在一个事务中,该事务在测试完成后回滚。某些 spec 则会在完成后对所有表发出 DELETE FROM 查询。这使得创建的行可以从多个数据库连接中查看,这对于在浏览器中运行的 spec 或迁移 spec 等非常重要。
使用这些策略而不是众所周知的 TRUNCATE TABLES 方法的一个后果是,主键和其他序列不会在 spec 之间重置。因此,如果你在 spec A 中创建一个项目,然后在 spec B 中创建一个项目,第一个项目的 id=1,而第二个项目的 id=2。
这意味着 spec 应该永远不要依赖 ID 的值或其他序列生成的列。为了避免意外冲突,spec 也应该避免在这些类型的列中手动指定任何值。相反,让它们未指定,并在行创建后查找其值。
迁移 spec 中的 TestProf
由于上述原因,迁移 spec 不能在数据库事务内运行。我们的测试套件使用了 TestProf 来提高测试套件的运行时间,但 TestProf 使用数据库事务来执行这些优化。出于这个原因,我们不能在我们的迁移 spec 中使用 TestProf 方法。以下是不应使用且应替换为默认 RSpec 方法的那些方法:
let_it_be:改用let或let!。let_it_be_with_reload:改用let或let!。let_it_be_with_refind:改用let或let!。before_all:改用before或before(:all)。
Redis
GitLab 在 Redis 中存储两大类数据:缓存项和 Sidekiq 任务。查看所有由独立 Redis 实例支持的 Gitlab::Redis::Wrapper 后代列表。
在大多数 spec 中,Rails 缓存实际上是一个内存存储。它在 spec 之间被替换,因此对 Rails.cache.read 和 Rails.cache.write 的调用是安全的。但是,如果一个 spec 直接进行 Redis 调用,它应根据所使用的 Redis 实例,标记自己带有 :clean_gitlab_redis_cache、:clean_gitlab_redis_shared_state 或 :clean_gitlab_redis_queues 特性。
后台任务 / Sidekiq
默认情况下,Sidekiq 任务会被加入到一个作业数组中而不进行处理。如果一个测试排队了 Sidekiq 任务并需要它们被处理,可以使用 :sidekiq_inline 特性。
当Sidekiq 内联模式被更改为模拟模式 时,添加了 :sidekiq_might_not_need_inline 特性,适用于所有需要 Sidekiq 实际处理作业的测试。具有此特性的测试应该被修复为不依赖 Sidekiq 处理作业,或者如果需要/预期处理后台作业,则将其 :sidekiq_might_not_need_inline 特性更新为 :sidekiq_inline。
perform_enqueued_jobs 的使用仅适用于测试延迟邮件发送,因为我们的 Sidekiq 工作者并不继承自 ApplicationJob / ActiveJob::Base。
DNS
DNS 请求在测试套件中被普遍 stub(截至 !22368),因为 DNS 可能会根据开发人员的本地网络导致问题。你可以在 spec/support/dns.rb 中找到可应用于测试的 RSpec 标签,以便在需要时绕过 DNS stub,例如:
it "really connects to Prometheus", :permit_dns do如果你需要更具体的控制,DNS 阻塞是在 spec/support/helpers/dns_helpers.rb 中实现的,并且这些方法可以在其他地方调用。
速率限制
速率限制 在测试套件中启用。使用 :js 特性的功能规格中可能会触发速率限制。大多数情况下,可以通过用 :clean_gitlab_redis_rate_limiting 特性标记规范来避免触发速率限制。此特性会在规范之间清除存储在 Redis 缓存中的速率限制数据。如果单个测试触发了速率限制,则可以使用 :disable_rate_limit 替代。
存根文件方法
在需要存根文件内容的情况下,使用 stub_file_read 和 expect_file_read 辅助方法,它们能正确处理对 File.read 的存根。这些方法会对给定文件名进行 File.read 存根,同时也会对 File.exist? 进行存根以返回 true。
如果您出于任何原因需要手动存根 File.read,请务必做到:
- 对其他文件路径进行存根并调用原始实现。
- 然后仅对您感兴趣的文件路径进行
File.read存根。
否则,来自代码库其他部分的 File.read 调用会被错误地存根。
# 不好的做法,所有 Files 都会读取并返回空值
allow(File).to receive(:read)
# 好的做法
stub_file_read(my_filepath, content: "假文件内容")
# 也可行
allow(File).to receive(:read).and_call_original
allow(File).to receive(:read).with(my_filepath).and_return("假文件内容")文件系统
文件系统数据大致可分为“仓库”和其他内容。仓库存储在 tmp/tests/repositories 中。该目录在测试运行开始前和结束后会被清空。它在规范之间不会被清空,因此创建的仓库会在进程生命周期内累积在此目录中。删除它们的成本很高,但这可能导致污染,除非妥善管理。
为了避免这种情况,测试套件中启用了哈希存储。这意味着仓库会被赋予一个唯一的路径,该路径取决于其项目的 ID。由于项目 ID 在规范之间不会重置,因此每个规范都会在磁盘上拥有自己的仓库,从而防止规范之间的更改被看到。
如果一个规范手动指定了项目 ID,或直接检查 tmp/tests/repositories/ 目录的状态,那么它应该在运行前后都清理该目录。通常,应完全避免这些模式。
与数据库对象关联的其他类型的文件(如上传)通常以相同方式管理。在规范中启用哈希存储后,它们会被写入由 ID 决定的位置,因此不应发生冲突。
一些规范通过向 projects 工厂传递 :legacy_storage 特性来禁用哈希存储。执行此操作的规范必须永远不要覆盖项目的 path 或其任何组的路径。默认路径包含项目 ID,因此不会冲突。如果两个规范创建了具有相同路径的 :legacy_storage 项目,它们会使用磁盘上的同一个仓库,从而导致测试环境污染。
其他文件必须由规范手动管理。例如,如果您运行的代码创建了 tmp/test-file.csv 文件,则规范必须确保该文件作为清理的一部分被移除。
持久内存中的应用程序状态
给定 rspec 运行中的所有规范共享同一个 Ruby 进程,这意味着它们可以通过修改规范之间可访问的 Ruby 对象来相互影响。实际上,这意味着全局变量和常量(包括 Ruby 类、模块等)。
全局变量通常不应被修改。如果有绝对必要,可以使用如下代码块来确保更改之后被回滚:
around(:each) do |example|
old_value = $0
begin
$0 = "new-value"
example.run
ensure
$0 = old_value
end
end如果一个规范需要修改常量,它应该使用 stub_const 辅助方法来确保更改被回滚。
如果您需要修改 ENV 常量的内容,可以使用 stub_env 辅助方法代替。
虽然大多数 Ruby 实例 在规范之间不被共享,但类和模块通常是共享的。类和模块的实例变量、访问器、类变量以及其他有状态的习惯用法,应像全局变量一样对待。不要修改它们,除非必须!特别是,优先使用期望或依赖注入结合存根,以避免修改的需要。如果没有其他选择,可以使用类似全局变量示例的 around 代码块,但如果有可能,应避免这样做。
Elasticsearch 规格说明
需要 Elasticsearch 的规格必须标记为 :elastic 或 :elastic_delete_by_query 元数据。:elastic 元数据会在所有示例前后创建和删除索引。
:elastic_delete_by_query 元数据是为减少管道运行时间而添加的,它仅在各个上下文的开始和结束处创建和删除索引。Elasticsearch 删除查询 API 用于删除所有索引中的数据(除迁移索引外)以在示例间确保干净的索引。
:elastic_clean 元数据会在示例间创建和删除索引以确保干净的索引。这样,测试就不会被非必要数据污染。如果使用 :elastic 或 :elastic_delete_by_query 元数据导致问题,请改用 :elastic_clean。:elastic_clean 比其他特性慢得多,应谨慎使用。
大多数针对 Elasticsearch 逻辑的测试涉及:
- 在 PostgreSQL 中创建数据并等待其被索引到 Elasticsearch。
- 搜索该数据。
- 确保测试给出预期结果。
有些例外情况,例如检查索引中的结构变化而非单个记录。
Elasticsearch 索引使用 Gitlab::Redis::SharedState。因此,Elasticsearch 元数据会动态使用 :clean_gitlab_redis_shared_state。你无需手动添加 :clean_gitlab_redis_shared_state。
使用 Elasticsearch 的规格要求你:
- 在 PostgreSQL 中创建数据,然后将其索引到 Elasticsearch。
- 启用 Elasticsearch 的应用设置(默认禁用)。
为此,请使用:
before do
stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
end此外,你可以使用 ensure_elasticsearch_index! 方法来克服 Elasticsearch 的异步特性。它使用 Elasticsearch 刷新 API 确保自上次刷新以来对索引执行的所有操作都可用于搜索。此方法通常在向 PostgreSQL 加载完数据后调用,以确保数据被索引且可搜索。
当使用任何 Elasticsearch 元数据时,ElasticsearchHelpers 的辅助方法会自动包含。你也可以通过 :elastic_helpers 元数据直接包含它们。
你可以使用 SEARCH_SPEC_BENCHMARK 环境变量来基准测试测试设置步骤:
SEARCH_SPEC_BENCHMARK=1 bundle exec rspec ee/spec/lib/elastic/latest/merge_request_class_proxy_spec.rb
测试遗留的 Snowplow 事件
本节介绍如何测试尚未转换为内部事件的事件。
后端
Snowplow 使用 contracts gem 进行运行时类型检查。由于 Snowplow 在测试和开发环境中默认禁用,当模拟 Gitlab::Tracking 时,很难捕获异常。
若要捕获因类型检查导致的运行时错误,可以使用 expect_snowplow_event,它会检查对 Gitlab::Tracking#event 的调用。
describe '#show' do
it '跟踪 Snowplow 事件' do
get :show
expect_snowplow_event(
category: 'Experiment',
action: 'start',
namespace: group,
project: project
)
expect_snowplow_event(
category: 'Experiment',
action: 'sent',
property: 'property',
label: 'label',
namespace: group,
project: project
)
end
end当你想确保没有任何事件被调用时,可以使用 expect_no_snowplow_event。
describe '#show' do
it '不跟踪任何 Snowplow 事件' do
get :show
expect_no_snowplow_event(category: described_class.name, action: 'some_action')
end
end尽管可以省略 category 和 action,但你至少应指定一个 category 以避免测试不稳定。例如,Users::ActivityService 可能在 API 请求后跟踪 Snowplow 事件,若未指定参数,expect_no_snowplow_event 可能会失败。
带有数据属性的视图层
如果您在 Haml 层使用数据属性注册跟踪,可以使用 have_tracking 匹配器方法来断言是否分配了预期的数据属性。
例如,如果我们需要测试以下 Haml:
%div{ data: { testid: '_testid_', track_action: 'render', track_label: '_tracking_label_' } } it '分配跟踪项' do
render
expect(rendered).to have_tracking(action: 'render', label: '_tracking_label_', testid: '_testid_')
end it '分配跟踪项' do
render_inline(component)
expect(page).to have_tracking(action: 'render', label: '_tracking_label_', testid: '_testid_')
end当您想确保未分配跟踪时,可以使用上述匹配器的 not_to。
针对 Schema 测试 Snowplow 上下文
Snowplow 模式匹配器 通过将 Snowplow 上下文针对 JSON 模式进行测试,帮助减少验证错误。该模式匹配器接受以下参数:
schema 路径上下文
要添加模式匹配器规格:
-
向 Iglu 仓库 添加新模式,然后将相同模式复制到
spec/fixtures/product_intelligence/目录。 -
在复制的模式中,移除
"$schema"键及其值。我们不需要它用于规格,且如果保留该键,规格会失败,因为它会尝试从 URL 中查找模式。 -
使用以下代码片段调用模式匹配器:
match_snowplow_context_schema(schema_path: '<步骤 1 中的文件名>', context: <上下文哈希> )
表格化/参数化测试
这种测试风格用于以广泛的输入范围测试一段代码。通过一次性指定测试用例,并搭配每个输入的预期输出表格,您的测试可以变得更易读且更紧凑。
我们使用 RSpec::Parameterized gem。一个简短的示例如下,使用表格语法并为一系列输入检查 Ruby 相等性:
describe "#==" do
using RSpec::Parameterized::TableSyntax
let(:one) { 1 }
let(:two) { 2 }
where(:a, :b, :result) do
1 | 1 | true
1 | 2 | false
true | true | true
true | false | false
ref(:one) | ref(:one) | true # 必须使用 `ref` 引用 let 变量
ref(:one) | ref(:two) | false
end
with_them do
it { expect(a == b).to eq(result) }
it '具有同构性' do
expect(b == a).to eq(result)
end
end
end如果在创建表格化测试后看到如下错误:
NoMethodError:
未定义的方法 `to_params'
param_sets = extracted.is_a?(Array) ? extracted : extracted.to_params
^^^^^^^^^^
您是否指? to_param这表示您需要在规格文件中包含 using RSpec::Parameterized::TableSyntax 这一行。
仅在 where 块中使用简单值作为输入。使用 proc、有状态对象、FactoryBot 创建的对象等可能导致 意外结果。
Prometheus 测试
Prometheus 指标可能会从一个测试运行保留到另一个。为确保在每个示例前重置指标,请向 RSpec 测试添加 :prometheus 标签。
匹配器
应创建自定义匹配器以明确意图和/或隐藏 RSpec 预期的复杂性。它们应放置在 spec/support/matchers/ 下。如果匹配器仅适用于特定类型的规格(如功能或请求),则可放入子文件夹,但如果适用于多种类型的规格,则不应分文件夹。
be_like_time
从数据库返回的时间可能与Ruby中的时间对象在精度上存在差异,因此在规范比较时需采用灵活的容差。
PostgreSQL的time和timestamp类型具有1微秒的分辨率。然而,Ruby Time 的精度会因操作系统而变化。
考虑以下代码片段:
project = create(:project)
expect(project.created_at).to eq(Project.find(project.id).created_at)在Linux系统中,Time 可达最高9位精度,且 project.created_at 的值(如 2023-04-28 05:53:30.808033064)具备相同精度。但实际存储至数据库并重新加载的 created_at 值(如 2023-04-28 05:53:30.808033)精度不一致,会导致匹配失败。在macOS X上,Time 精度与PostgreSQL timestamp类型匹配,匹配可能成功。
为避免该问题,可用 be_like_time 或 be_within 比较两个时间是否在一秒范围内。
示例:
expect(metrics.merged_at).to be_like_time(time)be_within 示例:
expect(violation.reload.merged_at).to be_within(0.00001.seconds).of(merge_request.merged_at)have_gitlab_http_status
优先选用 have_gitlab_http_status 而非 have_http_status 或 expect(response.status).to,因前者在状态不匹配时会展示响应体。当测试失效需快速定位原因时,无需修改源码重跑测试,这非常实用。
显示500内部服务器错误时尤为适用。
优先使用命名HTTP状态(如 :no_content)而非数字形式(如 206)。支持的状态码见此处。
示例:
expect(response).to have_gitlab_http_status(:ok)match_schema 和 match_response_schema
match_schema 匹配器可验证主体是否符合JSON schema。expect 内的对象可为JSON字符串或兼容JSON的数据结构。
match_response_schema 是配合请求规范响应对象的便捷匹配器。
示例:
# 匹配 spec/fixtures/api/schemas/prometheus/additional_metrics_query_result.json
expect(data).to match_schema('prometheus/additional_metrics_query_result')
# 匹配 ee/spec/fixtures/api/schemas/board.json
expect(data).to match_schema('board', dir: 'ee')
# 匹配由Ruby数据结构构成的schema
expect(data).to match_schema(Atlassian::Schemata.build_info)be_valid_json
be_valid_json 用于验证字符串能否解析为JSON且结果非空。若需结合schema匹配,使用 and:
expect(json_string).to be_valid_json
expect(json_string).to be_valid_json.and match_schema(schema)be_one_of(collection)
与 include 相反,测试 collection 是否包含预期值:
expect(:a).to be_one_of(%i[a b c])
expect(:z).not_to be_one_of(%i[a b c])测试查询性能
测试查询性能可实现:
- 断言代码块中无N+1问题。
- 确保代码块的查询数量未意外增长。
QueryRecorder
QueryRecorder 可分析和测试给定代码块执行的数据库查询数量。
详见 QueryRecorder 章节。
GitalyClient
Gitlab::GitalyClient.get_request_count 可测试给定代码块发起的Gitaly查询数量:
详见 Gitaly Request Counts 章节。
共享上下文
仅在单个规范文件中使用的共享上下文可内联声明。多文件共用的共享上下文:
- 需置于
spec/support/shared_contexts/下。 - 仅适用于特定类型规范(如features或requests)时可放子文件夹;若适用于多类型规范则不应如此。
每个文件仅含一个上下文,且需具描述性名称,例如 spec/support/shared_contexts/controllers/githubish_import_controller_shared_context.rb。
共享示例
仅在单个规范文件中使用的共享示例可以内联声明。 任何被多个规范文件使用的共享示例:
- 应该放在
spec/support/shared_examples/下。 - 如果它们只适用于特定类型的规范(如功能或请求),可以放在子文件夹中,但如果它们适用于多种类型的规范,则不应这样做。
每个文件应该只包含一个上下文,并有一个描述性名称,例如 spec/support/shared_examples/controllers/githubish_import_controller_shared_example.rb。
Helper
Helper通常是提供一些方法来隐藏特定RSpec示例复杂性的模块。如果它们不打算与其他规范共享,可以在RSpec文件中定义helper。否则,它们应放置在 spec/support/helpers/ 下。如果它们仅适用于特定类型的规范(如功能或请求),可以将helper放在子文件夹中,但如果它们适用于多种类型的规范,则不应这样做。
Helper应遵循Rails命名/命名空间约定,其中 spec/support/helpers/ 是根目录。例如,spec/support/helpers/features/iteration_helpers.rb 应定义:
# frozen_string_literal: true
module Features
module IterationHelpers
def iteration_period(iteration)
"#{iteration.start_date.to_fs(:medium)} - #{iteration.due_date.to_fs(:medium)}"
end
end
endHelper不应更改RSpec配置。例如,上述helper模块不应包含:
# 不良实践
RSpec.configure do |config|
config.include Features::IterationHelpers
end
# 良好实践,在特定规范中包含
RSpec.describe 'Issue Sidebar', feature_category: :team_planning do
include Features::IterationHelpers
end测试Ruby常量
当测试使用Ruby常量的代码时,重点应放在依赖于该常量的行为上,而不是测试常量的值。
例如,以下做法更可取,因为它测试了类方法 .categories 的行为:
describe '.categories' do
it 'gets CE unique category names' do
expect(described_class.categories).to include(
'deploy_token_packages',
'user_packages',
# ...
'kubernetes_agent'
)
end
end另一方面,测试常量本身的值通常只会重复代码和测试中的值,提供的价值很小。
describe CATEGORIES do
it 'has values' do
expect(CATEGORIES).to eq([
'deploy_token_packages',
'user_packages',
# ...
'kubernetes_agent'
])
end
end在关键情况下,如果常量错误可能导致灾难性影响,测试常量值可能有用作为额外的保障措施。例如,如果它可能导致整个GitLab服务宕机、导致客户被多收费,或导致宇宙坍缩。
工厂
GitLab 使用 factory_bot 作为测试固定装置的替代品。
-
工厂定义位于
spec/factories/中,命名采用对应模型的复数形式(User工厂定义在users.rb中)。 -
每个文件应仅有一个顶级工厂定义。
-
FactoryBot 方法被混入到所有 RSpec 组中。这意味着你可以(也应该)调用
create(...)而不是FactoryBot.create(...)。 -
利用 traits 来清理定义和使用。
-
定义工厂时,不要定义结果记录通过验证所不需要的属性。
-
从工厂实例化时,不要提供测试不需要的属性。
-
在回调中进行关联设置时,使用 隐式、显式 或 内联 关联,而非
create/build。有关更多背景信息,请参阅 issue #262624。当创建具有
has_many和belongs_to关联的工厂时,使用instance方法引用正在构建的对象。这通过使用 相互关联的关联 防止了 不必要记录的创建。例如,如果我们有以下类:
class Car < ApplicationRecord has_many :wheels, inverse_of: :car, foreign_key: :car_id end class Wheel < ApplicationRecord belongs_to :car, foreign_key: :car_id, inverse_of: :wheel, optional: false end我们可以创建以下工厂:
FactoryBot.define do factory :car do transient do wheels_count { 2 } end wheels do Array.new(wheels_count) do association(:wheel, car: instance) end end end end FactoryBot.define do factory :wheel do car { association :car } end end -
工厂不必局限于
ActiveRecord对象。参见示例。 -
避免在工厂中使用
skip_callback。详情请参阅 issue #247865。
固定装置
所有固定装置都应放置在 spec/fixtures/ 下。
仓库
测试某些功能(例如合并合并请求)需要在测试环境中存在具有特定状态的 Git 仓库。GitLab 维护着 gitlab-test 仓库以应对某些常见场景——你可以通过项目工厂的 :repository 特性确保使用该仓库的副本:
let(:project) { create(:project, :repository) }尽可能考虑使用 :custom_repo 特性代替 :repository。这允许你精确指定项目中仓库 main 分支出现的文件。例如:
let(:project) do
create(
:project, :custom_repo,
files: {
'README.md' => '此处的内容',
'foo/bar/baz.txt' => '此处的更多内容'
}
)
end这将创建一个包含两个文件的仓库,具有默认权限和指定内容。
配置
RSpec配置文件是用于更改RSpec配置(如RSpec.configure do |config|块)的文件。它们应放置在spec/support/目录下。
每个文件应与特定领域相关,例如spec/support/capybara.rb或spec/support/carrierwave.rb。
如果一个帮助模块仅适用于特定类型的规格,它应在config.include调用中添加修饰符。例如,若spec/support/helpers/cycle_analytics_helpers.rb仅适用于:lib和type: :model规格,则可编写如下代码:
RSpec.configure do |config|
config.include Spec::Support::Helpers::CycleAnalyticsHelpers, :lib
config.include Spec::Support::Helpers::CycleAnalyticsHelpers, type: :model
end如果配置文件仅包含config.include,可直接将这些config.include添加至spec/spec_helper.rb中。
对于非常通用的帮助程序,考虑将其包含在spec/support/rspec.rb文件中,该文件被spec/fast_spec_helper.rb使用。有关spec/fast_spec_helper.rb文件的更多详情,请参阅快速单元测试。
测试环境日志
测试环境的服务会在运行测试时自动配置并启动,包括Gitaly、Workhorse、Elasticsearch和Capybara。在CI环境中运行或需安装服务时,测试环境会记录设置时间的信息,生成类似以下的日志消息:
==> 正在设置 Gitaly...
Gitaly 设置完成,耗时 31.459649 秒...
==> 正在设置 GitLab Workhorse...
GitLab Workhorse 设置完成,耗时 29.695619 秒...
fatal: 更新 refs/heads/diff-files-symlink-to-image: 无效的 <newvalue>: 8cfca84
来自 https://gitlab.com/gitlab-org/gitlab-test
* [新分支] diff-files-image-to-symlink -> origin/diff-files-image-to-symlink
* [新分支] diff-files-symlink-to-image -> origin/diff-files-symlink-to-image
* [新分支] diff-files-symlink-to-text -> origin/diff-files-symlink-to-text
* [新分支] diff-files-text-to-symlink -> origin/diff-files-text-to-symlink
b80faa8..40232f7 snippet/multiple-files -> origin/snippet/multiple-files
* [新分支] testing/branch-with-#-hash -> origin/testing/branch-with-#-hash
==> 正在设置 GitLab Elasticsearch Indexer...
GitLab Elasticsearch Indexer 设置完成,耗时 26.514623 秒...本地运行且无需执行操作时,此信息会被省略。若您希望始终查看这些消息,可设置以下环境变量:
GITLAB_TESTING_LOG_LEVEL=debug