GitLab에서 Rails 마이그레이션 테스트 수행하기

신뢰할 수 있게 Rails 마이그레이션을 확인하려면 데이터베이스 스키마에 대해 테스트해야 합니다.

마이그레이션 테스트를 작성하는 시기

  • 포스트 마이그레이션(/db/post_migrate) 및 백그라운드 마이그레이션 (lib/gitlab/background_migration) 마이그레이션 테스트가 필수적으로 수행되어야 합니다.
  • 만약 마이그레이션이 데이터 마이그레이션이라면 반드시 마이그레이션 테스트가 있어야 합니다.
  • 다른 마이그레이션은 필요한 경우에만 마이그레이션 테스트를 가질 수 있습니다.

우리는 스키마 변경만을 수행하는 포스트 마이그레이션에 대해서는 테스트를 강제로 시행하지 않습니다.

작동 방식

(ee/)spec/migrations/spec/lib/(ee/)background_migrations의 모든 스펙은 자동으로 :migration RSpec 태그로 태깅되어 있습니다. 이 태그는 spec/support/migration.rb 의 사용자 지정 RSpec beforeafter 후크를 활성화합니다. :gitlab_main과 같은 데이터베이스 스키마에 대해 마이그레이션을 수행하는 경우 명시적으로 지정해야 합니다. 예를 들어 :gitlab_ci와 같이 RSpec 태그로: migration: :gitlab_ci. 예제는 spec/migrations/change_public_projects_cost_factor_spec.rb 을 참조하세요.

before 후크는 테스트 중인 마이그레이션이 아직 마이그레이션되지 않은 상태로 이전 마이그레이션으로 되돌립니다.

다시 말해, 우리의 사용자 지정 RSpec 후크는 이전 마이그레이션을 찾고 데이터베이스를 이전 마이그레이션 버전으로 이동시킵니다.

after 후크는 데이터베이스를 up으로 마이그레이션하고 최신 스키마 버전을 복원하여 이 프로세스가 후속 스펙에 영향을 미치지 않도록하며 적절한 격리를 보장합니다.

ActiveRecord::Migration 클래스 테스트하기

ActiveRecord::Migration 클래스(예: 일반 마이그레이션 db/migrate 또는 포스트-마이그레이션 db/post_migrate 등)를 테스트하려면 require_migration! 도우미 메서드를 사용하여 마이그레이션 파일을 로드해야 하며, Rails에 의해 자동으로 로드되지 않기 때문입니다.

예:

require 'spec_helper'

require_migration!

RSpec.describe ...

테스트 도우미

require_migration!

마이그레이션 파일은 Rails에 의해 자동으로 로드되지 않으므로 마이그레이션 파일을 수동으로 로드해야 합니다. 이를 위해 스펙 파일 이름을 기반으로 올바른 마이그레이션 파일을 자동으로 로드할 수 있는 require_migration! 도우미 메서드를 사용할 수 있습니다.

GitLab 14.4 이상에서는 마이그레이션 파일을 로드하기 위해 스키마 버전이 파일 이름에 포함된 스펙 파일에서 require_migration!을 사용할 수 있습니다(예: 2021101412150000_populate_foo_column_spec.rb).

# frozen_string_literal: true

require 'spec_helper'
require_migration!

RSpec.describe PopulateFooColumn do
  ...
end

일부 경우에는 여러 마이그레이션 파일을 로드하기 위해 스펙에 사용해야 할 수 있습니다. 여기서 스펙 파일과 다른 마이그레이션 파일 사이에 패턴이 없습니다. 마이그레이션 파일 이름을 다음과 같이 제공할 수 있습니다:

# frozen_string_literal: true

require 'spec_helper'
require_migration!
require_migration!('populate_bar_column')

RSpec.describe PopulateFooColumn do
  ...
end

table

table 도우미를 사용하여 임시 ActiveRecord::Base 파생 모델을 생성하여 테이블을 만들 수 있습니다. FactoryBot을 사용하여 마이그레이션 스펙에 데이터를 생성해서는 안됩니다. 왜냐하면 마이그레이션이 실행된 후에 응용 프로그램 코드가 변경될 수 있고 이는 테스트를 실패하게할 수 있기 때문입니다. 예를 들어 projects 테이블에 레코드를 생성하려면:

