GitLab QA의 리소스 클래스

리소스는 주로 브라우저 UI 단계를 사용하여 생성되지만, API나 CLI를 통해서도 생성할 수 있습니다.

리소스 클래스를 올바르게 구현하는 방법은?

모든 리소스 클래스는 Resource::Base에서 상속받아야 합니다.

리소스 클래스를 정의하기 위해 구현해야 할 필수 메서드는 하나만 있습니다.

이것은 리소스를 브라우저 UI를 통해 구축하는 데 사용되는 #fabricate! 메서드입니다. 이 메서드에서 웹 페이지와 상호작용하기 위해서는 오직 페이지 객체만 사용해야 합니다.

여기 상상의 예가 있습니다:

module QA
  module Resource
    class Shirt < Base
      attr_accessor :name

      def fabricate!
        Page::Dashboard::Index.perform do |dashboard_index|
          dashboard_index.go_to_new_shirt
        end

        Page::Shirt::New.perform do |shirt_new|
          shirt_new.set_name(name)
          shirt_new.create_shirt!
        end
      end
    end
  end
end

API 구현 정의하기

리소스 클래스는 공용 GitLab API를 통해 리소스를 생성할 수 있도록 다음 세 가지 메서드도 구현할 수 있습니다:

  • #api_get_path: 기존 리소스를 가져오기 위한 GET 경로.
  • #api_post_path: 새로운 리소스를 생성하기 위한 POST 경로.
  • #api_post_body: 새로운 리소스를 생성하기 위한 POST 본문(루비 해시 형식).

많은 API 리소스가 페이지네이션(pagination)되어 있다는 점에 유의하세요.

예상하는 결과를 찾지 못하면 결과가 여러 페이지일 수 있는지 확인하세요.

Shirt 리소스 클래스를 가져와서 이 세 가지 API 메서드를 추가해 봅시다:

module QA
  module Resource
    class Shirt < Base
      attr_accessor :name

      def fabricate!
        # ... 이전과 동일
      end

      def api_get_path
        "/shirt/#{name}"
      end

      def api_post_path
        "/shirts"
      end

      def api_post_body
        {
          name: name
        }
      end
    end
  end
end

Project 리소스는 브라우저 UI와 API 구현의 좋은 실제 예입니다.

리소스 속성

리소스는 먼저 다른 리소스가 존재해야 할 수 있습니다. 예를 들어, 프로젝트는 그룹이 생성되어야 합니다.

리소스 속성을 정의하기 위해서는 다른 리소스 클래스를 사용하여 리소스를 생성하는 블록과 함께 attribute 메서드를 사용할 수 있습니다.

그러면 리소스 객체의 메서드에서 다른 리소스에 접근할 수 있습니다. 일반적으로 #fabricate!, #api_get_path, #api_post_path, #api_post_body에서 사용합니다.

Shirt 리소스 클래스를 가져와서 project 속성을 추가해 봅시다:

module QA
  module Resource
    class Shirt < Base
      attr_accessor :name

      attribute :project do
        Project.fabricate! do |resource|
          resource.name = 'project-to-create-a-shirt'
        end
      end

      def fabricate!
        project.visit!

        Page::Project::Show.perform do |project_show|
          project_show.go_to_new_shirt
        end

        Page::Shirt::New.perform do |shirt_new|
          shirt_new.set_name(name)
          shirt_new.create_shirt!
        end
      end

      def api_get_path
        "/project/#{project.path}/shirt/#{name}"
      end

      def api_post_path
        "/project/#{project.path}/shirts"
      end

      def api_post_body
        {
          name: name
        }
      end
    end
  end
end

모든 속성은 지연(lazy) 생성됩니다. 특정 속성을 먼저 생성하고자 하는 경우, 사용하지 않더라도 먼저 attribute 메서드를 호출해야 합니다.

제품 데이터 속성

한 번 생성된 후에, 웹 페이지나 API 응답에서 찾을 수 있는 속성으로 리소스를 채우고 싶을 수 있습니다.

