GitLab QA의 페이지 오브젝트
GitLab v19.1GitLab QA에서는 Page Objects라고 불리는 잘 알려진 패턴을 사용합니다. 이는 GitLab QA 시나리오를 실행하기 위해 사용하는 GitLab의 모든 페이지에 대한 추상화를 구축했음을 의미합니다. 예를 들어, GitLab QA 테스트 하네스가 GitLab에 로그인할 때 사용자 로그인 정보와 비밀번호를 입력해야 합니다.
GitLab QA에서는 Page Objects라고 불리는 잘 알려진 패턴을 사용합니다.
이는 GitLab QA 시나리오를 실행하기 위해 사용하는 GitLab의 모든 페이지에 대한 추상화를 구축했음을 의미합니다. 폼 작성이나 버튼 선택처럼 페이지에서 무언가를 수행할 때마다, GitLab의 해당 영역과 연결된 페이지 오브젝트를 통해서만 작업을 수행합니다.
예를 들어, GitLab QA 테스트 하네스가 GitLab에 로그인할 때 사용자 로그인 정보와 비밀번호를 입력해야 합니다.
이를 위해 Page::Main::Login 클래스와 sign_in_using_credentials 메서드를 사용하며,
이 코드만이 user-login 및 user-password 필드를 읽는 유일한 코드입니다.
왜 필요한가?#
페이지 오브젝트가 필요한 이유는 코드 중복을 줄이고, 누군가 GitLab 소스 코드의 선택자를 변경했을 때 발생하는 문제를 방지하기 위해서입니다.
GitLab QA에 수백 개의 스펙이 있고, 매번 어서션을 수행하기 전에 GitLab에 로그인해야 한다고 가정해 보세요.
페이지 오브젝트 없이는 불안정한 헬퍼에 의존하거나 Capybara 메서드를 직접 호출해야 합니다.
모든 *_spec.rb 파일/테스트 예제에서 fill_in 'user-login'을 직접 호출하는 상황을 상상해 보세요.
나중에 누군가 이 페이지와 연결된 뷰에서 t.text_field 'login'을 t.text_field 'username'으로 변경하면
다른 필드 식별자가 생성되어 모든 테스트가 실질적으로 중단됩니다.
Page::Main::Login.perform(&:sign_in_using_credentials)를 GitLab 로그인이 필요한 모든 곳에서 사용하기 때문에,
페이지 오브젝트가 단일 진실 공급원(Single Source Of Truth, SSOT)이 되어
fill_in 'user-login'을 fill_in 'user-username'으로 한 곳에서만 수정하면 됩니다.
과거에 어떤 문제가 있었나?#
성능상의 이유와 패키지 빌드 및 전체 테스트에 소요되는 시간 때문에 모든 커밋에 대해 QA 테스트를 실행하지는 않습니다.
그렇기 때문에 누군가 new session 뷰에서 t.text_field 'login'을 t.text_field 'username'으로 변경하더라도,
GitLab QA 야간 파이프라인이 실패하거나 누군가 머지 리퀘스트에서 package-and-qa 액션을 트리거하기 전까지는
이 변경 사항을 알 수 없습니다.
이러한 변경은 모든 테스트를 중단시킵니다. 이 문제를 *취약한 테스트 문제(fragile tests problem)*라고 부릅니다.
GitLab QA를 더 신뢰할 수 있고 견고하게 만들기 위해, GitLab CE/EE 뷰와 GitLab QA 사이에 결합(coupling)을 도입하여 이 문제를 해결해야 했습니다.
취약한 테스트 문제를 어떻게 해결했나?#
현재, 새로운 Page::Base 파생 클래스를 추가할 때 페이지 오브젝트가 의존하는 모든 선택자도 반드시 정의해야 합니다.
CE/EE 리포지터리에 코드를 푸시할 때마다, qa:selectors 정상 테스트 job이 CI 파이프라인의 일부로 실행됩니다.
이 테스트는 qa/page 디렉터리에 구현된 모든 페이지 오브젝트를 검증합니다.
테스트가 실패하면, 누락되었거나 유효하지 않은 뷰/선택자 정의에 대해 알려줍니다.
페이지 오브젝트를 올바르게 구현하는 방법?#
페이지 오브젝트와 실제로 구현된 GitLab 뷰 사이의 결합을 정의하기 위한 DSL을 구축했습니다. 아래 예시를 참고하세요.
module Page
module Main
class Login < Page::Base
view 'app/views/devise/passwords/edit.html.haml' do
element 'password-field'
element 'password-confirmation'
element 'change-password-button'
end
view 'app/views/devise/sessions/_new_base.html.haml' do
element 'login-field'
element 'password-field'
element 'sign-in-button'
end
# ...
end
end
end
엘리먼트 정의하기#
view DSL 메서드는 엘리먼트를 렌더링하는 Rails 뷰, 파셜(partial), 또는 Vue 컴포넌트에 해당합니다.
element DSL 메서드는 뷰 파일에 아직 추가되지 않은 경우, 해당 엘리먼트에 대한 testid=element-name 데이터 속성을 반드시 추가해야 하는 엘리먼트를 선언합니다.
실제 뷰 코드와 매칭할 값(String 또는 Regexp)을 정의할 수도 있지만, 이는 더 이상 권장되지 않으며(deprecated) 다음 두 가지 이유로 위의 방법이 권장됩니다:
-
일관성: 엘리먼트를 정의하는 방법이 오직 하나뿐입니다.
-
관심사 분리: 테스트는 다른 컴포넌트에서 사용하는 코드나 클래스(예:
js-*클래스 등)를 재사용하는 대신 전용data-testid속성을 사용합니다.
view 'app/views/my/view.html.haml' do
### Good ###
# Implicitly require the CSS selector `[data-testid="logout-button"]` to be present in the view
element 'logout-button'
### Bad ###
## This is deprecated and forbidden by the `QA/ElementWithPattern` RuboCop cop.
# Require `f.submit "Sign in"` to be present in `my/view.html.haml
element :my_button, 'f.submit "Sign in"' # rubocop:disable QA/ElementWithPattern
## This is deprecated and forbidden by the `QA/ElementWithPattern` RuboCop cop.
# Match every line in `my/view.html.haml` against
# `/link_to .* "My Profile"/` regexp.
element :profile_link, /link_to .* "My Profile"/ # rubocop:disable QA/ElementWithPattern
end
뷰에 엘리먼트 추가하기#
다음과 같은 엘리먼트가 있다고 가정하면...
view 'app/views/my/view.html.haml' do
element 'login-field'
element 'password-field'
element 'sign-in-button'
end
이 엘리먼트들을 뷰에 추가하려면, 정의된 각 엘리먼트에 대해 data-testid 속성을 추가하여 Rails 뷰, 파셜, 또는 Vue 컴포넌트를 수정해야 합니다.
이 경우, data-testid="login-field", data-testid="password-field", data-testid="sign-in-button"을 추가합니다.
app/views/my/view.html.haml
= f.text_field :login, class: "form-control top", autofocus: "autofocus", autocapitalize: "off", autocorrect: "off", required: true, title: "This field is required.", data: { testid: 'login_field' }
= f.password_field :password, class: "form-control bottom", required: true, title: "This field is required.", data: { testid: 'password_field' }
= f.submit "Sign in", class: "btn btn-confirm", data: { testid: 'sign_in_button' }
주의사항:
-
엘리먼트 이름과
data-testid는 반드시 일치해야 하며 케밥 케이스(kebab case)로 작성해야 합니다. -
엘리먼트가 페이지에 무조건적으로 나타나는 경우, 엘리먼트에
required: true를 추가하세요. 동적 엘리먼트 유효성 검사를 참고하세요. -
페이지 오브젝트에
data-qa-selector클래스가 있어서는 안 됩니다.data-testid정의 방식을 사용해야 합니다.
data-testid vs data-qa-selector#
히스토리
- GitLab 16.1에서 도입됨.
기존의 모든 data-qa-selector 클래스는 더 이상 권장되지 않는(deprecated) 것으로 간주하며,
data-testid 정의 방식을 사용해야 합니다.
동적 엘리먼트 선택#
자동화 테스트에서 자주 발생하는 상황은 "여러 항목 중 하나(one-of-many)"인 단일 엘리먼트를 선택하는 것입니다. 여러 항목 목록에서 선택하는 항목을 어떻게 구분할 수 있을까요? 가장 일반적인 해결책은 텍스트 매칭을 이용하는 것입니다. 하지만 더 나은 방법은 텍스트가 아닌 고유 식별자로 특정 엘리먼트를 매칭하는 것입니다.
이 문제는 확장 가능한 data-qa-* 선택 메커니즘을 추가하여 해결했습니다.
예시#
예시 1
다음 Rails 뷰가 있다고 가정합니다(GitLab Issues를 예시로 사용):
%ul.issues-list
- @issues.each do |issue|
%li.issue{data: { testid: 'issue', qa_issue_title: issue.title } }= link_to issue
Rails 모델을 매칭하여 특정 이슈를 선택할 수 있습니다.
class Page::Project::Issues::Index < Page::Base
def has_issue?(issue)
has_element?(:issue, issue_title: issue)
end
end
테스트에서 특정 이슈가 존재하는지 검증할 수 있습니다.
describe 'Issue' do
it 'has an issue titled "hello"' do
Page::Project::Issues::Index.perform do |index|
expect(index).to have_issue('hello')
end
end
end
예시 2
인덱스로 선택하기...
%ol
- @some_model.each_with_index do |model, idx|
%li.model{ data: { testid: 'model', qa_index: idx } }
expect(the_page).to have_element(:model, index: 1) #=> select on the first model that appears in the list
예외 사항#
일부 경우에는 선택자를 추가하는 것이 불가능하거나 가치가 없을 수 있습니다.
일부 UI 컴포넌트는 서드파티가 유지 관리하는 것을 포함한 외부 라이브러리를 사용합니다. 라이브러리가 GitLab에서 유지 관리되더라도, 선택자 정상 테스트는 GitLab 프로젝트 내의 코드에서만 실행되므로 라이브러리 코드의 뷰 경로를 지정하는 것은 불가능합니다.
이러한 드문 경우에는 element를 추가할 수 없는 이유를 설명하는 주석과 함께
페이지 오브젝트 메서드에 CSS 선택자를 사용하는 것이 합리적입니다.
페이지 Concern 정의하기#
일부 페이지는 공통 동작을 공유하거나, EE 전용 메서드를 추가하는 EE 전용 모듈이 앞에 추가(prepend)됩니다.
이러한 모듈은 반드시:
-
extend QA::Page::PageConcern을 사용하여QA::Page::PageConcern모듈을 확장해야 합니다. -
다른 모듈을 직접
include/prepend하거나view또는elements를 정의해야 하는 경우,self.prepended메서드를 오버라이드해야 합니다. -
self.prepended에서 가장 먼저super를 호출해야 합니다. -
다른 모듈을 include/prepend하고
view/elements를base.class_eval블록 안에 정의하여 모듈을 prepend하는 클래스에서 이들이 정의되도록 해야 합니다.
이 단계들은 정상 선택자 검사가 문제를 올바르게 감지하도록 보장합니다.
예를 들어, qa/qa/ee/page/merge_request/show.rb는
(QA::Page::MergeRequest::Show.prepend_mod_with('Page::MergeRequest::Show', namespace: QA)를 통해)
qa/qa/page/merge_request/show.rb에 EE 전용 메서드를 추가하며, 다음은 구현 방식입니다
(관련 부분만 표시하고 위에서 설명한 4단계를 인라인 주석으로 참조):
module QA
module EE
module Page
module MergeRequest
module Show
extend QA::Page::PageConcern # 1.
def self.prepended(base) # 2.
super # 3.
base.class_eval do # 4.
prepend Page::Component::LicenseManagement
view 'app/assets/javascripts/vue_merge_request_widget/components/states/sha_mismatch.vue' do
element 'head-mismatch', "The source branch HEAD has recently changed."
end
[...]
end
end
end
end
end
end
end
로컬에서 테스트 실행하기#
개발 중에는 qa 디렉터리 내에서 다음 명령을 실행하여 qa:selectors 테스트를 실행할 수 있습니다.
bin/qa Test::Sanity::Selectors
도움이 필요한 경우#
추가 정보가 필요하면 Slack의 #s_developer_experience 채널에서 도움을 요청하세요(내부용, GitLab 팀 전용).
GitLab 팀원이 아닌데 기여를 위해 도움이 필요하다면, ~QA 라벨을 붙여 GitLab 이슈 트래커에 이슈를 열어주세요.