Rails で migration のテストを書く
Ruby on Rails で開発するとき、良識のある人々なら rspec などでテストを書いていると思う。たとえば、APIの機能追加のとき request spec を書いたりする。
migration の不安から解放されたい
DB の migration を自動テストしているという人は割と少ないと思う。
簡単なカラム追加の migration は何も問題ない。しかし、ちょっと複雑なデータ移行を migration するような場合は、不安な気持ちでデプロイしてるかもしれない。
個人的な経験ではこういうことがあった:
- 検証環境のDBで実行して、エラーでコケて修正する、を何度も繰り返して時間を消費する
- 本番環境で動くか不安な状態でデプロイする (ローカル開発環境と本番環境でのデータは違う)
- 移行コードがバグっていて、移行結果のデータがおかしくなる
他の部分でテストを書くのと同じように、 migration を手元でテストする方法があれば、もう少し安らかな気持ちで実行できるはず。
GitLab 流 migration テスト
GitLab の開発wiki に Rails migration をテストする方法が書かれている。
GitLab は社内で使うドキュメントを積極的に公開している。特に Rails や PostgreSQL の運用についての知見は良質で、よく参考にしている。
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
素朴に考えると以下のような手順ができるとテストになる:
- テスト対象の migration を実行する前のデータベースの状態を作る
- 実行前の状態: 好きにデータを挿入する
- テスト対象の migration を実行する
- 実行後の状態: 好きにデータを検証する
その素朴な考えをコードに移す:
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 についてもテストが書けます。
再利用に便利なようにするには以下のファイルなどを見るといいと思う。
実際のところ
単純なスキーマ変更では書いてないけど、ちょっと複雑なデータ移行のときには書くようにしている。テストを書いた場合には、やはり変なミスは減っているように感じる。
他の rspec テストと同じようにCIで実行されるようにしている。古い migration をいつまでもテストしても仕方ないので、バージョンが古いものはスキップするようなコードを入れています。