Help us learn about your current experience with the documentation. Take the survey.

在 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 钩子将数据库向上迁移并恢复最新架构版本,确保过程不影响后续规范并保证适当隔离。

测试 ActiveRecord::Migration

要测试 ActiveRecord::Migration 类(例如常规迁移 db/migrate 或后迁移 db/post_migrate),必须使用 require_migration! 辅助方法加载迁移文件,因为 Rails 不会自动加载它。

示例:

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 '迁移成功' do
  # ... 迁移前预期

  migrate!

  # ... 迁移后预期
end

reversible_migration

使用 reversible_migration 辅助方法测试包含 change 或同时包含 updown 钩子的迁移。这验证了迁移后应用程序及其数据的状态在回滚后与迁移运行前相同。该辅助方法:

  1. 向上迁移前运行 before 预期。
  2. 向上迁移。
  3. 运行 after 预期。
  4. 向下迁移。
  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 记录。

*args 是传递给 MigrationClass 的附加参数,而 **kwargs 是要在 BatchedMigration 记录上验证的任何其他属性(示例: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 枚举
  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 "对于 #draft? == true 但 draft 属性为 false 的 MR" 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 "将所有打开的草稿 MR 的 draft 字段更新为 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 元数据,使测试在事务中运行,数据将回滚到原始值。