예를 들어, 프로젝트를 생성한 후에 그 리포지토리 SSH URL을 속성으로 저장하고 싶을 수 있습니다.

다시 말해, 페이지 객체를 사용하여 페이지에서 데이터를 검색하기 위해 블록과 함께 attribute 메서드를 사용할 수 있습니다.

Shirt 리소스 클래스를 가져와서 :brand 속성을 정의해 봅시다:

module QA
  module Resource
    class Shirt < Base
      attr_accessor :name

      attribute :project do
        Project.fabricate! do |resource|
          resource.name = 'project-to-create-a-shirt'
        end
      end

      # 브라우저 UI에서 채워진 속성 (블록 사용)
      attribute :brand do
        Page::Shirt::Show.perform do |shirt_show|
          shirt_show.fetch_brand_from_page
        end
      end

      # ... 이전과 동일
    end
  end
end

다시 한 번 참고하자면, 모든 속성은 지연 생성됩니다. 이는 shirt.brand를 다른 페이지로 이동한 후에 호출하면, 우리가 예상한 페이지에 더 이상 있지 않기 때문에 데이터를 올바르게 검색하지 않는다는 의미입니다.

다음과 같은 상황을 고려해 보세요:

shirt =
  QA::Resource::Shirt.fabricate! do |resource|
    resource.name = "GitLab QA"
  end

shirt.project.visit!

shirt.brand # => 실패!

위 예제는 이제 프로젝트 페이지에 있으면서 셔츠 페이지에서 브랜드 데이터를 구성하려고 하기 때문에 실패합니다. 그러나 이미 프로젝트 페이지로 이동했습니다. 이를 해결하는 방법은 두 가지가 있으며, 하나는 프로젝트로 다시 방문하기 전에 브랜드를 검색하려고 시도하는 것입니다:

shirt =
  QA::Resource::Shirt.fabricate! do |resource|
    resource.name = "GitLab QA"
  end

shirt.brand # => 성공!

shirt.project.visit!

shirt.brand # => 성공!

속성은 인스턴스에 저장되므로 모든 후속 호출은 이전에 구성된 데이터를 사용하여 잘 작동합니다. 만약 이것이 너무 취약하다고 생각한다면, 데이터 생성을 마치기 전에 데이터를 미리 생성할 수 있습니다:

module QA
  module Resource
    class Shirt < Base
      # ... 이전과 동일

      def fabricate!
        project.visit!

        Page::Project::Show.perform do |project_show|
          project_show.go_to_new_shirt
        end

        Page::Shirt::New.perform do |shirt_new|
          shirt_new.set_name(name)
          shirt_new.create_shirt!
        end

        populate(:brand) # 데이터를 미리 생성합니다
      end
    end
  end
end

populate 메서드는 인자를 반복하며 각 속성을 호출합니다. 여기서 populate(:brand)는 단순히 brand와 동일한 효과를 가집니다. populate 메서드를 사용하면 의도를 더 명확히 할 수 있습니다.

이렇게 하면 셔츠를 생성한 직후에 데이터를 구성하게 됩니다. 단점은 리소스가 생성될 때마다 데이터를 항상 생성하므로 필요하지 않을 때도 있습니다.

대안으로는 브랜드 데이터를 생성하기 전에 올바른 페이지에 있는지 확인할 수 있습니다:

module QA
  module Resource
    class Shirt < Base
      attr_accessor :name

      attribute :project do
        Project.fabricate! do |resource|
          resource.name = 'project-to-create-a-shirt'
        end
      end

      # 브라우저 UI에서 채워진 속성 (블록 사용)
      attribute :brand do
        back_url = current_url
        visit!

        Page::Shirt::Show.perform do |shirt_show|
          shirt_show.fetch_brand_from_page
        end

        visit(back_url)
      end

      # ... 이전과 동일
    end
  end
end

이렇게 하면 브랜드를 구성하기 전에 셔츠 페이지에 있도록 보장하고 상태가 깨지는 것을 피하기 위해 이전 페이지로 돌아갑니다.

API 응답을 기반으로 속성 정의하기

