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

端到端测试最佳实践

这是在测试指南中找到的最佳实践的定制扩展。

类和模块命名

QA框架使用Zeitwerk进行类和模块自动加载。默认的Zeitwerkinflector将蛇形命名法的文件名转换为帕斯卡尔命名法的模块或类名。建议遵循此模式以避免手动维护推断逻辑。

若需自定义推断逻辑,可在qa.rb文件的loader.inflector.inflect方法调用中添加自定义推断器。

关联测试与测试用例

每个测试都应在GitLab项目测试用例中有对应的测试用例,以及在质量测试用例项目中有结果问题。 若测试用例问题尚未存在,任何GitLab团队成员均可于GitLab项目的**CI/CD > 测试用例**页面创建带占位符标题的新测试用例。当测试用例URL链接至代码中的测试后,在启用报告的管道中运行测试时,report-results脚本会自动更新测试用例和结果问题。 若结果问题尚未存在,report-results脚本会自动创建一个并将其链接至对应测试用例。

要将测试用例链接至代码中的测试,必须手动添加testcase RSpec元数据标签。多数情况下,单个测试关联单个测试用例。

例如:

RSpec.describe 'Stage' do
  describe '被测功能的通用描述' do
    it '测试名称', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/:test_case_id' do
      ...
    end

    it '另一个测试', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/:another_test_case_id' do
      ...
    end
  end
end

共享测试的情况

多数测试由spec文件中的一行定义,故此类测试可通过testcase标签链接至单个测试用例。

然则,部分测试在spec文件的一行与测试用例间无一一对应关系。因部分测试的定义方式使单行关联多个测试,包括:

  • 并行化测试。
  • 模板化测试。
  • 含多个示例的共享示例中的测试。

在此类及相似场景下,需通过其他方式包含测试用例链接。

示例如下,在qa/specs/features/ee/browser_ui/3_create/repository/restrict_push_protected_branch_spec.rb的共享示例中有两个测试:

RSpec.shared_examples '未选中的维护者' do |testcase|
  it '用户无法推送', testcase: testcase do
    ...
  end
end

RSpec.shared_examples '已选中的开发者' do |testcase|
  it '用户推送并合并', testcase: testcase do
    ...
  end
end

考虑以下包含共享示例的测试:

RSpec.describe '创建' do
  describe '受保护的分支推送与合并' do
    context '当仅允许一个用户向受保护分支推送和合并时' do
      ...

      it_behaves_like '未选中的维护者', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347775'
      it_behaves_like '已选中的开发者', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347774'
    end

    context '当仅允许一个组向受保护分支推送和合并时' do
      ...

      it_behaves_like '未选中的维护者', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347772'
      it_behaves_like '已选中的开发者', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347773'
    end
  end
end

我们建议为每个共享示例创建两个相关测试用例(共四个)。

测试命名

测试名称应构成可读语句,明确测试目的。我们的测试指南扩展了Thoughtbot测试风格指南。本页澄清了指南,并结合了https://www.betterspecs.org/RSpec命名指南的输入。

推荐方法

以下代码块生成一个名为 Plan wiki content creation in a project adds a home page 的测试:

# `RSpec.describe` 是所覆盖的 DevOps 阶段
RSpec.describe 'Plan', product_group: :knowledge do
  # `describe` 是被测的功能
  describe 'wiki content creation' do
    # `context` 提供被覆盖的条件
    context 'in a project'
      # `it` 定义测试的预期结果
      it 'adds a home page'
      ...
      end
    ...
    end
  ...
  end
end
  1. 每个 describecontextit 块都应附带简短描述
  2. 保持描述尽可能简洁。
    1. 过长的描述或多个条件判断可能是应该拆分的信号(额外的 context 块)。
    2. 文档风格指南 提供了如何用主动语态编写简洁内容的建议。
  3. 最外层的 Rspec.describe 块应是 DevOps 阶段名称
  4. Rspec.describe 块内是一个以被测功能命名的 describe
  5. 可选的 context 块定义被测试的条件
    1. context 块的描述应以 whenwithwithoutforandoninasif 开头,以符合 RuboCop 规则
  6. it 块描述测试的通过/失败标准
    1. 在包含单个示例的 shared_examples 中,可使用 specify 块代替命名的 it

