엔드 투 엔드 테스팅 베스트 프랙티스

이것은 테스팅 가이드에서 발견된 베스트 프랙티스의 맞춤 확장입니다.

클래스 및 모듈 네이밍

QA 프레임워크는 클래스 및 모듈 자동로딩을 위해 Zeitwerk를 사용합니다. 기본 Zeitwerk inflector는 snake_cased 파일 이름을 PascalCased 모듈 또는 클래스 이름으로 변환합니다. 수동으로 인플렉션을 유지하는 것을 피하려면 이 패턴을 따르는 것이 좋습니다.

사용자 정의 인플렉션 로직이 필요한 경우 qa.rb 파일에 사용자 정의 인플렉터가 loader.inflector.inflect 메소드 호출로 추가됩니다.

테스트를 해당 테스트 케이스에 연결

모든 테스트는 GitLab 프로젝트 테스트 케이스에 해당하는 테스트 케이스와 퀄리티 테스트 케이스 프로젝트에 결과 이슈를 가져야 합니다. 테스트 케이스 이슈가 아직 존재하지 않는 경우, GitLab 팀 멤버는 GitLab 프로젝트의 CI/CD > Test cases 페이지에서 임시 제목으로 새 테스트 케이스를 만들 수 있습니다. 테스트 케이스 URL이 코드의 테스트에 연결되면, 보고가 활성화된 파이프라인에서 테스트를 실행하면 report-results 스크립트가 자동으로 테스트 케이스와 결과 이슈를 업데이트합니다. 결과 이슈가 아직 존재하지 않는 경우, report-results 스크립트가 자동으로 이를 생성하고 해당 테스트 케이스에 연결합니다.

코드의 테스트 케이스에 테스트 케이스를 연결하려면 수동으로 testcase RSpec 메타데이터 태그를 추가해야 합니다. 대부분의 경우, 한 테스트가 한 테스트 케이스에 연결됩니다.

예를 들어:

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 파일의 한 줄과 테스트 케이스 간에 일대일 관계가 아닙니다. 이는 몇 가지 이유로 발생합니다:

  • 병렬로 실행되는 테스트.
  • 템플릿화된 테스트.
  • 둘 이상의 예제를 포함하는 공유 예 중에 있는 테스트.

이와 유사한 경우에는 다른 방법으로 테스트 케이스 링크를 포함해야 합니다.

예를 들어, 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

각 공유 예제에 대해 두 개의 연관된 테스트 케이스를 생성하는 것을 권장합니다.

테스트 네이밍

테스트 명은 테스트의 목적을 정의하는 읽기 쉬운 문장을 형성해야 합니다. 테스팅 가이드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. 모든 describe, context, 및 it 블록에는 짧은 설명을 첨부해야 합니다.
  2. 설명을 가능한 한 간결하게 유지하세요.
    1. 긴 설명이나 여러 조건부가 있는 경우 분할해야 할 수 있음을 나타낼 수 있습니다(추가 context 블록).
    2. 문서 작성 스타일 가이드에서 간결하고 능동태로 쓰는 추천 사항을 확인할 수 있습니다.
  3. 가장 바깥쪽 Rspec.describe 블록은 DevOps 단계 이름이어야 합니다.
  4. Rspec.describe 블록 내부에는 테스트되는 기능의 이름을 가진 describe 블록이 있어야 합니다.
  5. 선택 사항인 context 블록은 테스트하는 조건을 나타냅니다.
    1. context 블록 설명은 rubocop 규칙과 일치하도록 ‘when’, ‘with’, ‘without’, ‘for’, ‘and’, ‘on’, ‘in’, ‘as’, 또는 ‘if’로 시작해야 합니다.
  6. it 블록은 테스트의 통과/실패 기준을 설명합니다.
    1. 단일 예제로 공유된 shared_examples인 경우 명명된 it 블록 대신 specify 블록을 사용할 수 있습니다.

UI 대신 API 선호

엔드투엔드 테스트 프레임워크는 경우에 따라 자원을 만들 수 있는 능력을 갖추고 있습니다. 가능한 경우 리소스는 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를 선호

테스트 케이스 내에 여러 기대사항이 있어야 하는 경우 aggregate_failures를 사용하는 것이 좋습니다.

이를 통해 테스트가 첫 번째 실패 시 중단되는 대신 모든 실패를 한데 묶어 볼 수 있습니다.

예를 들어:

#=> 좋음
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

#=> 나쁨
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 '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

#=> 나쁨
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 블록에서 여러 동작을 피하세요

하나의 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

그리고 메소드 내부에는 다음과 같은 내용이 포함됩니다:

#=> 좋음
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 변수 vs 인스턴스 변수

