Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
81
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

Organization

DatabaseCleanerの実装と使い所

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.rbstart, 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を実行する必要があることがわかる。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
81
Help us understand the problem. What are the problem?