Factory Girlを用いてテストデータを初期化する
2016/01/29
Railsの自動テストでデータベースにテストデータを投入する方法は2つあります。
前者が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 でも動くはずですが、今回は説明を割愛します。
まず、Gemfile
に factory_girl_rails
と database_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.rb
、customers.rb
、orders.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.rb
の RSpec.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
に変更されました。