Factory Girlを用いてテストデータを初期化する

2016/01/29

Initialize Test Data

Railsの自動テストでデータベースにテストデータを投入する方法は2つあります。

FixturesFactory Girlです。

前者がRails標準で、後者はGemパッケージとして提供されています。

前者はYAMLで、後者はRubyでデータを記述します。

私がRailsに出会った頃(2005年)にはFixturesしかありませんでした。その時は、こんな便利なものはないと感激しましたが、データベース構造やアプリケーションのロジックが複雑になるにつれYAMLファイルを保守するのに困難を感じるようになりました。その後、Factory Girlを知り、Fixturesを使うのをやめてしまいました。

最近知ったのですが、一昨年(2014年)に私がRailsでMinitestとFixturesを使い続ける7つの理由と題する英語のブログ記事が書かれて、ちょっと話題になったそうです。

RSpecの代わりにMinitestを使うのは悪くないアイデアですが、Fixturesを本物の開発案件で使うのはためらわれます。

昔のFixturesとは異なりレコード間の関連付けもやってくれるようではありますが、保守性という観点ではFixturesの能力はFactory Girlのそれに遠く及びません。私はFactory GirlのSequencesとかCallbacksとかの便利な機能を使いたいのです。また、データベースに保存する前にモデルのバリデーションも通したいのです。

ただし、Factory Girlに対しても不満があります。テストの実行が遅くなるということです。テストの各ケース(RSpec用語ではエグザンプル)ごとに必要なデータ構造をいちから作るのでどうしても遅くなります。まったく同じデータ構造を前提とするテストケースが2個あれば、2回テストデータを投入することになります。

他方、Fxturesではテストスイートの開始前に初期データベースを作り、これを全テストケースで繰り返し利用します。


今回のRails TipではこのFactory Girlの欠点を補う方法を紹介します。

基本的なアイデアは、テストスイートの実行前にFactory Girlで初期データベースを構築しておくというものです。

以下、簡単に手順を説明します。ただし、テストフレームワークとして RSpec を使う前提です。Minitest でも動くはずですが、今回は説明を割愛します。

まず、Gemfilefactory_girl_railsdatabase_cleaner を追加します。

group :test do
  gem 'factory_girl_rails'
  gem 'database_cleaner'
end

ターミナルで bin/bundle install してください。

そして、rspec ディレクトリの下に、新規ファイル initial_data_loader.rb を以下のような内容で作成します。

require 'digest/md5'
require 'database_cleaner'

md5 = Digest::MD5.new
Dir[Rails.root.join("spec/initial_data/*.rb")].each do |f|
  md5.update File.new(f).read
end

conn = ActiveRecord::Base.connection

DIGEST_TABLE_NAME = '_initial_data_digest'

unless conn.table_exists?(DIGEST_TABLE_NAME)
  conn.create_table(DIGEST_TABLE_NAME) do |t|
    t.string :md5_value
  end
end

class InitialDataDigest < ActiveRecord::Base
  self.table_name = DIGEST_TABLE_NAME
end

digest = InitialDataDigest.first

unless digest.try(:md5_value) == md5.hexdigest
  DatabaseCleaner.strategy = :truncation, { except: [ DIGEST_TABLE_NAME ] }
  DatabaseCleaner.clean

  yaml_path = Rails.root.join('spec', 'initial_data', '_index.yml')
  if File.exist?(yaml_path)
    table_names = YAML.load_file(yaml_path)
    table_names.each do |table_name|
      path = Rails.root.join('spec', 'initial_data', "#{table_name}.rb")
      if File.exist?(path)
        puts "Creating #{table_name}...."
        require path
      end
    end
  else
    Dir[Rails.root.join("spec/initial_data/*.rb")].each do |f|
      table_name = f.match(/(\w+)\.rb$/)[1]
      puts "Creating #{table_name}...."
      require f
    end
  end

  digest ||= InitialDataDigest.new
  digest.md5_value = md5.hexdigest
  digest.save
end

続いて、エディタで spec/rails_helper.rb を開きます。その中に

RSpec.configure do |configure|

end

というブロックがありますので、この内側に

  config.before(:suite) do
    require 'initial_data_loader.rb'
  end

