GitLab QA의 리소스 클래스

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

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

모든 리소스 클래스는 Resource::Base를 상속해야 합니다.

리소스 클래스를 정의하는 데 구현해야 하는 필수 메서드는 단 하나입니다. 이것은 #fabricate! 메서드이며 브라우저 UI를 통해 리소스를 빌드하는 데 사용됩니다. 이 메서드에서는 웹 페이지와 상호 작용할 때 페이지 객체만 사용해야 합니다.

가상의 예시는 다음과 같습니다:

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 리소스가 페이징됩니다. 예상한 결과를 찾지 못하는 경우 결과가 여러 페이지에 걸쳐 있는지 확인하세요.

이제 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

모든 속성은 게으르게 생성됩니다. 즉, 특정 속성이 먼저 만들어져야 하는 경우에도 사용하지 않더라도 먼저 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 응답을 기반으로 속성 정의

가끔은 `GET` 또는 `POST` 요청에 대한 API 응님을 기반으로 리소스 속성을 정의할 수 있습니다. 예를 들어, API를 통해 셔츠를 생성하면 다음과 같은 응답이 반환됩니다.

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

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

이때 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! 메서드를 호출하거나 factory를 사용할 수 있습니다. 리소스 클래스가 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) }

# 프로젝트에 속한 이슈를 API를 통해 생성하여 테스트에 사용
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

###

# 아직 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 클래스를 대표합니다.

예를 들어, :issue 팩토리는 qa/resource/issue.rb에서 찾을 수 있습니다. :project 팩토리는 qa/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) # Resource::Shirt#read_only=를 호출하려고 시도함
end

```

리소스 정리

우리는 테스트 실행 중에 생성된 모든 리소스를 수집하는 메커니즘과, 이러한 리소스를 처리하는 다른 메커니즘을 갖고 있습니다. 닷컴 환경에서는 QA 파이프라인에서 테스트 스위트가 완료되면 통과한 모든 테스트의 리소스가 동일한 파이프라인 실행에서 자동으로 삭제됩니다. 실패한 테스트에서 생성된 리소스는 조사를 위해 예약되며, 예약된 파이프라인에 의해 다음 토요일까지 삭제되지 않습니다. 새로운 리소스를 도입할 때에는 삭제할 수 없는 모든 리소스를 IGNORED_RESOURCES 목록에 추가하는 것도 잊지 마세요.

도움이 필요한 경우?

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

팀 멤버가 아니고 기여를 돕기 위해 도움이 필요한 경우, ~QA 라벨이 달린 GitLab CE 이슈 트래커에 이슈를 열어주세요.