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 をいつまでもテストしても仕方ないので、バージョンが古いものはスキップするようなコードを入れています。

目視でAPIレスポンスを確認している

JSON 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 として切り出している。見やすいように構造を整形したり、色を付けて表示したりする。

github.com

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 を実装するときに、レスポンスの中身を出力して目で見て確認するようにしているという話でした。

かなり単純な仕組みだけど、バグやミスに気づけることが増えた上に、ドキュメントを書きやすくなったので、プロダクトの品質を高めるのに役に立っている。

github.com

*1:割と単純な話なので、もしかしたら僕が知らなかっただけで皆は当然のようにやっている話なのかもしれない。