InfoGrab DocsInfoGrab Docs

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

요약

Rails 마이그레이션을 안정적으로 확인하려면 데이터베이스 스키마를 대상으로 마이그레이션을 테스트해야 합니다. 스키마 변경만 수행하는 포스트 마이그레이션에는 테스트를 강제하지 않습니다. (ee/)spec/migrations/와 spec/lib/(ee/)background_migrations의 모든 스펙에는 :migration RSpec 태그가 자동으로 붙습니다.

Rails 마이그레이션을 안정적으로 확인하려면 데이터베이스 스키마를 대상으로 마이그레이션을 테스트해야 합니다.

마이그레이션 테스트 작성 시점#

  • 포스트 마이그레이션(/db/post_migrate)과 백그라운드 마이그레이션 (lib/gitlab/background_migration)은 반드시 마이그레이션 테스트를 수행해야 합니다.
  • 마이그레이션이 데이터 마이그레이션이면 반드시 마이그레이션 테스트가 있어야 합니다.
  • 그 외 마이그레이션은 필요한 경우 마이그레이션 테스트를 둘 수 있습니다.

스키마 변경만 수행하는 포스트 마이그레이션에는 테스트를 강제하지 않습니다.

동작 방식#

(ee/)spec/migrations/와 spec/lib/(ee/)background_migrations의 모든 스펙에는 :migration RSpec 태그가 자동으로 붙습니다. 이 태그는 spec/support/migration.rb에 정의된 커스텀 RSpec before·after 훅을 실행시킵니다. :gitlab_main이 아닌 다른 데이터베이스 스키마(예: :gitlab_ci)를 대상으로 마이그레이션을 수행한다면 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! 헬퍼 메서드를 사용할 수 있습니다.

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

# 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을 사용하지 않습니다. FactoryBot은 마이그레이션 실행 이후 바뀔 수 있는 애플리케이션 코드에 의존하므로 테스트가 실패할 수 있습니다. 예를 들어 projects 테이블에 레코드를 만들 때는 다음과 같이 작성합니다.

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

migrate!#

migrate! 헬퍼로 테스트 대상 마이그레이션을 실행합니다. 이 헬퍼는 마이그레이션을 실행하고 schema_migrations 테이블의 스키마 버전을 올립니다. after 훅에서 나머지 마이그레이션을 실행하므로 어디서부터 시작해야 하는지 알아야 하기 때문에 필요합니다. 예시:

it 'migrates successfully' do
  # ... pre-migration expectations

  migrate!

  # ... post-migration expectations
end

reversible_migration#

reversible_migration 헬퍼로 change 훅을 쓰거나 up과 down 훅을 모두 쓰는 마이그레이션을 테스트합니다. 이 헬퍼는 마이그레이션을 되돌린 뒤의 애플리케이션과 데이터 상태가 마이그레이션 실행 전과 같은지 확인합니다. 헬퍼의 동작은 다음과 같습니다.

  1. 업 마이그레이션 전에 before 기대값을 실행합니다.
  2. 업 마이그레이션을 수행합니다.
  3. after 기대값을 실행합니다.
  4. 다운 마이그레이션을 수행합니다.
  5. before 기대값을 다시 한 번 실행합니다.

예시:

reversible_migration do |migration|
  migration.before -> {
    # ... pre-migration expectations
  }

  migration.after -> {
    # ... post-migration expectations
  }
end

배포 후 마이그레이션용 커스텀 매처#

spec/support/matchers/background_migrations_matchers.rb에는 배포 후 마이그레이션에서 백그라운드 마이그레이션이 올바르게 예약되었는지, 그리고 인자 개수가 올바른지 검증하는 커스텀 매처가 있습니다.

have_scheduled_batched_migration#

기대한 클래스와 인자로 BatchedMigration 레코드가 생성되었는지 검증합니다.

*args는 MigrationClass에 전달되는 추가 인자이고, **kwargs는 BatchedMigration 레코드에서 검증할 그 밖의 속성입니다(예: interval: 2.minutes).

# Migration
queue_batched_background_migration(
  'MigrationClass',
  table_name,
  column_name,
  *args,
  **kwargs
)

# Spec
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

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

이 스펙은 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 "updates all open 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

GitLab은 삭제 기반 데이터베이스 정리 전략을 사용하므로 이 테스트는 데이터베이스 트랜잭션 안에서 실행되지 않습니다. 트랜잭션이 존재한다는 전제에 의존하지 않습니다.

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

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

