DatabaseCleanerの挙動と使い所がよくわかっていなかったのでソースコードを読みながら整理しました。
自分の備忘録がわりに書きましたが、DatabaseCleanerの概要と使い所を知りたい方に向けても書いています。
サマリー
- DatabaseCleanerを使うことでbeforeで作ったレコードもafterで消すことが可能
- before(:each)とbefore(:all)でtransactionがネストされていても問題なく動作する
- RSpec.configureのbefore(:all)フックはトップレベルのcontextでしか実行されないので注意
RSpecの基本(前提)
RSpecのtransaction
下記設定がtrue(defaultではtrue)の場合はbefore(:each)の際にtransactionが貼られ、
after(:each)のタイミングでrollbackされる。
これによって、exampleで行ったDBへの更新、削除の操作が別のexampleに副作用をもたらさなくなり、テストの独立性が保たれる。
Any data you create in a before(:each) hook will be rolled back at the end of
the example.
# rails_helper.rb
RSpec.configure do |config|
# If you're not using ActiveRecord, or you'd prefer not to run each of your
# examples within a transaction, remove the following line or assign false
# instead of true.
config.use_transactional_fixtures = true
...
end
また、before(:all)ではtransactionが貼られないことにも注意が必要。
したがって、何もしなければbefore(:all)で作られたレコードは残り続けることになる。
Be sure to clean up any data in an after(:all) hook. If you don't do that, you'll leave data lying around that will eventually interfere with other examples.
hookの実行タイミング
documentに丁寧に説明がある。
1 . 実行順序
before(:suite)
before(:all)
before(:each)
after(:each)
after(:all)
after(:suite)
最近のRSpecではallをcontext, eachをexampleとも表現するようになっている。
その由来は、以下のようにallがcontext単位で、eachがexample単位で実行されるからだと考えられる。
2 . 実行タイミング
suite: テスト(rspecコマンド) の実行前(後)に呼ばれる。要するに最初(最後)の一回のみだけ。
all : それが記述されているcontext内のexampleの実行前(後)に一度だけ呼ばれる。要するにcontext単位で実行される。
each : それが記述されているcontext内のexampleの実行前(後)に毎回呼ばれる。要するにexample単位で実行される。
DatabaseCleanerとは?
上述の通り、RSpecのデフォルトではbefore(:each)でtransactionを張ってafter(:each)でrollbackするだけ。
しかしDatabaseCleanerを使うことで、DBを綺麗にする方法(rollbackするか、trucateするか)や対象のテーブルの指定などができる。
主な設定は、DatabaseCleaner.strategy
で、DatabaseCleaner.start
からDatabaseCleaner.clean
を実行するまでのデータの操作の方法を決める。
# rails_helper.rb
RSpec.configure do |config|
# RSpecの実行前に一度、実行
config.before(:suite) do
# DBを綺麗にする手段を指定、トランザクションを張ってrollbackするように指定
DatabaseCleaner.strategy = :transaction
# truncate table文を実行し、レコードを消す
DatabaseCleaner.clean_with(:truncation)
end
# exampleが始まるごとに実行
config.before(:each) do
# strategyがtransactionなので、トランザクションを張る
DatabaseCleaner.start
end
# exampleが終わるごとに実行
config.after(:each) do
# strategyがtransactionなので、rollbackする
DatabaseCleaner.clean
end
...
end
Strategyごとの実装の違い
strategyによってDatabaseCleanerの振る舞いが変わる。
選択肢としてはtransaction, truncationとdeletionの三つがある。
- transaction: transactionを張ってrollbackする
- truncation: TRUNCATE TABLE文を実行する
- deletion: DELETE文を実行する
それぞれの違いを確認するために、DatabaseCleaner.start
, DatabaseCleaner.clean
された時の処理をソースコードで辿る。
lib/database_cleaner/configuration.rb
にstart
, clean
, clean_with
などのインターフェースが用意され、まず最初にそれらにメッセージが行くようになっている。このファイルはデザインパターンでいうObserverパターンが適用されており、ORマッパーごとにDatabaseCleaner::Base
オブジェクトを配列で管理し、Gem外からの呼び出しに応じて、それぞれのオブジェクトにメッセージを通知する役割を担っている。
# lib/database_cleaner/configuration.rb
module DatabaseCleaner
...
class << self
# It returns Array<DatabaseCleaner::Base>
def connections
unless defined?(@cleaners) && @cleaners
...
# 引数にはORMを表すシンボルが格納される, ex: :active_record
add_cleaner(autodetected.orm)
end
@connections
end
def add_cleaner(orm,opts = {})
...
cleaner = DatabaseCleaner::Base.new(orm,opts)
...
@connections << cleaner
cleaner
end
def start
connections.each { |connection| connection.start }
end
def clean
connections.each { |connection| connection.clean }
end
def clean_with(*args)
connections.each { |connection| connection.clean_with(*args) }
end
...
end
end
それぞれのORマッパーごとにメッセージが委譲されるDatabaseCleaner::Base
オブジェクトを見てみる。
DatabaseCleaner.start
, DatabaseCleaner.clean
でstrategyにそれぞれのメッセージが委譲される。
module DatabaseCleaner
class Base
...
def start
strategy.start
end
def clean
strategy.clean
end
...
end
ではこのstrategyとは何かというと、インスタンス変数で、DBを綺麗にする実際の振る舞いが実装されたオブジェクトが格納されている。
Strategyパターンからstragetyという変数名になっているのだと思う。
module DatabaseCleaner
class Base
...
def strategy
@strategy ||= NullStrategy
end
end
end
それは、DatabaseCleaner.strategy = (transaction | truncation | deletion)
で対象オブジェクトが選択され格納されるようになっている。
# lib/database_cleaner/configuration.rb
module DatabaseCleaner
...
class << self
...
def strategy=(stratagem)
connections.each { |connect| connect.strategy = stratagem }
...
end
...
end
end
# lib/database_cleaner/base.rb
module DatabaseCleaner
class Base
...
def strategy=(args)
...
@strategy = create_strategy(*args)
...
@strategy
end
def create_strategy(*args)
strategy, *strategy_args = args
orm_strategy(strategy).new(*strategy_args)
end
...
private
def orm_strategy(strategy)
# ORマッパーがActiveRecord, strategyがtransactionの場合は
# database_cleaner/active_record/transaction.rbがrequireされる
require "database_cleaner/#{orm.to_s}/#{strategy.to_s}"
# そしてそこにあるクラス名が返される
# ex: DatabaseCleaner::ActiveRecord.const_get(Transaction)
orm_module.const_get(strategy.to_s.capitalize)
rescue LoadError
...
end
...
end
end
これによって、start
, clean
の委譲先のオブジェクトが決まる。
ActiveRecordでtransactionを指定すると、lib/database_cleaner/active_record/transaction.rb
へ、truncationを指定するとlib/database_cleaner/active_record/truncation.rb
へそれぞれのメッセージが委譲される。
1 . Transactionの場合
DatabaseCleaner.start
DatabaseCleaner.clean
2 . Truncationの場合
DatabaseCleaner.start
DatabaseCleaner.clean
DeletionはこれがDELETE文になったのとほぼ同じ。
DatabaseCleanerによるトランザクションのネスト
ActiveRecordでtransactionをネストして張った場合は、raise ActiveRecord::Rollback
して例外を発生させてもrollbackされない。
strategyをtransactionにして張ったtransactionはrollbackされるのか確認した。
結論から言うとrollbackされる。
# rails_helper.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
end
# exampleの実行前にtransactionを張る
config.before(:each) do
DatabaseCleaner.start
end
# exampleの実行後にrollbackする
config.after(:each) do
DatabaseCleaner.clean
end
end
# spec/controllers/deals_controller_spec.rb
require 'rails_helper'
RSpec.describe DealsController do
describe '#update' do
context 'before allでレコードを作る' do
before(:all) do
DatabaseCleaner.start
@d1 = create(:deal, amount: 100)
end
after(:all) do
DatabaseCleaner.clean
end
# Deal.find(params[:id]).update(amount: params[:amount])をする適当なアクション
subject { put :update, params: { id: @d1.id, amount: 400 } }
it 'amountを400に更新する' do
expect(Deal.count).to eq(1)
is_expected.to have_http_status(200)
expect(@d1.reload.amount).to eq(400)
end
it 'amountがrollbackされ、100に戻っている' do
expect(Deal.count).to eq(1)
expect(@d1.reload.amount).to eq(100)
end
end
context 'before allでレコードを作らない' do
it '前のcontextで作ったレコードがrollbackされている' do
expect(Deal.count).to eq(0)
end
end
end
end
結果を見るとうまくいっている。
before(:each), before(:all)でそれぞれtransactionを張っても有効であることが確認できた。
$ bin/rspec spec/controllers/deals_controller_spec.rb
Running via Spring preloader in process 52332
...
Finished in 0.15593 seconds (files took 0.36434 seconds to load)
3 examples, 0 failures
config.before(:all)のタイミング
なかなか見つからなかったが、RSpec.configureのbefore(:all)はそのspecファイルのトップレベルのcontextでのみ実行される。
New names for hook scopes: :example and :context
RSpec 2.x had three different hook scopes:
describe MyClass do
before(:each) { } # runs before each example in this group
before(:all) { } # runs once before the first example in this group
end
# spec/spec_helper.rb
RSpec.configure do |c|
c.before(:each) { } # runs before each example in the entire test suite
c.before(:all) { } # runs before the first example of each top-level group
c.before(:suite) { } # runs once after all spec files have been loaded, before the first spec runs
end
なので、RSpec.configureのbefore(:all)でDatabaseCleanerのトランザクションを張ってもネストされたcontextのbefore(:all)ではトランザクションは張られない。
# rails_helper.rb
RSpec.configure do |config|
config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
end
config.before(:each) do
DatabaseCleaner.start
end
config.after(:each) do
DatabaseCleaner.clean
end
config.before(:all) do
DatabaseCleaner.start
puts "DatabaseCleaner.start in config.before(:all): #{Deal.count}\n"
end
config.after(:all) do
DatabaseCleaner.clean
puts "\nDatabaseCleaner.clean in config.after(:all): #{Deal.count}\n"
end
end
# spec/controllers/deals_controller_spec.rb
require 'rails_helper'
RSpec.describe DealsController do
describe '#update' do
context 'before allでレコードを作る' do
# ここのbefore(:all)ではトランザクションは張られない
before(:all) do
@d1 = create(:deal, amount: 100)
end
it 'before allでレコードが作られる' do
expect(Deal.count).to eq(1)
end
end
context 'before allでレコードを作らない' do
it '前のtop-levelでないcontextで作ったレコードはrollbackされていない' do
expect(Deal.count).to eq(1)
end
end
end
end
ネストされたcontextのbefore(:all)ではtransaction張られず、contextと超えてbefere(:all)で作ったレコードが残っている。
$ bin/rspec spec/controllers/deals_controller_spec.rb
Running via Spring preloader in process 86767
DatabaseCleaner.start in config.before(:all): 0
..
DatabaseCleaner.clean in config.after(:all): 0
Finished in 0.15074 seconds (files took 0.39055 seconds to load)
2 examples, 0 failures
このことからネストしたcontextでbefore(:all)を使う場合は明示的にDatabaseCleaner.start
とそのafter(:all)でDatabaseCleaner.clean
を実行する必要があることがわかる。