기본적으로 let이나 인스턴스 변수를 사용할 때 테스트 최적의 방법을 따르세요. 그러나 엔드 투 엔드 테스트의 경우, 리소스 생성과 같은 설정은 비용이 많이 드는 경우가 많습니다. 만약 리소스를 공유할 수 있다면, 각 예제마다 생성될 것을 고려하여 let 대신 before(:all) 블록에서 인스턴스 변수를 사용하여 실행 시간을 절약하세요. 변수가 여러 예제에서 공유될 수 없는 경우 let을 사용하세요.

before(:context)after 후크에서 UI 사용 제한

before(:context) 후크의 사용을 API 호출만, 비 UI 동작 또는 로그인과 같은 기본적인 UI 동작을 수행하는 것으로 제한하세요.

우리는 capybara-screenshot 라이브러리를 사용하여 실패 시 화면 샷을 자동으로 저장합니다.

capybara-screenshotRSpect의 after 후크에 화면 샷을 저장합니다. 만약 before(:context)에서 실패하는 경우, after 후크가 호출되지 않습니다 그래서 화면 샷이 저장되지 않습니다.

이와 유사하게, 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

관리자 액세스가 필요한 태그 테스트하기

우리는 관리자 액세스가 필요한 테스트를 우리의 프로덕션 환경에서 실행하지 않습니다.

관리자 액세스가 필요한 새로운 테스트를 추가할 때에는 RSpec 메타데이터 :requires_admin을 적용하여 해당 테스트가 프로덕션 및 기타 환경에서 실행되지 않도록 테스트 스위트에 포함되지 않도록 합니다.

로컬에서 테스트를 실행하거나 파이프라인을 설정할 때, :requires_admin 태그가 있는 테스트를 건너뛰기 위해 환경 변수 QA_CAN_TEST_ADMIN_FEATURESfalse로 설정할 수 있습니다.

참고: 관리자 액세스가 필요한 테스트의 유일한 작업이 기능 플래그를 토글하는 것인 경우, 대신 feature_flag 태그를 사용하십시오. 더 자세한 내용은 기능 플래그로 테스트하기에서 확인할 수 있습니다.

ProjectPush 대신 Commit 리소스를 선호하기

API 사용에 따라 가능한 경우 Commit 리소스를 사용하십시오.

ProjectPush는 Git 명령줄 인터페이스(CLI)의 원시 쉘 명령을 사용하며, Commit 리소스는 HTTP 요청을 만듭니다.

# 커밋 리소스 사용
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 문이 효율적으로 대기하도록 보장하기

일반적으로 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('숨겨진')
except(page).to have_no_text('숨겨진')

유감스럽게도, 이것은 우리가 테스트 프레임워크에 추가한 술어 메서드에 자동으로 적용되지 않습니다. 페이지 객체에 추가된 우리 자신의 술어 메서드에 대한 부정 가능한 매처를 만들어야 합니다. 자체 부정 가능한 매처를 만들기 위한 것입니다.

초기 예제는 Page::Project::Pipeline::Show 페이지 객체의 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

그리고 다음 예제의 두 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_*를 사용하는 경우에는 부정 가능한 매처가 필요하지 않지만 코드 가독성을 높일 수 있습니다.

네거터블(matchers) 매쳐가 필요한 이유

다음 코드를 고려해보지만, have_job에 대한 사용자 정의된 네거터블(matcher)이 없다고 가정해 봅시다.

# 안 좋은 예
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를 반환하기 때문에 이 테스트는 예상 조건에서 필요 이상으로 10초 더 오래 걸립니다.

그 대신에, 우리는 기다리지 않도록 강제할 수 있습니다.

# 안 좋지 않지만 잠재적으로 부정확함
Page::Project::Pipeline::Show.perform do |pipeline|
  expect(pipeline).not_to have_job('a_job', wait: 0)
end

문제는, 만약 'a_job'이 존재하고 우리가 그것이 사라질 때까지 기다리고 있다면, 이 문은 실패합니다.

사용자 정의된 네거터블(matcher)를 만든다면 어느 문제도 존재하지 않습니다. 왜냐하면 has_no_job? 예측 메서드가 사용되어서 작업이 사라질 때까지만 필요한 시간만큼만 기다리게 될 것이기 때문입니다.

마지막으로, 네거터블(matcher)은 have_no_* 형태의 matchers를 사용하는 것보다 선호됩니다. 왜냐하면 not_to를 사용하여 matcher를 부정하는 것은 흔하고 익숙한 관행이기 때문입니다. 네거터블(matcher)를 추가함으로써 해당 관행을 용이하게 해주면, 후속 테스트 작성자가 효율적인 테스트를 쉽게 작성할 수 있도록 해줍니다.

puts 대신에 로거(logger) 사용하기

우리는 현재 GitLab QA 애플리케이션과 종단 간 테스트 모두에서 Rails logger를 사용하여 로그를 처리합니다. 이는 puts와 비교했을 때 다음과 같은 추가적인 기능을 제공합니다:

  • 로깅 레벨을 지정할 수 있는 기능.
  • 유사한 로그에 태그를 지정할 수 있는 기능.
  • 자동으로 로그 메시지를 형식화하는 기능.