가끔 API 응답을 기반으로 리소스 속성을 정의하고 싶습니다.

GET 또는 POST 요청에서 예를 들어, API를 통해 셔트를 생성할 때 다음과 같은 응답이 반환될 수 있습니다.

{
  brand: 'a-brand-new-brand',
  style: 't-shirt',
  materials: [[:cotton, 80], [:polyamide, 20]]
}

이 경우, style을 리소스에 그대로 저장하고, 첫 번째 materials 항목의 첫 번째 값을 main_fabric 속성에 가져올 수 있습니다.

Shirt 리소스 클래스를 살펴보면, :style:main_fabric 속성을 정의하는 방법은 다음과 같습니다:

module QA
  module Resource
    class Shirt < Base
      # ... 이전과 동일

      # @style 인스턴스에서 존재하는 경우,
      # 또는 API 응답에서 존재하는 경우 가져오고,
      # 그렇지 않으면 QA::Resource::Base::NoValueError가 발생합니다
      attribute :style

      # @main_fabric가 존재하지 않고,
      # API에 이 필드가 포함되어 있지 않으면, 이 블록이
      # API 응답을 기반으로 값을 만들기 위해 사용되며,
      # 결과를 @main_fabric에 저장합니다
      attribute :main_fabric do
        api_response.&dig(:materials, 0, 0)
      end

      # ... 이전과 동일
    end
  end
end

속성 우선순위에 대한 주의 사항:

  • 리소스 인스턴스 변수의 우선순위가 가장 높습니다.
  • API 응답의 속성이 블록(일반적으로 브라우저 UI)에서의 속성보다 우선합니다.
  • 값이 없는 속성은 QA::Resource::Base::NoValueError 오류를 발생시킵니다.

테스트에서 리소스 생성하기

테스트에서 리소스를 생성하기 위해 리소스 클래스에서 .fabricate! 메서드를 호출하거나, 팩토리를 사용하여 생성할 수 있습니다.

리소스 클래스가 API 제작을 지원하는 경우, 기본값으로 이 제작을 사용합니다.

여기 Shirt 리소스 클래스가 지원되기 때문에 API 제작 방법을 사용하는 예가 있습니다:

my_shirt = Resource::Shirt.fabricate! do |shirt|
  shirt.name = 'my-shirt'
end

expect(page).to have_text(my_shirt.name) # => 리소스의 인스턴스 변수에서 "my-shirt"
expect(page).to have_text(my_shirt.brand) # => API 응답에서 "a-brand-new-brand"
expect(page).to have_text(my_shirt.style) # => API 응답에서 "t-shirt"
expect(page).to have_text(my_shirt.main_fabric) # => 블록을 통해 API 응답에서 "cotton"

브라우저 UI 제작 방법을 명시적으로 사용하려면 대신 .fabricate_via_browser_ui! 메서드를 호출할 수 있습니다:

my_shirt = Resource::Shirt.fabricate_via_browser_ui! do |shirt|
  shirt.name = 'my-shirt'
end

expect(page).to have_text(my_shirt.name) # => 리소스의 인스턴스 변수에서 "my-shirt"
expect(page).to have_text(my_shirt.brand) # => 블록을 통해 `Page::Shirt::Show` 페이지에서 가져온 브랜드 이름
expect(page).to have_text(my_shirt.style) # => API 응답 또는 블록이 제공되지 않아 QA::Resource::Base::NoValueError가 발생합니다.
expect(page).to have_text(my_shirt.main_fabric) # => API 응답이 없고 블록이 값을 제공하지 않아 QA::Resource::Base::NoValueError가 발생합니다.

API 제작 방법을 명시적으로 사용하려면 .fabricate_via_api! 메서드를 호출할 수 있습니다:

my_shirt = Resource::Shirt.fabricate_via_api! do |shirt|
  shirt.name = 'my-shirt'
end

이 경우, 결과는 Resource::Shirt.fabricate!를 호출하는 것과 유사합니다.

팩토리

테스트 내에서 리소스를 생성, 빌드 및 가져오기 위해 FactoryBot 호출을 사용할 수 있습니다.

