エンドツーエンドテストのベストプラクティス

これは、テストガイドにあるベストプラクティスを拡張したものです。

クラスとモジュールの命名

QA フレームワークでは、クラスとモジュールのオートロードにZeitwerk を使用します。デフォルトの Zeitwerkインフレクタは、snake_cased ファイル名を PascalCased モジュール名またはクラス名に変換します。手動による屈折のメンテナンスを避けるため、このパターンに従うことをお勧めします。

カスタム屈折ロジックが必要な場合は、loader.inflector.inflect メソッド呼び出しのqa.rbファイルにカスタム屈折器を追加します。

すべてのテストには、GitLabプロジェクトのテストケースに対応するテストケースがあり、Quality Test Casesプロジェクトに結果のイシューがあるはずです。テストケースのイシューがまだ存在しない場合、GitLabチームのメンバーであれば誰でも CI/CD > テストケースページで新しいテストケースを作成することができます。テストケースの URL がコード内のテストにリンクされた後、レポートが有効になっているパイプラインでテストが実行されると、report-results スクリプトは自動的にテストケースと結果のイシューを更新 report-resultsします。結果report-results イシューがまだ存在 report-resultsしない場合は、スクリプトが自動的にイシューを作成し、対応するテストケースにリンクします。

テストケースをコード内のテストにリンクするには、testcase RSpec メタデータ タグを手動で追加する必要があります。ほとんどの場合、1 つのテストは 1 つのテストケースに関連付けられています。

使用例:

RSpec.describe 'Stage' do
  describe 'General description of the feature under test' do
    it 'test name', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/:test_case_id' do
      ...
    end

    it 'another test', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/:another_test_case_id' do
      ...
    end
  end
end

共有テストの場合

ほとんどのテストは、spec ファイルの一行で定義されます。そのため、これらのテストは、testcase タグでひとつのテストケースにリンクすることができます。

しかし、spec ファイルの 1 行とテストケースが 1 対 1 の関係にないテストもあります。これは、1 つの行が複数のテストに関連付けられるように定義されているテストがあるためです:

  • 並列化されたテスト。
  • テンプレート化されたテスト
  • 複数の例を含む共有例のテスト。

このような場合、他の方法でテストケースのリンクを含める必要があります。

例として、qa/specs/features/ee/browser_ui/3_create/repository/restrict_push_protected_branch_spec.rbの共有例の中に二つのテストがあります:

RSpec.shared_examples 'unselected maintainer' do |testcase|
  it 'user fails to push', testcase: testcase do
    ...
  end
end

RSpec.shared_examples 'selected developer' do |testcase|
  it 'user pushes and merges', testcase: testcase do
    ...
  end
end

共有例を含む次のテストを考えてみましょう:

RSpec.describe 'Create' do
  describe 'Restricted protected branch push and merge' do
    context 'when only one user is allowed to merge and push to a protected branch' do
      ...

      it_behaves_like 'unselected maintainer', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347775'
      it_behaves_like 'selected developer', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347774'
    end

    context 'when only one group is allowed to merge and push to a protected branch' do
      ...

      it_behaves_like 'unselected maintainer', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347772'
      it_behaves_like 'selected developer', 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/347773'
    end
  end
end

各共有例に対して 2 つずつ、合計 4 つのテストケースを作成することを推奨します。

テストの命名

テストの名前は、テストの目的を定義する読みやすい文章にします。私たちのテストガイドはThoughtbotのテストスタイルガイドを拡張したものです。このページでは、https://www.betterspecs.org/およびRSpec ネーミングガイドからの情報とともに、ガイドラインを説明します。

次のブロックはPlan wiki content creation in a project adds a home page

# `RSpec.describe` is the DevOps Stage being covered
RSpec.describe 'Plan', product_group: :knowledge do
  # `describe` is the feature being tested
  describe 'wiki content creation' do
    # `context` provides the condition being covered
    context 'in a project'
      # `it` defines the expected result of the test
      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 ブロックの説明は、when,with,without,for,and,on,in,as, またはif で始まる必要があります
  6. it ブロックには、テストの合否基準を記述します。
    1. shared_examplesit ブロックの代わりに、specify ブロックを使用することができます。

UIよりもAPIを優先

エンドツーエンドのテストフレームワークは、ケースバイケースでリソースを作成する能力を持っています。リソースは可能な限りAPI経由で作成されるべきです。

