cream

code rules everything around me

Rails で migration のテストを書く

Ruby on Rails で開発するとき、良識のある人々なら rspec などでテストを書いていると思う。たとえば、APIの機能追加のとき request spec を書いたりする。

migration の不安から解放されたい

DB の migration を自動テストしているという人は割と少ないと思う。

簡単なカラム追加の migration は何も問題ない。しかし、ちょっと複雑なデータ移行を migration するような場合は、不安な気持ちでデプロイしてるかもしれない。

個人的な経験ではこういうことがあった:

  • 検証環境のDBで実行して、エラーでコケて修正する、を何度も繰り返して時間を消費する
  • 本番環境で動くか不安な状態でデプロイする (ローカル開発環境と本番環境でのデータは違う)
  • 移行コードがバグっていて、移行結果のデータがおかしくなる

他の部分でテストを書くのと同じように、 migration を手元でテストする方法があれば、もう少し安らかな気持ちで実行できるはず。

GitLab 流 migration テスト

GitLab の開発wikiRails migration をテストする方法が書かれている。

GitLab は社内で使うドキュメントを積極的に公開している。特に RailsPostgreSQL の運用についての知見は良質で、よく参考にしている。

Testing Rails migrations at GitLab | GitLab

丁寧に書かれてはいるけど、GitLab のコードベースにあるヘルパー関数で抽象化されているので、実際に何が起こっているか若干わかりにくい。

以下では、リンクにある方法のコードを展開する形で書いて説明しようと思う。

基本

たとえば、領収書を表現するモデル Receipt があるとする。

固定だった金額を可変にしたくなったため、カラムを追加したいとする。

db/migrate/20210505073832_add_amount_to_receipts.rb

class AddAmountToReceipts < ActiveRecord::Migration[6.0]
  def change
    add_column :receipts, :amount, :integer
  end
end

素朴に考えると以下のような手順ができるとテストになる:

  1. テスト対象の migration を実行する前のデータベースの状態を作る
  2. 実行前の状態: 好きにデータを挿入する
  3. テスト対象の migration を実行する
  4. 実行後の状態: 好きにデータを検証する

その素朴な考えをコードに移す:

spec/migrations/add_amount_to_receipts_spec.rb

require 'rails_helper'
# migration file は自動ではロードされないので, 明示的に require する
require './db/migrate/20210505073832_add_amount_to_receipts.rb'

# `AddAmountToReceipts` は migration file で定義した migration の class
describe AddAmountToReceipts do
  # ActiveRecord::MigrationContext を使って, バージョン情報を一覧したり, 特定のバージョンの状態に戻す操作を実行する
  let(:migration_context) { ActiveRecord::Base.connection.migration_context }

  # テスト対象の migration
  let(:current_migration) { migration_context.migrations.find { |migration| migration.name == described_class.name } }

  # 対象 migration の1つ前のバージョンまで戻したいのでとる
  let(:previous_migration) do
    migration_context.migrations.each_cons(2) do |previous, migration|
      break previous if migration.name == described_class.name
    end
  end

  # rails db:migrate などで migration 実行するとログメッセージがでる
  # テストで出ると鬱陶しいので出ないようにする
  around do |e|
    ActiveRecord::Migration.suppress_messages { e.run }
  end

  it do
    # 1. migration 実行前の状態を作る
    migration_context.down(previous_migration.version)

    # 2. データのセットアップをする

    # 3. migration を実行する
    migration_context.up(current_migration.version)

    # 4. データのテストをする
  end
end

普通に rspec として実行できる:

❯ bin/rspec spec/migrations/add_amount_to_table_receipts_spec.rb
Running via Spring preloader in process 5841
.

Finished in 2.48 seconds (files took 0.82292 seconds to load)
1 example, 0 failures

これで migration がただ単にエラーにならずに動くことがテストできた。

実際書いていくと色々細かいテクニックが要るのだけど、これが基本になる。

テストを足す