project = table(:projects).create!(id: 1, name: 'gitlab1', path: 'gitlab1')

migrate!

테스트 중인 마이그레이션을 실행하는 데 migrate! 도우미를 사용할 수 있습니다. 이는 마이그레이션을 실행하고 schema_migrations 테이블에서 스키마 버전을 올리는 동작입니다. 이것은 after 후크에서 나머지 마이그레이션을 실행하고 시작할 위치를 알아야하기 때문에 필요합니다. 예:

it 'migrates successfully' do
  # ... 마이그레이션 전 기대

  migrate!

  # ... 마이그레이션 후 기대
end

reversible_migration

reversible_migration 도우미를 사용하여 change 또는 updown 후크 중 하나 또는 둘 다를 갖는 마이그레이션을 테스트할 수 있습니다. 이것은 마이그레이션이 전진되었을 때 응용 프로그램 및 그 데이터 상태가 처음에 마이그레이션이 실행되기 전과 동일해야 함을 테스트합니다. 이 도우미는:

  1. up 마이그레이션 이전의 기대를 실행합니다.
  2. up 마이그레이션을 실행합니다.
  3. after 기대를 실행합니다.
  4. down 마이그레이션을 실행합니다.
  5. 두 번째로 up 기대를 실행합니다.

예:

reversible_migration do |migration|
  migration.before -> {
    # ... 마이그레이션 전 기대
  }

  migration.after -> {
    # ... 마이그레이션 후 기대
  }
end

배포 후 마이그레이션을 위한 사용자 정의 Matcher

우리는 배포 후 마이그레이션에서 백그라운드 마이그레이션이 올바르게 예약되었고 올바른 인수의 수를 받았는지를 확인하는데 사용되는 spec/support/matchers/background_migrations_matchers.rb 가 있습니다.

모두 be_background_migration_with_arguments이라는 내부 Matcher를 사용하는데, 이 Matcher는 주어진 인수를 받아들일 때 마이그레이션 클래스의 #perform 메소드가 충돌하지 않았는지 확인합니다.

be_scheduled_migration

예상된 클래스와 인수를 사용하여 Sidekiq 작업이 대기열에 들어갔는지 확인합니다.

이 Matcher는 보통 도우미를 통해가 아니라 수동으로 작업을 대기열에 넣는 경우에 의미가 있습니다.

# 마이그레이션
BackgroundMigrationWorker.perform_async('MigrationClass', args)

# 스펙
expect('MigrationClass').to be_scheduled_migration(*args)

be_scheduled_migration_with_multiple_args

예상된 클래스와 인수를 사용하여 Sidekiq 작업이 대기열에 들어갔는지 확인합니다.

이 Matcher는 be_scheduled_migration과 동일하게 작동하지만, 배열 인수를 비교할 때 순서가 무시됩니다.

# 마이그레이션
BackgroundMigrationWorker.perform_async('MigrationClass', ['foo', [3, 2, 1]])

# 스펙
expect('MigrationClass').to be_scheduled_migration_with_multiple_args('foo', [1, 2, 3])

be_scheduled_delayed_migration

예상된 지연, 클래스 및 인수를 사용하여 Sidekiq 작업이 대기열에 들어갔는지 확인합니다.

이 Matcher는 queue_background_migration_jobs_by_range_at_intervals 및 관련 도우미와 함께 사용할 수도 있습니다.

# 마이그레이션
BackgroundMigrationWorker.perform_in(delay, 'MigrationClass', args)

# 스펙
expect('MigrationClass').to be_scheduled_delayed_migration(delay, *args)

have_scheduled_batched_migration

기대되는 클래스 및 인수로 BatchedMigration 레코드가 생성되었는지 확인합니다.

*argsMigrationClass로 전달된 추가 인수이며, **kwargsBatchedMigration 레코드에서 확인되어야 하는 다른 속성들입니다 (예: interval: 2.minutes).