テストが必要とするリソースを API 経由で作成することで、時間とコストの両方を節約できます。

リソースについて詳しくはこちら。

余計な期待を避ける

テストを無駄のないものにするためには、必要なものだけをテストすることが重要です。

テストする必要のないexpect() ステートメントを追加しないようにしましょう。

使用例:

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

#=> Bad
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) #=>  we already validated being signed in. redundant.
  expect(menu).to have_element(:nav_bar) #=> likely unnecessary. already validated in lower-level. test doesn't call for validating this.
end

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

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

#=> Bad
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) #=> page content check is redundant as the issue was already validated in the line above.
end

aggregate_failures 、背中合わせの期待がある場合を優先します。

複数の期待値がある場合、プリファード・アグリゲートの失敗を参照してください。

複数の期待値がある場合は、aggregate_failures

テストケース内に複数の期待値が必要な場合は、aggregate_failures を使用することをお勧めします。

これにより、最初の失敗でテストが中断されるのではなく、期待値のセットをグループ化し、すべての失敗をまとめて見ることができます。

使用例:

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

  aggregate_failures 'testing search results' 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

#=> Bad
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 メタデータを例に添付します。

#=> Good
it 'searches', :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

#=> Bad
it 'searches' 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 ブロック内での複数のアクションは避けてください。

複数のアクションを1つのexpect do ... end.not_to raise_error またはexpect do ... end.to raise_error ブロックにまとめると、ログが出力されるため、失敗の実際の原因をデバッグするのが難しくなります。重要な情報が切り捨てられたり、完全に欠落したりする可能性があります。

たとえば、expect_owner_permissions_allow_delete_issue のように、いくつかのアクションと期待値をテストの非公開メソッドにカプセル化します:

it "has Owner role with Owner permissions" 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

そして、そのメソッド自体に

#=> Good
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

#=> Bad
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

テストを複数のファイルに分割することを優先します。

私たちのフレームワークには、spec ファイルを並列に実行するいくつかの並列化メカニズムがあります。

しかし、テストは specファイル単位で並列化され、test/example 単位では並列化されないため、既存のファイルに新しいテストが追加されても、より大きな並列化は実現できません。

とはいえ、既存のファイルに新しいテストを追加する理由は他にも考えられます。

たとえば、セットアップにコストのかかる状態をテストが共有している場合、たとえそのセットアップを使用するテストが並列化できないとしても、そのセットアップを一度実行したほうが効率的な場合があります。

まとめると

  • To-Do:高価なセットアップを共有するテストを除き、テストを別々のファイルに分割します。
  • やめてください:並列化への影響を考慮せずに、既存のファイルに新しいテストを入れること。

let 変数とインスタンス変数の比較

デフォルトでは、let またはインスタンス変数を使用する場合は、テストのベストプラクティスに従います。しかし、エンドツーエンドのテストでは、リソースの作成などのセットアップにコストがかかります。let を使ってリソースを保存すると、各サンプルに対して個別に作成されます。リソースを複数の例で共有できる場合は、before(:all) ブロックのインスタンス変数を代わりにlet 使用して、実行時間を節約 letします。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使うべきです。テストファイルのフックにafter あるUIオペレーションはすべて after、スクリーンショットを撮るafter フックの前に実行されます。これは、UIステータスを障害発生地点から遠ざける結果となり、スクリーンショットが適切なタイミングでキャプチャされません。

テストがブラウザにログインしたままにならないようにします。

すべてのテストは、テストの開始時にサインインできることを期待します。

例については、イシュー#34736 を参照してください。

after(:context) (あるいはbefore(:context)) ブロックで実行されるアクションは、API を使って実行されるのが理想的です。ユーザーインターフェイスで行う必要がある場合 (例えばAPI機能が存在しない場合) は、必ずブロックの最後でサインアウトしてください。

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

  # Do something while logged in

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

管理者アクセスが必要なタグテスト

管理者アクセスを必要とするテストを本番環境に対して実行することはありません。

管理者アクセスを必要とするテストを新規に追加する場合は、RSpec メタデータ:requires_admin を適用して、Production 環境やその他のテストを実行したくない環境に対して実行されるテストスイートに、そのテストが含まれないようにします。

ローカルでテストを実行する場合やパイプラインを設定する場合は、環境変数QA_CAN_TEST_ADMIN_FEATURESfalse に設定することで、:requires_admin タグを持つテストをスキップすることができます。

