Help us learn about your current experience with the documentation. Take the survey.

测试最佳实践

测试设计

在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/AvoidConditionalStatements RuboCop Cop - MR)。
  • 尝试使测试的顺序与类中的顺序一致。
  • 尝试遵循四阶段测试模式,使用换行符分隔各个阶段。
  • 使用Gitlab.config.gitlab.host而非硬编码'localhost'
  • 对于测试中的字面量URL,使用example.comgitlab.example.com。这将确保我们不使用任何真实URL。
  • 不要断言序列生成属性的绝对值(参见陷阱)。
  • 避免使用expect_any_instance_ofallow_any_instance_of(参见陷阱)。
  • 不要向hook传递:each参数,因为这是默认行为。
  • beforeafter hook中,优先选择作用域为: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_doublespyFactoryBot.build(...) 更快。
  • FactoryBot.build(...).build_stubbed.create 更快。
  • 当你可以使用 buildbuild_stubbedattributes_forspyinstance_double 时,不要 create 对象。数据库持久化很慢!

使用 Factory Doctor 来查找给定测试中不需要数据库持久化的情况。

工厂优化的例子 12

# 运行指定路径的测试
FDOC=1 bin/rspec spec/[path]/[to]/[spec].rb

一个常见的改动是用 buildbuild_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 timetop-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 timetop-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_refindlet_it_be_with_reload 类似,但前者会调用 ActiveRecord::Base#find,而非 ActiveRecord::Base#reload。通常 reloadrefind 更快。
  • let_it_be_with_reload 会为同一上下文中的所有示例仅创建一次对象,但在每个示例结束后,数据库变更会被回滚,并调用 object.reload 将对象恢复至初始状态。这意味着你可以在示例之前或期间对对象进行修改。然而,存在某些情况下会发生状态泄漏到其他模型。在这些情况下,let 可能是更简单的选择,尤其当仅有少量示例时。
  • let_it_be 会为同一上下文中的所有示例仅创建一次对象。对于无需在示例之间改变的对象而言,它是 letlet! 的绝佳替代方案。使用 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 声明内的对象存在一些重要的注意事项(12)。若要让 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_reloadlet 更高效的示例:

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

存根成员访问级别

若要对像 ProjectGroup 这样的工厂存根 成员访问级别,请使用 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_authorizationsMember 记录,请不要使用此存根助手。改用 Project#add_memberGroup#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 项目

功能类别元数据

你必须 为每个 RSpec 示例设置功能类别元数据

依赖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 failures

live_debug 仅适用于启用 JavaScript 的规范。

在可见浏览器中运行:js测试

使用 WEBDRIVER_HEADLESS=0 运行测试,示例如下:

WEBDRIVER_HEADLESS=0 bin/rspec some_spec.rb

测试会快速完成,但这能让你了解发生了什么。 使用 live_debug 配合 WEBDRIVER_HEADLESS=0 会暂停打开的浏览器,且不会重新打开页面。这可用于调试和检查元素。

你也可以添加 byebugbinding.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'添加到需要re2 gem的库文件中,以明确此要求。这种方法更可取。
  • 将其添加到测试本身。

或者,如果它是你的领域中许多不同的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测试,例如无法成功独立运行的测试。有关更多详细信息,请参阅该脚本。

subjectlet 变量

GitLab 的 RSpec 测试套件大量使用了 let(及其严格的非惰性版本 let!)变量来减少重复。然而,这有时会牺牲清晰度,因此我们需要制定一些未来的使用准则:

  • let! 变量优于实例变量。let 变量优于 let! 变量。局部变量优于 let 变量。
  • 使用 let 在整个规范文件中减少重复。
  • 不要用 let 定义仅由单个测试使用的变量;将其定义为测试的 it 块内的局部变量。
  • 不要在顶级 describe 块内定义仅在更深嵌套的 contextdescribe 块中使用的 let 变量。将定义尽可能靠近使用的地方。
  • 尝试避免用一个 let 变量覆盖另一个的定义。
  • 不要定义仅由另一个 let 变量的定义使用的 let 变量。改用辅助方法。
  • let! 变量应仅在需要严格评估且定义顺序的情况下使用,否则 let 就足够了。记住 let 是惰性的,直到被引用时才会被评估。
  • 避免在示例中引用 subject。使用命名主题 subject(:name)let 变量代替,这样变量就有上下文名称。
  • 如果 subject 从未被示例引用,那么可以接受不命名地定义 subject

通用测试设置

let_it_bebefore_all 与 DatabaseCleaner 的删除策略不兼容。这包括迁移规范、Rake 任务规范以及带有 :delete RSpec 元数据标签的规范。有关更多信息,请参阅 问题 420379

在某些情况下,无需为每个示例重新创建相同的对象进行测试。例如,测试同一项目的问题需要一个项目和该项目的访客,因此整个文件只需一个项目和用户即可。

尽可能不要使用 before(:all)before(:context) 来实现这一点。如果这样做,您需要手动清理数据,因为这些钩子在数据库事务之外运行。

相反,可以通过使用 let_it_be 变量和来自 test-prof gembefore_all 钩子来实现这一点。

let_it_be(:project) { create(:project) }
let_it_be(:user) { create(:user) }

before_all do
  project.add_guest(user)
end

这会导致在此上下文中只创建一个 ProjectUserProjectMember

let_it_bebefore_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 的双精度或部分双精度。

因此,解决方案是使用 letlet! 代替 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
end

RSpec 辅助方法

您可以使用 :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:改用 letlet!
  • let_it_be_with_reload:改用 letlet!
  • let_it_be_with_refind:改用 letlet!
  • before_all:改用 beforebefore(:all)

Redis

GitLab 在 Redis 中存储两大类数据:缓存项和 Sidekiq 任务。查看所有由独立 Redis 实例支持的 Gitlab::Redis::Wrapper 后代列表

在大多数 spec 中,Rails 缓存实际上是一个内存存储。它在 spec 之间被替换,因此对 Rails.cache.readRails.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_readexpect_file_read 辅助方法,它们能正确处理对 File.read 的存根。这些方法会对给定文件名进行 File.read 存根,同时也会对 File.exist? 进行存根以返回 true

如果您出于任何原因需要手动存根 File.read,请务必做到:

  1. 对其他文件路径进行存根并调用原始实现。
  2. 然后仅对您感兴趣的文件路径进行 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

尽管可以省略 categoryaction,但你至少应指定一个 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 路径
  • 上下文

要添加模式匹配器规格:

  1. Iglu 仓库 添加新模式,然后将相同模式复制到 spec/fixtures/product_intelligence/ 目录。

  2. 在复制的模式中,移除 "$schema" 键及其值。我们不需要它用于规格,且如果保留该键,规格会失败,因为它会尝试从 URL 中查找模式。

  3. 使用以下代码片段调用模式匹配器:

    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_timebe_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_statusexpect(response.status).to,因前者在状态不匹配时会展示响应体。当测试失效需快速定位原因时,无需修改源码重跑测试,这非常实用。

显示500内部服务器错误时尤为适用。

优先使用命名HTTP状态(如 :no_content)而非数字形式(如 206)。支持的状态码见此处

示例:

expect(response).to have_gitlab_http_status(:ok)

match_schemamatch_response_schema

match_schema 匹配器可验证主体是否符合JSON schemaexpect 内的对象可为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
end

Helper不应更改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_manybelongs_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.rbspec/support/carrierwave.rb

如果一个帮助模块仅适用于特定类型的规格,它应在config.include调用中添加修饰符。例如,若spec/support/helpers/cycle_analytics_helpers.rb仅适用于:libtype: :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

返回到测试文档