优先使用 API 而非 UI

端到端测试框架能够按需构建资源。应尽可能通过 API 构建资源。

我们可以通过 API 为测试所需资源进行构建,从而节省时间和成本。

了解更多 关于资源的信息。

避免冗余的期望

为了保持测试精简,仅测试必要的内容很重要。

确保不要添加与测试需求无关的任何 expect() 语句。

例如:

#=> 好
Flow::Login.sign_in
Page::Main::Menu.perform do |menu|
  expect(menu).to be_signed_in
end

#=> 坏
Flow::Login.sign_in(as: user)
Page::Main::Menu.perform do |menu|
  expect(menu).to be_signed_in
  expect(page).to have_content(user.name) #=> 我们已验证过登录状态,冗余。
  expect(menu).to have_element(:nav_bar) #=> 可能没必要,已在低层验证,测试未要求验证此内容。
end

#=> 好
issue = create(:issue, name: 'issue-name')

Project::Issues::Index.perform do |index|
  expect(index).to have_issue(issue)
end

#=> 坏
issue = create(:issue, name: 'issue-name')

Project::Issues::Index.perform do |index|
  expect(index).to have_issue(issue)
  expect(page).to have_content(issue.name) #=> 页面内容检查冗余,因上一行已验证问题存在。
end

当有连续的期望时,优先使用 aggregate_failures

参见 当有多个期望时,优先使用聚合失败

当有多个期望时,优先使用 aggregate_failures

在必须在一个测试用例中包含多个期望的情况下,最好使用 aggregate_failures

这允许你将一组期望分组,并一次性查看所有失败,而不是在第一次失败时就终止测试。

例如:

#=> 好
Page::Search::Results.perform do |search|
  search.switch_to_code

  aggregate_failures '测试搜索结果' do
    expect(search).to have_file_in_project(template[:file_name], project.name)
    expect(search).to have_file_with_content(template[:file_name], content[0..33])
  end
end

#=> 坏
Page::Search::Results.perform do |search|
  search.switch_to_code
  expect(search).to have_file_in_project(template[:file_name], project.name)
  expect(search).to have_file_with_content(template[:file_name], content[0..33])
end

如果多个期望由语句分隔,可将 :aggregate_failures 元数据附加到示例上。

#=> 好
it '搜索', :aggregate_failures do
  Page::Search::Results.perform do |search|
    expect(search).to have_file_in_project(template[:file_name], project.name)

    search.switch_to_code

    expect(search).to have_file_with_content(template[:file_name], content[0..33])
  end
end

#=> 坏
it '搜索' do
  Page::Search::Results.perform do |search|
    expect(search).to have_file_in_project(template[:file_name], project.name)

    search.switch_to_code

    expect(search).to have_file_with_content(template[:file_name], content[0..33])
  end
end

避免在 expect do ... raise_error 块中执行多个操作

当你在一个 expect do ... end.not_to raise_errorexpect do ... end.to raise_error 块中包装多个操作时,由于日志的打印方式,很难调试失败的实际原因。重要信息可能会被截断或完全缺失。

例如,如果你在测试中将一些操作和预期封装在一个私有方法中,比如 expect_owner_permissions_allow_delete_issue

it "拥有 Owner 角色及 Owner 权限" do
  Page::Dashboard::Projects.perform do |projects|
    projects.filter_by_name(project.name)

    expect(projects).to have_project_with_access_role(project.name, 'Owner')
  end

  expect_owner_permissions_allow_delete_issue
end

然后在方法本身中:

#=> 好
def expect_owner_permissions_allow_delete_issue
  issue.visit!

  Page::Project::Issue::Show.perform(&:delete_issue)

  Page::Project::Issue::Index.perform do |index|
    expect(index).not_to have_issue(issue)
  end
end

#=> 不佳
def expect_owner_permissions_allow_delete_issue
  expect do
    issue.visit!

    Page::Project::Issue::Show.perform(&:delete_issue)

    Page::Project::Issue::Index.perform do |index|
      expect(index).not_to have_issue(issue)
    end
  end.not_to raise_error
end

优先将测试拆分到多个文件中