これだけだとテストをする意味がないので、もう少しテスト駆動のような形で書いてみる。

新規カラムにデータを移行して、将来のことを考えて not null 制約をつけたいとする。

class AddAmountToReceipts < ActiveRecord::Migration[6.0]
  def change
    add_column :receipts, :amount, :integer
    up_only do
      # データ移行する
    end
    change_column_null :receipts, :amount, false
  end
end

実際のデータを想定していれる

  let(:order) { create(:order, :complete) }
  let(:owner) { order.restaurant.owner }

  before { migration_context.down(previous_migration.version) }

  it do
    receipt = order.receipts.create!(name: '株式会社なんとか', creator: owner)
    migration_context.up(current_migration.version)
  end

実行してみる(データ移行のコードがないので落ちる)

❯ bin/rspec spec/migrations/add_amount_to_receipts_spec.rb
F

Failures:

  1) AddAmountToReceipts
     Failure/Error: migration_context.up(current_migration.version)

     StandardError:
       An error has occurred, this and all later migrations canceled:

       PG::NotNullViolation: ERROR:  column "amount" of relation "receipts" contains null values
     # ./db/migrate/20210505073832_add_amount_to_receipts.rb:7:in `change'
     # ./spec/migrations/add_amount_to_receipts_spec.rb:30:in `block (2 levels) in <top (required)>'

移行するコードを書く

class AddAmountToReceipts < ActiveRecord::Migration[6.0]
  def change
    add_column :receipts, :amount, :integer

    up_only do
      Receipt.reset_column_information
      Receipt.find_each do |receipt|
        receipt.update!(amount: receipt.calculate_amount)
      end
    end

    change_column_null :receipts, :amount, false
  end
end

実際に amount に値が埋まっているか検証しておく

  it do
    receipt = order.receipts.create!(name: '株式会社なんとか', creator: owner)

    migration_context.up(current_migration.version)

    Table::Receipt.reset_column_information
    receipt.reload
    expect(receipt.amount).to be_present
  end

Table::Receipt.reset_column_information で、カラム追加後に model のフィールドとして呼び出したいため、テーブルのスキーマ情報を読み直している。

model がスキーマに依存する場合

model のコードがDBスキーマに依存するような場合、 問題がでてくる。

たとえば、新規カラムを参照するロジックを model に書いた場合:

class Receipt < ApplicationRecord
  before_validation :set_amount
  # ...
  private
  def set_amount
    self.amount ||= total_amount
  end
end

model のコードを使ってデータを用意してるので、存在しない attribute を参照しようとしてエラーになる:

❯ bin/rspec spec/migrations/add_amount_to_table_receipts_spec.rb

Failures:

  1) AddAmountToTableReceipts 
     Failure/Error: self.amount ||= total_amount
     
     NoMethodError:
       undefined method `amount' for #<Receipt:0x00007ffb7c1adb58>

そのため、ActiveRecord::Base を継承した無名クラスを作って model を使わずにデータを挿入する

  def table(name)
    Class.new(ActiveRecord::Base) do
      self.table_name = name
      self.inheritance_column = :_type_disabled

      def self.name
        table_name.singularize.camelcase
      end
    end
  end

  before { migration_context.down(previous_migration.version) }
  after { Receipt.reset_column_information }

  it do
    receipt = table(:receipts).create!(
      name: '株式会社なんとか', creator_type: 'Owner', creator_id: owner.id, table_order_id: order.id
    )
    migration_context.up(current_migration.version)
    # ...
  end

詳細

こういう具合で migration についてもテストが書けます。

再利用に便利なようにするには以下のファイルなどを見るといいと思う。

gitlab.com

実際のところ

単純なスキーマ変更では書いてないけど、ちょっと複雑なデータ移行のときには書くようにしている。テストを書いた場合には、やはり変なミスは減っているように感じる。

他の rspec テストと同じようにCIで実行されるようにしている。古い migration をいつまでもテストしても仕方ないので、バージョンが古いものはスキップするようなコードを入れています。