GitLab での Rails マイグレーションのテスト

Railsマイグレーションを確実にチェックするには、データベーススキーマと照らし合わせてテストする必要があります。

マイグレーションテストを書くタイミング

  • ポストマイグレーション(/db/post_migrate)およびバックグラウンドマイグレーション(lib/gitlab/background_migration)では、マイグレーションテストを実施する必要があります。
  • マイグレーションがデータマイグレーションである場合、マイグレーションテストが必要です。
  • その他のマイグレーションでも、必要に応じてマイグレーションテストを行うことができます。

スキーマの変更のみを行うポストマイグレーションでは、テストを実施しません。

どのように動作しますか?

(ee/)spec/migrations/spec/lib/(ee/)background_migrations のすべての仕様には、自動的に:migration RSpec タグが付けられます。このタグにより、spec/support/migration.rb のカスタム RSpecbefore およびafter フックが実行されます。:gitlab_main 以外のデータベーススキーマ (たとえば:gitlab_ci) に対してマイグレーションを実行する場合は、次のような RSpec タグで明示的に指定する必要があります:migration: :gitlab_ci.例についてはspec/migrations/change_public_projects_cost_factor_spec.rbを参照してください。

before フックは、テスト中のマイグレーションがまだマイグレーションされていない時点まで、すべてのマイグレーションを戻します。

言い換えると、私たちのカスタムRSpecフックは以前のマイグレーションを検出し、データベースを以前のマイグレーションバージョンまでマイグレーションします。

この方法で、データベーススキーマに対するマイグレーションをテストできます。

after フックがデータベースをマイグレーションし、最新のスキーマ・バージョンをリストアします。このプロセスは後続の仕様に影響を与えず、適切な分離を保証します。

ActiveRecord::Migration クラスのテスト

ActiveRecord::Migration クラス (たとえば通常のマイグレーションdb/migrate やマイグレーション後のdb/post_migrate) をテストするには、require_migration! ヘルパーメソッドを使ってマイグレーションファイルを読み込む必要があります。

使用例:

require 'spec_helper'

require_migration!

RSpec.describe ...

テストヘルパー

require_migration!

マイグレーションファイルはRailsによって自動ロードされないので、手動でロードする必要があります。そのためには、require_migration! ヘルパーメソッドを使うことができます。このヘルパーメソッドは spec ファイル名に基づいて正しいマイグレーションファイルを自動的にロードします。

GitLab 14.4以降では、require_migration! を使って、ファイル名にスキーマバージョンを含むspecファイルからマイグレーションファイルをロードすることができます(たとえば、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
  # ... pre-migration expectations

  migrate!

  # ... post-migration expectations
end

reversible_migration

change あるいはupdown の両方のフックでマイグレーションをテストするためにreversible_migration ヘルパーを使います。これはマイグレーションが逆転した後のアプリケーションとデータの状態がマイグレーションが実行される前と同じであることをテストします。ヘルパーは

  1. マイグレーションの前にbefore
  2. マイグレーションをアップします。
  3. after 期待を実行します。
  4. マイグレーションダウン
  5. before 、2回目の期待を実行します。

使用例:

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

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

デプロイ後のマイグレーション用カスタムマッチャー

デプロイ後のマイグレーションからバックグラウンドマイグレーションが正しくスケジュールされ、正しい数の引数を受け取ったことを確認するために、spec/support/matchers/background_migrations_matchers.rb にいくつかのカスタムマッチャーがあります。

これらはすべて内部 matcherbe_background_migration_with_arguments を使用し、マイグレーションクラスの#perform メソッドが提供された引数を受け取ったときにクラッシュしないことを検証します。

be_scheduled_migration

Sidekiqジョブが期待されるクラスと引数でキューに入れられたことを検証します。

このマッチャーは、通常、ヘルパーを経由するのではなく、手動でジョブをキューイングしている場合に意味があります。

# Migration
BackgroundMigrationWorker.perform_async('MigrationClass', args)

# Spec
expect('MigrationClass').to be_scheduled_migration(*args)

be_scheduled_migration_with_multiple_args

Sidekiqジョブが期待されるクラスと引数でキューに入れられたことを検証します。

これは、配列の引数を比較するときに順序が無視される点を除き、be_scheduled_migration と同じ動作をします。

# Migration
BackgroundMigrationWorker.perform_async('MigrationClass', ['foo', [3, 2, 1]])

# Spec
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 および関連ヘルパーでも使用できます。

# Migration
BackgroundMigrationWorker.perform_in(delay, 'MigrationClass', args)

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

have_scheduled_batched_migration

BatchedMigration レコードが期待されたクラスと引数で作成されたことを確認します。

*argsMigrationClassに渡される追加引数で、**kwargsBatchedMigration レコードで検証されるその他の属性です (例: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

バックグラウンドマイグレーションスケジューリングテストの例

これらをテストするには、通常

  • いくつかのレコードを作成します。
  • マイグレーションを実行します。
  • 正しいレコードセット、正しいバッチサイズ、間隔などで、期待されたジョブがスケジュールされたことを確認します。

バックグラウンドマイグレーション自体の動作は、バックグラウンドマイグレーションクラスの別のテストで検証する必要があります。

この仕様では、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 "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

これらのテストは、削除データベースのクリーンアップ戦略を使用しているため、データベーストランザクション内では実行されません。トランザクションが存在することに依存しないでください。