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이 아닌 다른 데이터베이스 스키마에 대해 마이그레이션을 수행하는 경우 migration: :gitlab_ci와 같이 RSpec 태그를 명시적으로 지정해야 합니다. spec/migrations/change_public_projects_cost_factor_spec.rb에서 예를 확인하세요.

before 훅은 테스트 중인 마이그레이션이 아직 마이그레이션되지 않은 상태로 모든 마이그레이션을 되돌립니다.

즉, 사용자 정의 RSpec 훅이 이전 마이그레이션을 찾아서 데이터베이스를 이전 마이그레이션 버전으로 다운 마이그레이션합니다.

이 접근 방식을 사용하면 데이터베이스 스키마에 대해 마이그레이션을 테스트할 수 있습니다.

after 훅은 데이터베이스를 마이그레이션하고 최신 스키마 버전을 복원하므로 프로세스가 후속 사양에 영향을 미치지 않고 적절한 격리를 보장합니다.

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

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

예:

require 'spec_helper'

require_migration!

RSpec.describe ...

테스트 헬퍼

require_migration!

마이그레이션 파일이 Rails에 의해 자동으로 로드되지 않기 때문에 마이그레이션 파일을 수동으로 로드해야 합니다. 이를 위해 require_migration! 헬퍼 메서드를 사용할 수 있으며, 이는 스펙 파일 이름에 따라 올바른 마이그레이션 파일을 자동으로 로드할 수 있습니다.

스펙 파일 이름에 스키마 버전이 포함된 마이그레이션 파일을 로드하는 데 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!(name: 'gitlab1', path: 'gitlab1')

migrate!

migrate! 헬퍼를 사용하여 테스트 중인 마이그레이션을 실행합니다.

이 헬퍼는 마이그레이션을 실행하고 schema_migrations 테이블에서 스키마 버전을 증가시킵니다.

이것은 after 훅에서 나머지 마이그레이션을 실행해야 하고, 어디서 시작해야 할지 알아야 하므로 필요합니다. 예시:

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

  migrate!

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

reversible_migration

reversible_migration 헬퍼를 사용하여 change 또는 updown 훅 모두가 있는 마이그레이션을 테스트합니다.

이것은 마이그레이션 후 애플리케이션 및 데이터의 상태가 처음 마이그레이션이 실행되기 전과 동일한지 확인합니다. 헬퍼:

  1. up 마이그레이션 전에 before 기대 사항을 실행합니다.
  2. up으로 마이그레이션 합니다.
  3. after 기대 사항을 실행합니다.
  4. down으로 마이그레이션 합니다.
  5. 두 번째로 before 기대 사항을 실행합니다.

예시:

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

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

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

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

모든 매처는 내부 매처인 be_background_migration_with_arguments를 사용하여 여러분의 마이그레이션 클래스에서 #perform 메서드가 제공된 인수를 수신할 때 충돌하지 않는지 확인합니다.

be_scheduled_migration

예상된 클래스와 인수로 Sidekiq 작업이 대기열에 추가되었는지를 확인합니다.

이 매처는 일반적으로 우리의 헬퍼를 통해 작업을 호출하는 대신 수동으로 작업을 대기열에 추가할 때 의미가 있습니다.

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

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

be_scheduled_migration_with_multiple_args

예상된 클래스와 인수로 Sidekiq 작업이 대기열에 추가되었는지를 확인합니다.

이것은 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 작업이 대기열에 추가되었는지를 확인합니다.

이것은 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가 예상된 백그라운드 마이그레이션 클래스를 호출하는지 확인합니다.

# Migration
finalize_background_migration('MigrationClass')

# Spec
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 'updates the incident issue type' 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 'updates the incident issue type' 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 'correctly schedules background migrations', :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 "MRs에 대해 #draft?가 true인 제목이지만 draft 속성이 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 "모든 열린 드래프트 병합 요청의 드래프트 필드를 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 "성공적으로 완료된 슬라이스를 완료로 표시합니다" 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

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

deletion_except_tables에 시드 데이터를 변경하는 마이그레이션을 테스트할 때는, :migration_with_transaction 메타데이터를 추가하여 테스트가 트랜잭션 내에서 실행되고 데이터가 원래 값으로 롤백되도록 할 수 있습니다.