End-to-end 테스트 Best Practices

이것은 테스트 가이드에서 찾을 수 있는 Best Practices의 맞춤형 확장입니다.

클래스 및 모듈 명명

QA 프레임워크는 클래스 및 모듈 자동로딩을 위해 Zeitwerk를 사용합니다. 기본 Zeitwerk 무정규화기는 snake_cased 파일 이름을 PascalCased 모듈 또는 클래스 이름으로 변환합니다. 매뉴얼 무정규화의 유지보수를 피하기 위해 이 패턴을 준수하는 것이 좋습니다.

특정한 무정규화 논리가 필요한 경우, 사용자 정의 무정규화기는 loader.inflector.inflect 메서드 호출에서 qa.rb 파일에 추가됩니다.

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

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

코드의 테스트 케이스에 링크하려면 매뉴얼으로 testcase RSpec 메타데이터 태그를 추가해야 합니다. 대부분의 경우, 단일 테스트는 단일 테스트 케이스와 연관됩니다.

예: ```ruby 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/the RSpec naming guide의 입력을 함께 명확히 합니다.

권장 접근

다음 블록은 Plan wiki content creation in a project adds a home page라는 이름의 테스트를 생성합니다.

# `RSpec.describe`는 커버되는 DevOps Stage입니다.
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. Documentation Style Guide는 간결하고 적극적인 어조로 어떻게 작성해야 하는지에 대한 권장 사항을 제공합니다.
  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 선호

End-to-end 테스트 프레임워크는 경우별로 리소스를 프로덕션할 수 있는 능력을 갖고 있습니다. 리소스는 가능한 경우 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를 사용하는 것이 좋습니다.

이를 통해 테스트가 첫 번째 실패 시 중단되는 대신 모든 실패 사항을 한눈에 볼 수 있습니다.

예를 들어:

#=> 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 블록에서 복수의 작업을 피합니다

하나의 expect do ... end.not_to raise_error 또는 expect do ... end.to raise_error 블록에 여러 작업을 래핑하는 경우, 로그가 출력되는 방식으로 인해 실제 실패 원인을 디버깅하기 어려울 수 있습니다. 중요한 정보가 축소되거나 완전히 빠진 경우가 있습니다.

예를 들어, 테스트 내의 비공개 메서드에 일부 작업과 예상 사항을 캡슐화할 때:

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

테스트를 여러 파일로 분할하는 것을 선호합니다

저희 프레임워크에는 여러 개의 병렬화 메커니즘이 포함되어 있어서 스펙 파일을 병렬로 실행함으로써 작동합니다.

그러나 테스트는 스펙 파일별로 병렬화되기 때문에 새로운 테스트가 기존 파일에 추가되면 더 큰 병렬화를 달성할 수 없습니다.

그럼에도 불구하고 기존 파일에 새로운 테스트를 추가해야 하는 경우도 있을 수 있습니다.

예를 들어, 테스트가 설정하는 상태를 공유하는 경우, 설정을 한 번만 수행하는 것이 더 효율적이기 때문에 사용할 수 있습니다.

요약하면:

  • 하십시오: 테스트를 별도의 파일로 분할하십시오, 단 테스트가 비싼 설정을 공유한다면.
  • 하지 마십시오: 병렬화에 대한 영향을 고려하지 않고 새로운 테스트를 기존 파일에 넣지 마십시오.

let 변수 대 인스턴스 변수

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

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

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

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

capybara-screenshotRSpec의 after 후크에 스크린샷을 저장하며, if before(:context)에서 실패하는 경우 after 후크가 호출되지 않습니다와 같이 스크린샷이 저장되지 않습니다.

따라서 before(:context)의 사용을 스크린샷이 필요하지 않은 작업에만 제한해야 합니다.

마찬가지로, 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

관리자 액세스가 필요한 테스트에 태그 추가

운영 환경에서 관리자 액세스가 필요한 테스트를 실행하지 않습니다.

새로운 관리자 액세스가 필요한 테스트를 추가할 때 테스트가 우리가 해당 테스트를 실행하고 싶지 않은 운영 환경 및 기타 환경에서 실행되지 않도록 :requires_admin RSpec 메타데이터를 적용하세요.

로컬로 테스트를 실행하거나 파이프라인을 구성할 때, QA_CAN_TEST_ADMIN_FEATURES 환경 변수를 false로 설정하여 :requires_admin 태그가 있는 테스트를 건너뛸 수 있습니다.

note
테스트에 필요한 행동이 피처 플래그를 토글하는 것만 필요한 경우, feature_flag 태그를 대신 사용하세요. 자세한 내용은 피처 플래그로 테스트를 참조하세요.

Commit 리소스를 ProjectPush 대신 선호합니다

API 사용하기 원칙에 따라 가능한 경우 Commit 리소스를 사용하세요.

ProjectPush는 Git 명령줄 인터페이스(CLI)의 원시 쉘 명령을 사용하며, 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

일부 예외는 테스트에서 SSH 통합을 테스트하거나 Git CLI를 사용할 때입니다.

요소를 흐리게 하는 선호하는 방법

요소를 흐리게 하려면, 선호하는 방법은 텍스트 상태를 변경하지 않는 다른 요소를 선택하는 것입니다. 드롭다운과 같이 페이지 요소를 가리는 마스크가 있는 경우 WebDriver의 기본 마우스 이벤트를 사용하여 요소의 좌표에서 클릭 이벤트를 모방합니다. 다음 방법을 사용하세요: click_element_coordinates.

입력란이나 드롭다운과 같은 요소를 흐리게 하기 위해 body를 클릭하는 것을 피하십시오. 이는 뷰포트의 중앙을 클릭하기 때문에 오작동을 일으키고 테스트 상태를 변경하여 실패로 이어질 수 있습니다.

# 입력란을 흐리게 하기 위해 다른 요소를 클릭합니다
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)
  # 제목을 클릭하여 입력란을 흐리게 합니다
  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('hidden')
except(page).to have_no_text('hidden')

불행하게도 같은 맥락에서 사용하는 우리의 페이지 오브젝트들에 대한 술어 메서드(predicate method)들은 자동으로 그렇지 않습니다. 우리는 직접 부정 가능한 매처를 만들어야 합니다.

초기 예제는 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에서 사용자 정의 부정 가능한 매처를 만들고 있습니다.

note
우리는 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'이 나타나기 전까지 최대 열 초 동안 기다려야 하므로 이 테스트는 필요 이상으로 열 초 동안 더 걸릴 것입니다.

대신에 우리는 기다림 없이 테스트할 수 있습니다:

# 그렇지만 안 좋지 않으나 잠재적으로 취약함
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 애플리케이션과 종단 간 테스트에서 로그를 처리하기 위해 Rails logger를 사용하고 있습니다. 이는 puts와 비교했을 때 추가 기능을 제공합니다.

  • 로깅 레벨 지정 가능
  • 유사한 로그를 태그 지정 가능
  • 자동 포맷 로그 메시지생성