と書き入れてください。

次に、spec ディレクトリの下に initial_data ディレクトリを作成します。ここに Factory Girl を用いたデータ投入スクリプトを置いてください。

例えば、customers テーブルに 20 件のテストレコードを投入するスクリプトはこんな感じになります。

include FactoryGirl::Syntax::Methods

0.upto(19) do |n|
  create(:customer, email: "test#{n}@example.jp", password: "password")
end

テストケースの中では、find_by メソッドを用いてこれらのレコードを参照します。例えば、こんな感じです。

require 'rails_helper'

describe Customer::SessionsController do
  describe '#create' do
    let(:customer) { Customer.find_by(email: 'test0@example.jp') }

    example '顧客がログインに成功する' do
      post :create, customer_login_form: {
        email: customer.email,
        password: 'password'
      }

      expect(session[:customer_id]).to eq(customer.id)
    end
  end
end

この例ではデータ構造が複雑ではないのでそれほど時間短縮の効果は出ませんが、業務アプリの開発などでは相当な威力を発揮します。


さて、私が書いた initial_data_loader.rb には、ちょっとおもしろい工夫が施されています。

それは、spec/initial_data ディレクトリに置かれたファイル群の MD5 ダイジェストを計算し、その値を _initial_data_digest というテーブルに記録するというものです。

初期データの投入を開始する前に、計算した MD5 ダイジェストの値と _initial_data_digest テーブルに記録されている値を比較し、一致すれば初期データの投入をスキップします。値が異なれば、_initial_data_digest 以外の全テーブルを空にしたあとで初期データの投入スクリプトを走らせます。

こうすることで、二度目以降のテスト実行にかかる時間が短縮されます。


最後に注意点を1つ。

spec/initial_data ディレクトリに置かれたデータ投入スクリプトはアルファベット順に読み込まれます。

もし読み込み順序を制御したい場合は、同じディレクトリに _index.yml というファイルを置いてください。

- products
- customers
- orders

のように書けば products.rbcustomers.rborders.rb の順番に読み込まれます。


[UPDATE] 2016-01-31

この記事で作った initial_data_loader.rb とほぼ同等プラスアルファの機能を持つ Gem パッケージ initial-test-data を作りました。

以下、RSpecでの使い方を簡単に。

(1) Gemfile に次の行を追加。

gem 'initial-test-data', group: :test

(2) spec/rails_helper.rb の冒頭(require 'rspec/rails' の下)に次の記述を追加。

require 'initial-test-data'

(3) spec/rails_helper.rbRSpec.configure ブロックの内側に次の記述を追加。

config.before(:suite) do
  InitialTestData.load('spec')
end

ここで 'spec' はディレクトリの名前を示します。

なお、InitialTestData.load はすべてのテーブルを空にしてから初期データを投入します。そのまま残したいテーブルがあるときは、except オプションにテーブル名の配列を指定してください。

InitialTestData.load('spec', except: %w(countries))

また、InitialTestData.load はデフォルトで spec/initial_data ディレクトリと app/models ディレクトリを監視し、中身が変化していればテストデータを削除して入れ直します。

もしこれら以外のディレクトリを監視対象に置きたい場合は、monitoring オプションにディレクトリの配列を指定してください。

InitialTestData.load('spec', monitoring: [ 'app/services', 'lib' ])

配列の各要素には Rails.root からの相対パスを指定してください。

ここから先は、記事本編で書いたことの繰り返しになります。

(4) spec ディレクトリの下に initial_data ディレクトリを作り、そこにテストデータを投入する Ruby スクリプトを置きます。

Ruby スクリプトはアルファベット順で読み込まれます。読み込み順を制御したい場合は、initial_data ディレクトリの下に _index.yml というファイルを、次のような形式で作ってください。

- products
- customers
- orders

(5) ターミナルで bin/rspec コマンドを入力し、テストを実行します。

spec/initial_data ディレクトリの中身が変化していなければ、次回のテストからは初期データの投入処理が行われないので、テストの実行時間が短縮されます。

ぜひお試しください!


[UPDATE] 2016-02-14

initial-test-data の v0.5.0 で、クラスメソッドの名前が load から import に変更されました。