note
テストの中で管理者のアクセス権限が必要なアクションが機能フラグを切り替える_ことだけ_である場合は、代わりにfeature_flag タグを使用してください。詳細は機能フラグを使ったテストにあります。

Commit リソースを優先してください。ProjectPush

APIの使用と同様に、可能な限りCommit のリソースを使用してください。

ProjectPush Git コマンドラインインターフェイス(CLI) を使った生の Shell コマンドを使うのに対して、Commit リソースは HTTP リクエストを行います。

# Using a commit resource
Resource::Repository::Commit.fabricate_via_api! do |commit|
  commit.commit_message = 'Initial commit'
  commit.add_files([
    { file_path: 'README.md', content: 'Hello, GitLab' }
  ])
end

# Using a ProjectPush
Resource::Repository::ProjectPush.fabricate! do |push|
  push.commit_message = 'Initial commit'
  push.file_name = 'README.md'
  push.file_content = 'Hello, GitLab'
end

ProjectPush を使う例外としては、SSH のインテグレーションや Git CLI をテストする場合などがあります。

要素をぼかすための望ましい方法

要素をぼかすには、テスト状態を変更しない別の要素を選択するのが好ましい方法です。いくつかのドロップダウンで発生する可能性があるように、ページ要素をブロックするマスクがある場合、WebDriver のネイティブマウスイベントを使用して、要素の座標上のクリックイベントをシミュレートします。次のメソッドを使用してください:click_element_coordinates.

ビューポートの中心をクリックするため、入力やドロップダウンなどのぼかし要素のbody をクリックすることは避けてください。このアクションは、意図せずに他の要素をクリックし、テストの状態を変更して失敗させる可能性もあります。

# Clicking another element to blur an input
def add_issue_to_epic(issue_url)
  find_element(:issue_actions_split_button).find('button', text: 'Add an issue').click
  fill_element(:add_issue_input, issue_url)
  # Clicking the title blurs the input
  click_element(:title)
  click_element(:add_issue_button)
end

# Using native mouse click events in the case of a mask/overlay
click_element_coordinates(:title)

expect ステートメントが効率的に待機するようにします。

一般的に、expect ステートメントを使用して、期待_通りか_どうかをチェックします。例えば

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

待つ必要のある期待値にはeventually_ マッチャーを使います。

マッチングに待ち時間が必要な場合は、待ち時間を明確に定義したeventually_ マッチャーを使用してください。

Eventually matchers は以下の命名パターンを使います: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 の組み込み matcher は否定可能で、Capybara の matcher も否定可能です。

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

残念ながら、ページオブジェクトに追加する述語メソッドでは、自動的にそうなるわけではありません。否定可能なマッチャを独自に作成する必要があります。

最初の例では、Page::Project::Pipeline::Show ページオブジェ ク ト](https://gitlab.com/gitlab-org/gitlab/-/blob/87864b3047c23b4308f59c27a3757045944af447/qa/qa/page/project/pipeline/show.rb#L53) の[has_job? 述語 メ ソ ッ ド か ら 派生 し たhave_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

そして、次の例の2つの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 で否定可能なカスタムマッチャーを作成しています。

note
テストフレームワークに追加した述語メソッドに対してのみ、not_to を使用する場合のみ、否定可能なマッチャを作成する必要があります。to have_no_* を使用する場合、否定可能なマッチャーは必要ありませんが、コードの可読性を高めます。

否定可能なマッチャが必要な理由

以下のコードを考えてみましょう。ただし、have_job に対して否定可能なマッチャーをカスタムで用意していないと仮定します_。_

# Bad
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')falseを返す前に、'a job' が現れるまで最大10秒待つことです。想定される条件では、このテストは必要以上に10秒長くかかります。

その代わりに、待ち時間を発生させないようにすることもできます:

# Not as bad but potentially flaky
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 を使用してマッチャを否定することは、一般的でよく知られた習慣だからです。否定可能なマッチャを追加することで、そのような慣行を容易にすれば、後続のテスト作成者が効率的なテストを書きやすくなります。

puts よりも logger を使いましょう

現在、GitLab QAアプリケーションとエンドツーエンドテストの両方でログを処理するためにRailslogger を使っています。これは、puts と比較して、以下のような追加機能を提供します:

  • ロギングレベルを指定する機能。
  • 類似ログにタグを付ける機能。
  • ログメッセージの自動フォーマット