GitLab v19.4
원문 보기

요약

Rails 마이그레이션을 안정적으로 확인하려면 데이터베이스 스키마를 대상으로 마이그레이션을 테스트해야 합니다. 스키마 변경만 수행하는 포스트 마이그레이션에는 테스트를 강제하지 않습니다. (ee/)spec/migrations/와 spec/lib/(ee/)background_migrations의 모든 스펙에는 :migration RSpec 태그가 자동으로 붙습니다.

Rails 마이그레이션을 안정적으로 확인하려면 데이터베이스 스키마를 대상으로 마이그레이션을 테스트해야 합니다.

마이그레이션 테스트 작성 시점#

  • 포스트 마이그레이션(/db/post_migrate)과 백그라운드 마이그레이션 (lib/gitlab/background_migration)은 반드시 마이그레이션 테스트를 수행해야 합니다.
  • 마이그레이션이 데이터 마이그레이션이면 반드시 마이그레이션 테스트가 있어야 합니다.
  • 그 외 마이그레이션은 필요한 경우 마이그레이션 테스트를 둘 수 있습니다.

스키마 변경만 수행하는 포스트 마이그레이션에는 테스트를 강제하지 않습니다.

동작 방식#

(ee/)spec/migrations/와 spec/lib/(ee/)background_migrations의 모든 스펙에는 :migration RSpec 태그가 자동으로 붙습니다. 이 태그는 spec/support/migration.rb에 정의된 커스텀 RSpec before·after 훅을 실행시킵니다. :gitlab_main이 아닌 다른 데이터베이스 스키마(예: :gitlab_ci)를 대상으로 마이그레이션을 수행한다면 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! 헬퍼 메서드를 사용할 수 있습니다.

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

# 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을 사용하지 않습니다. FactoryBot은 마이그레이션 실행 이후 바뀔 수 있는 애플리케이션 코드에 의존하므로 테스트가 실패할 수 있습니다. 예를 들어 projects 테이블에 레코드를 만들 때는 다음과 같이 작성합니다.

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

migrate!#

migrate! 헬퍼로 테스트 대상 마이그레이션을 실행합니다. 이 헬퍼는 마이그레이션을 실행하고 schema_migrations 테이블의 스키마 버전을 올립니다. after 훅에서 나머지 마이그레이션을 실행하므로 어디서부터 시작해야 하는지 알아야 하기 때문에 필요합니다. 예시:

it 'migrates successfully' do
  # ... pre-migration expectations

  migrate!

  # ... post-migration expectations
end

reversible_migration#

reversible_migration 헬퍼로 change 훅을 쓰거나 up과 down 훅을 모두 쓰는 마이그레이션을 테스트합니다. 이 헬퍼는 마이그레이션을 되돌린 뒤의 애플리케이션과 데이터 상태가 마이그레이션 실행 전과 같은지 확인합니다. 헬퍼의 동작은 다음과 같습니다.

  1. 업 마이그레이션 전에 before 기대값을 실행합니다.
  2. 업 마이그레이션을 수행합니다.
  3. after 기대값을 실행합니다.
  4. 다운 마이그레이션을 수행합니다.
  5. before 기대값을 다시 한 번 실행합니다.

예시:

reversible_migration do |migration|
  migration.before -> {
    # ... pre-migration expectations
  }

  migration.after -> {
    # ... post-migration expectations
  }
end

배포 후 마이그레이션용 커스텀 매처#

spec/support/matchers/background_migrations_matchers.rb에는 배포 후 마이그레이션에서 백그라운드 마이그레이션이 올바르게 예약되었는지, 그리고 인자 개수가 올바른지 검증하는 커스텀 매처가 있습니다.

have_scheduled_batched_migration#

기대한 클래스와 인자로 BatchedMigration 레코드가 생성되었는지 검증합니다.

*args는 MigrationClass에 전달되는 추가 인자이고, **kwargs는 BatchedMigration 레코드에서 검증할 그 밖의 속성입니다(예: interval: 2.minutes).

# Migration
queue_batched_background_migration(
  'MigrationClass',
  table_name,
  column_name,
  *args,
  **kwargs
)

# Spec
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

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

이 스펙은 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 "updates all open 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

GitLab은 삭제 기반 데이터베이스 정리 전략을 사용하므로 이 테스트는 데이터베이스 트랜잭션 안에서 실행되지 않습니다. 트랜잭션이 존재한다는 전제에 의존하지 않습니다.

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