# 테스트에서 사용할 API를 통해 프로젝트 생성
let(:project) { create(:project) }

# 테스트에서 사용할 프로젝트에 속하는 이슈 생성
let(:issue) { create(:issue, project: project) }

# 특정 이름으로 API를 통해 비공식 프로젝트 생성
let(:project) { create(:project, :private, name: 'my-project-name', add_name_uuid: false) }

# 세 가지 작업을 수행하는 프로젝트에서 커밋 하나 생성
let(:commit) do
  create(:commit, commit_message: 'my message', project: project, actions: [
    { action: 'create', file_path: 'README.md', content: '# Welcome!' },
    { action: 'update', file_path: 'README.md', content: '# Updated' },
    { action: 'delete', file_path: 'README.md' }
  ])
end

###

# Issue 인스턴스 생성하지만 API를 통해 아직 생성하지 않음
let(:issue) { build(:issue) }

# 프로젝트 인스턴스를 생성하고 생성 전에 몇 가지 작업 수행
let(:project) do
  build(:project) do |p|
    p.name = 'Test'
    p.add_name_uuid = false
  end
end

# 속성과 함께 API를 통해 기존 이슈 가져오기
let(:existing_issue) { build(:issue, project: project, iid: issue.iid).reload! }

모든 팩토리는 qa/qa/factories에 정의되어 있으며, 각각의 QA::Resource::Base 클래스를 대표합니다.

예를 들어, 팩토리 :issueqa/resource/issue.rb에서 찾을 수 있습니다. 팩토리 :projectqa/resource/project.rb에서 찾을 수 있습니다.

새 팩토리 생성

리소스가 주어졌을 때:

# qa/resource/shirt.rb
module QA
  module Resource
    class Shirt < Base
      attr_accessor :name
      attr_reader :read_only

      attribute :brand

      def api_post_body
        { name: name, brand: brand }
      end
    end
  end
end

기본값과 오버라이드가 있는 팩토리 정의:

# qa/factories/shirts.rb
module QA
  FactoryBot.define do
    factory :shirt, class: 'QA::Resource::Shirt' do
      brand { 'BrandName' }

      trait :with_name do
        name { 'Shirt Name' }
      end
    end
  end
end

테스트에서 API를 통해 리소스를 생성:

let(:my_shirt) { create(:shirt, brand: 'AnotherBrand') } #<Resource::Shirt @brand="AnotherBrand" @name=nil>
let(:named_shirt) { create(:shirt, :with_name) } #<Resource::Shirt @brand="Brand Name" @name="Shirt Name">
let(:invalid_shirt) { create(:shirt, read_only: true) } # NoMethodError

it 'creates a shirt' do
  expect(my_shirt.brand).to eq('AnotherBrand')
  expect(named_shirt.name).to eq('Shirt Name')
  expect(invalid_shirt).to raise_error(NoMethodError) # tries to call Resource::Shirt#read_only=
end

리소스 정리

테스트 실행 중에 생성된 모든 리소스를 수집하는 메커니즘과 이러한 리소스를 처리하는 또 다른 메커니즘이 있습니다. dotcom 환경에서는 QA 파이프라인에서 테스트 스위트가 완료된 후 모든 통과한 테스트의 리소스가 동일한 파이프라인 실행에서 자동으로 삭제됩니다.

모든 실패한 테스트의 리소스는 조사를 위해 예약되며, 다음 토요일에 예정된 파이프라인에 의해 삭제될 때까지 삭제되지 않습니다. 새로운 리소스를 도입할 때, 삭제할 수 없는 모든 리소스를 IGNORED_RESOURCES 목록에 추가하는 것도 잊지 마세요.

도움을 요청할 곳은 어디인가요?

더 많은 정보가 필요하면 Slack의 #test-platform 채널에 도움을 요청하세요
(내부, GitLab 팀만 해당).

팀원이 아닌 경우에도 기여하는 데 도움이 필요하면, ~QA 라벨을 붙여 GitLab CE 이슈 트래커에 이슈를 열어주세요.