# 마이그레이션
queue_batched_background_migration(
  'MigrationClass',
  table_name,
  column_name,
  *args,
  **kwargs
)

# 스펙
expect('MigrationClass').to have_scheduled_batched_migration(
  table_name: table_name,
  column_name: column_name,
  job_arguments: args,
  **kwargs
)

be_finalize_background_migration_of

마이그레이션이 finalize_background_migration을 예상된 백그라운드 마이그레이션 클래스로 호출하는지 확인합니다.

# 마이그레이션
finalize_background_migration('MigrationClass')

# 스펙
expect(described_class).to be_finalize_background_migration_of('MigrationClass')

마이그레이션 테스트 예시

마이그레이션 테스트는 마이그레이션이 정확히 무엇을 하는지에 따라 달라집니다. 가장 일반적인 유형은 데이터 마이그레이션 및 백그라운드 마이그레이션 예약입니다.

데이터 마이그레이션 테스트 예시

이 스펙은 db/post_migrate/20200723040950_migrate_incident_issues_to_incident_type.rb 마이그레이션을 테스트합니다. 완전한 스펙은 spec/migrations/migrate_incident_issues_to_incident_type_spec.rb 에서 찾을 수 있습니다.

# frozen_string_literal: true

require 'spec_helper'
require_migration!

RSpec.describe MigrateIncidentIssuesToIncidentType do
  let(:migration) { described_class.new }

  let(:projects) { table(:projects) }
  let(:namespaces) { table(:namespaces) }
  let(:labels) { table(:labels) }
  let(:issues) { table(:issues) }
  let(:label_links) { table(:label_links) }
  let(:label_props) { IncidentManagement::CreateIncidentLabelService::LABEL_PROPERTIES }

  let(:namespace) { namespaces.create!(name: 'foo', path: 'foo') }
  let!(:project) { projects.create!(namespace_id: namespace.id) }
  let(:label) { labels.create!(project_id: project.id, **label_props) }
  let!(:incident_issue) { issues.create!(project_id: project.id) }
  let!(:other_issue) { issues.create!(project_id: project.id) }

  # Issue issue_type enum
  let(:issue_type) { 0 }
  let(:incident_type) { 1 }

  before do
    label_links.create!(target_id: incident_issue.id, label_id: label.id, target_type: 'Issue')
  end

  describe '#up' do
    it '연습 사항을 수정합니다' do
      expect { migrate! }
        .to change { incident_issue.reload.issue_type }
        .from(issue_type)
        .to(incident_type)

      expect(other_issue.reload.issue_type).to eq(issue_type)
    end
  end

  describe '#down' do
    let!(:incident_issue) { issues.create!(project_id: project.id, issue_type: issue_type) }

    it '연습 사항을 수정합니다' do
      migration.up

      expect { migration.down }
        .to change { incident_issue.reload.issue_type }
        .from(incident_type)
        .to(issue_type)

      expect(other_issue.reload.issue_type).to eql(issue_type)
    end
  end
end

백그라운드 마이그레이션 스케줄링 테스트 예시

이를 테스트하려면 보통 다음을 해야합니다:

  • 일부 레코드를 생성합니다.
  • 마이그레이션을 실행합니다.
  • 기대하는 작업이 올바르게 예약되었는지, 올바른 레코드 세트, 올바른 일괄 처리 크기, 간격 등으로 확인합니다.

백그라운드 마이그레이션 자체의 동작은 백그라운드 마이그레이션 클래스에 대한 별도의 테스트에서 확인해야 합니다.

이 명세는 db/post_migrate/20210701111909_backfill_issues_upvotes_count.rb 배포 후 마이그레이션을 테스트합니다. 전체 명세는 spec/migrations/backfill_issues_upvotes_count_spec.rb에서 찾을 수 있습니다.

require 'spec_helper'
require_migration!