我们的框架包含几个并行化机制,通过并行执行规范文件来工作。

然而,由于测试是通过规范文件而非测试/示例进行并行化的,如果我们向现有文件添加新测试,就无法实现更大的并行化。

尽管如此,可能有其他原因需要在现有文件中添加新测试。

例如,如果测试共享的设置成本高昂,即使这意味着使用该设置的测试无法并行化,也可能更高效地执行一次设置。

总之:

  • :除非测试共享昂贵的设置,否则将测试拆分到单独的文件中。
  • 不做:不考虑对并行化的影响就将新测试放入现有文件中。

let 变量与实例变量

默认情况下,使用 let 或实例变量时遵循 测试最佳实践。然而,在端到端测试中,诸如创建资源之类的设置操作成本很高。如果你使用 let 存储资源,它将为每个示例分别创建。如果资源可以在多个示例之间共享,请在 before(:all) 块中使用实例变量而不是 let 来节省运行时间。当变量无法由多个示例共享时,使用 let

限制在 before(:context)after 钩子中使用 UI

before(:context) 钩子的使用限制为仅执行设置任务,这些任务只能通过 API 调用、非 UI 操作或基本 UI 操作(如登录)来完成。

我们使用 capybara-screenshot 库来自动在失败时保存屏幕截图。

capybara-screenshot 在 RSpec 的 after 钩子中保存截图如果在 before(:context) 中发生失败,则不会调用 after 钩子,因此不会保存截图。

鉴于这一事实,我们应该将 before(:context) 的使用限制在那些不需要截图的操作上。

同样,after 钩子应仅用于非 UI 操作。测试文件中 after 钩子中的任何 UI 操作都会在该捕获截图的 after 钩子之前执行。这将导致 UI 状态偏离失败点,因此截图不会被恰当地捕捉。

确保测试不会让浏览器保持登录状态

所有测试期望能够在测试开始时登录。

有关示例,请参阅 问题 #34736

理想情况下,在 after(:context)(或 before(:context))块中执行的操作应通过 API 执行。如果必须通过用户界面执行(例如,如果 API 功能不存在),请务必在块结束时登出。

after(:all) do
  login unless Page::Main::Menu.perform(&:signed_in?)

  # 在登录状态下执行某些操作

  Page::Main::Menu.perform(&:sign_out)
end

标记需要管理员访问权限的测试

我们不会在Production环境中运行需要管理员访问权限的测试。

当你添加一个需要管理员访问权限的新测试时,请应用RSpec元数据:requires_admin,这样该测试将不会被包含在针对Production和其他我们不希望运行这些测试的环境执行的测试套件中。

在本地运行测试或配置管道时,可以将环境变量QA_CAN_TEST_ADMIN_FEATURES设置为false,以跳过带有:requires_admin标签的测试。

如果测试中唯一需要管理员访问权限的操作是切换功能标志,请改用feature_flag标签。更多详情可参见使用功能标志进行测试

优先使用 Commit 资源而非 ProjectPush

遵循使用API,应尽可能使用Commit资源。

ProjectPush使用来自Git命令行界面(CLI)的原生shell命令,而Commit资源则发出HTTP请求。


# 使用commit资源
Resource::Repository::Commit.fabricate_via_api! do |commit|
  commit.commit_message = '初始提交'
  commit.add_files([
    { file_path: 'README.md', content: '你好,GitLab' }
  ])
end

# 使用ProjectPush
Resource::Repository::ProjectPush.fabricate! do |push|
  push.commit_message = '初始提交'
  push.file_name = 'README.md'
  push.file_content = '你好,GitLab'
end

使用ProjectPush的一些例外情况是当你的测试需要测试SSH集成或使用Git CLI时。

模糊元素的首选方法

要模糊一个元素,首选方法是选择另一个不改变测试状态的元素。 如果有遮罩阻挡了页面元素(例如某些下拉菜单可能出现的情况),请使用WebDriver的原生鼠标事件来模拟点击元素的坐标。使用以下方法:click_element_coordinates

避免为输入框和下拉菜单等元素点击body,因为它会点击视口的中心。 此操作也可能无意中点击其他元素,从而改变测试状态并导致失败。


