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 をいつまでもテストしても仕方ないので、バージョンが古いものはスキップするようなコードを入れています。
目視でAPIレスポンスを確認している
普段 Rails で開発していて、モバイルアプリ向けにいわゆるフツウにJSONで返す Web API を実装しています。
URLやインターフェースを設計して、それに合わせて rspec でテストを書いて中身を実装する流れで開発することが多い。
今回は、実装するときにテストを書くだけでなく、必ず 目視で APIのレスポンスをチェックするようにしてから、バグやミスが減ったということを紹介したい。*1
目視で確認する
たとえば、レストランのメニューを表示するようなAPIを作ってるとして、rspec (reques spec) を書いたとする。
describe 'GET /menu/:id' do let(:menu) { create(:menu) } subject(:submit_request) { get "/menu/#{menu.id}", headers: headers } it 'returns a menu' do expect(json).to match( hash_including(id: menu.id, ...) ) end end
テストを書いたら実行するわけだけど、書いたテストが通ることを確認するだけでなく、 APIリクエスト / レスポンスの中身がログとして実行時に出力されるようにしている。
新しく書いたテストだけでなく、既存のテストを実行するときにも、出力されて見えるようにしておく。
❯ bin/rspec spec/requests/api/menus_spec.rb . GET http://api.lvh.me/menus/bf28a586-d30c-4c1d-b812-e80415342d09 {} { "id": "bf28a586-d30c-4c1d-b812-e80415342d09", "name": "ディナー1", "status": "published", "created_at": "2021-04-28T21:55:10+09:00", ... }
中身をログに流しておいて、ざっと眺められるようにしておくと、実装時に色々な問題に気づきやすくなる。たとえば:
- テストは通ってるが、バグのある部分があることに気づく
- テストすべき挙動がテストされてなくて、テストが足りていないことに気づく
- 表示してはいけないAPIフィールド (極端な例: IPアドレスなど) が含まれていることに気づく
- ほかの既存APIのバグにたまたま気づく
- null になっていてはいけないフィールドに null が入ってるなど
上のは簡単な例だけど、実際にはネストした構造のデータが出力されることが多い。
ネストしたデータはテストで比較的検証しにくく、バグも発生しやすいので、目視で検知する方法が役に立つ。
{ "id": "bf28a586-d30c-4c1d-b812-e80415342d09", "name": "ディナー1", "menu_pages": [ { "id": "xxx", "name": "ページ1", "product": { "id": "yyy", "name": "商品1", ... }, ... } ] ... }
実装中は無意識に眺めているような感じで、実装が完成したら確認の意味でスクロールしてざっと見てみるようにしている。
流れるログを無意識に眺めているだけでも野生の勘が働いて、重大な問題に気づけることもあった。
rspec を実行時に自動でAPIレスポンスの中身を表示するコードは個人的に便利に使ってるので、gem として切り出している。見やすいように構造を整形したり、色を付けて表示したりする。
APIドキュメントの代わりにコピペする
一緒に働いているメンバーは優秀で、APIの仕様を細かく書かなくても理解してくれるので、代わりに簡単に説明を pull request に貼るようにしている。
そのときに、上で出力されたテキストを pull request にコピペしている。API の path やリクエストも含めて表示されるので、ざっと概要を伝えるのに便利。
たとえば、メニューを更新するようなAPIだとこんな例を貼っておくと、だいたい使い方は分かると思う。
PATCH http://api.lvh.me/menus/aa8d8277-43d3-47d0-bf04-655006ab27db { "status": "unpublished" } { "id": "aa8d8277-43d3-47d0-bf04-655006ab27db", "name": "ディナー28", "status": "unpublished", "created_at": "2021-04-28T21:48:07+09:00", ... }
まとめ
JSON API を実装するときに、レスポンスの中身を出力して目で見て確認するようにしているという話でした。
かなり単純な仕組みだけど、バグやミスに気づけることが増えた上に、ドキュメントを書きやすくなったので、プロダクトの品質を高めるのに役に立っている。
*1:割と単純な話なので、もしかしたら僕が知らなかっただけで皆は当然のようにやっている話なのかもしれない。