RSpec.describe BackfillIssuesUpvotesCount do
  let(:migration) { described_class.new }
  let(:issues) { table(:issues) }
  let(:award_emoji) { table(:award_emoji) }

  let!(:issue1) { issues.create! }
  let!(:issue2) { issues.create! }
  let!(:issue3) { issues.create! }
  let!(:issue4) { issues.create! }
  let!(:issue4_without_thumbsup) { issues.create! }

  let!(:award_emoji1) { award_emoji.create!( name: 'thumbsup', awardable_type: 'Issue', awardable_id: issue1.id) }
  let!(:award_emoji2) { award_emoji.create!( name: 'thumbsup', awardable_type: 'Issue', awardable_id: issue2.id) }
  let!(:award_emoji3) { award_emoji.create!( name: 'thumbsup', awardable_type: 'Issue', awardable_id: issue3.id) }
  let!(:award_emoji4) { award_emoji.create!( name: 'thumbsup', awardable_type: 'Issue', awardable_id: issue4.id) }

  it '백그라운드 마이그레이션을 올바르게 스케줄링합니다.', :aggregate_failures do
    stub_const("#{described_class.name}::BATCH_SIZE", 2)

    Sidekiq::Testing.fake! do
      freeze_time do
        migrate!

        expect(described_class::MIGRATION).to be_scheduled_migration(issue1.id, issue2.id)
        expect(described_class::MIGRATION).to be_scheduled_migration(issue3.id, issue4.id)
        expect(BackgroundMigrationWorker.jobs.size).to eq(2)
      end
    end
  end
end

ActiveRecord::Migration 클래스가 아닌 테스트하기

ActiveRecord::Migration 클래스가 아닌 테스트(백그라운드 마이그레이션)를 테스트하려면, 필요한 스키마 버전을 수동으로 제공해야 합니다. 데이터베이스 스키마를 전환하려는 컨텍스트에 schema 태그를 추가하세요.

설정하지 않은 경우, schema:latest로 기본 설정됩니다.

예제:

describe SomeClass, schema: 20170608152748 do
  # ...
end

백그라운드 마이그레이션 테스트 예시

이 명세는 lib/gitlab/background_migration/backfill_draft_status_on_merge_requests.rb 백그라운드 마이그레이션을 테스트합니다. 전체 명세는 spec/lib/gitlab/background_migration/backfill_draft_status_on_merge_requests_spec.rb에서 찾을 수 있습니다.

# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Gitlab::BackgroundMigration::BackfillDraftStatusOnMergeRequests do
  let(:namespaces)     { table(:namespaces) }
  let(:projects)       { table(:projects) }
  let(:merge_requests) { table(:merge_requests) }

  let(:group)   { namespaces.create!(name: 'gitlab', path: 'gitlab') }
  let(:project) { projects.create!(namespace_id: group.id) }

  let(:draft_prefixes) { ["[Draft]", "(Draft)", "Draft:", "Draft", "[WIP]", "WIP:", "WIP"] }

  def create_merge_request(params)
    common_params = {
      target_project_id: project.id,
      target_branch: 'feature1',
      source_branch: 'master'
    }

    merge_requests.create!(common_params.merge(params))
  end

  context "for MRs with #draft? == true titles but draft attribute false" do
    let(:mr_ids) { merge_requests.all.collect(&:id) }

    before do
      draft_prefixes.each do |prefix|
        (1..4).each do |n|
          create_merge_request(
            title: "#{prefix} This is a title",
            draft: false,
            state_id: n
          )
        end
      end
    end

    it "opens draft merge request's draft field to true" do
      mr_count = merge_requests.all.count

      expect { subject.perform(mr_ids.first, mr_ids.last) }
        .to change { MergeRequest.where(draft: false).count }
              .from(mr_count).to(mr_count - draft_prefixes.length)
    end

    it "marks successful slices as completed" do
      expect(subject).to receive(:mark_job_as_succeeded).with(mr_ids.first, mr_ids.last)

      subject.perform(mr_ids.first, mr_ids.last)
    end
  end
end

이러한 테스트는 데이터베이스 트랜잭션 내에서 실행되지 않습니다. 삭제 데이터베이스 정리 전략을 사용하므로 트랜잭션에 의존하지 마십시오.