# 点击另一个元素来模糊输入框
def add_issue_to_epic(issue_url)
  find_element(:issue_actions_split_button).find('button', text: '添加问题').click
  fill_element(:add_issue_input, issue_url)
  # 点击标题来模糊输入框
  click_element(:title)
  click_element(:add_issue_button)
end

# 在存在遮罩/覆盖层的情况下使用原生鼠标点击事件
click_element_coordinates(:title)

确保期望语句高效等待

一般来说,我们使用expect语句来检查某事是否符合我们的预期。例如:

Page::Project::Pipeline::Show.perform do |pipeline|
  expect(pipeline).to have_job('a_job')
end

对需要等待的期望使用 eventually_ 匹配器

当某事需要等待才能匹配时,请使用具有明确等待时间定义的eventually_匹配器。

Eventually匹配器使用以下命名模式:eventually_${rspec_matcher_name}。它们定义在eventually_matcher.rb中。

expect { async_value }.to eventually_eq(value).within(max_duration: 120, max_attempts: 60, reload_page: page)

创建可否定匹配器以加速 expect 检查

然而,有时我们想检查某物是否不是我们不希望它成为的样子。换句话说,我们想要确保某物不存在。对于单元测试和功能规范,我们通常使用 not_to,因为 RSpec 的内置匹配器是可否定的,Capybara 的也是如此,这意味着以下两个语句是等效的。

except(page).not_to have_text('hidden')
except(page).to have_no_text('hidden')

不幸的是,对于我们添加到 页面对象 的谓词方法来说,情况并非自动如此。我们需要 创建自己的可否定匹配器

初始示例使用了 have_job 匹配器,该匹配器派生自 Page::Project::Pipeline::Show 页面对象的 has_job? 谓词方法。要创建可否定匹配器,我们对否定情况使用 has_no_job?

RSpec::Matchers.define :have_job do |job_name|
  match do |page_object|
    page_object.has_job?(job_name)
  end

  match_when_negated do |page_object|
    page_object.has_no_job?(job_name)
  end
end

然后,以下示例中的两个 expect 语句是等效的:

Page::Project::Pipeline::Show.perform do |pipeline|
  expect(pipeline).not_to have_job('a_job')
  expect(pipeline).to have_no_job('a_job')
end

查看此合并请求,了解添加自定义匹配器的真实示例

我们在 qa/spec/support/matchers 中创建自定义可否定匹配器。

我们只需要为我们添加到测试框架的谓词方法创建自定义可否定匹配器,并且只有在使用 not_to 时才需要。如果我们使用 to have_no_*,则不需要可否定匹配器,但它能提高代码的可读性。

为什么我们需要可否定匹配器

考虑以下代码,但假设我们没有针对 have_job 的自定义可否定匹配器。

# 不良实践
Page::Project::Pipeline::Show.perform do |pipeline|
  expect(pipeline).not_to have_job('a_job')
end

为了使该语句通过,have_job('a_job') 必须返回 false,以便 not_to 可以对其进行否定。问题是 have_job('a_job') 会等待最多十秒钟,直到 'a job' 出现后才返回 false。在预期条件下,这个测试会比所需时间长十秒。

相反,我们可以强制不等待:

# 不算太差但可能有缺陷
Page::Project::Pipeline::Show.perform do |pipeline|
  expect(pipeline).not_to have_job('a_job', wait: 0)
end

问题是,如果 'a_job' 存在且我们正在等待它消失,这个语句将会失败。

如果我们创建了自定义可否定匹配器,这两个问题都不会存在,因为会使用 has_no_job? 谓词方法,该方法只会等待必要的时间让作业消失。

最后,可否定匹配器比使用 have_no_* 形式的匹配器更受青睐,因为使用 not_to 否定匹配器是一种常见且熟悉的做法。如果我们通过添加可否定匹配器来促进这种做法,就能让后续的测试编写者更容易写出高效的测试。

使用 logger 而非 puts

目前我们在 GitLab QA 应用程序和端到端测试中都使用 Rails logger 来处理日志。与 puts 相比,这提供了额外的功能,例如:

  • 能够指定日志级别。
  • 能够标记相似的日志。
  • 自动格式化日志消息。