FactoryBot とRSpec によるテスト(1) - 不定期刊 Rails App を作る(12)

RailsApp Rails FactoryBot RSpec 2018年 11月 20日

ログインできるようになったので,モデルを順次追加していく. その前に,モデルのテストを簡単にするために,FactoryBot を利用する. トラブルシューティングを含め,細かく書いてあるので, Rails + RSpec で FactoryBot(旧 FactoryGirl)を使う にしたがって作業する.

FactoryBot のインストール

  1. Gemfile に factory_bot を追加する.
  2. group :development, :test do
      gem 'factory_bot_rails', '~> 4.0'
  3. bundle する(2行目以降は結果: 追加分のみ)
  4. $ bundle
    Fetching factory_bot 4.11.1
    Installing factory_bot 4.11.1
    Fetching factory_bot_rails 4.11.1
    Installing factory_bot_rails 4.11.1
  5. .rspec の require が spec_helper になっているので,rails_helper に修正する
  6. --require rails_helper
    --format documentation
  7. spec/rails_helper.rb に factory_bot.rb の require を追加する.また,後で spec/support 以下にファイルを置くので,そこのファイルを自動的に require する設定を追記する(コメントしてあるだけなので,コメントを外せばよい)
  8. # Add additional requires below this line. Rails is not loaded until this point!
    require 'factory_bot'
    (中略)
    Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f }
  9. config/application.rb に fixture_replacement の記述を追加する.以前,generators の設定をしているので,そこに追加する形にすればよい.また,FactoryBot の設定に合わせて,generators の設定も修正しておく.
  10. Rails.application.config.generators do |g|
      g.fixture_replacement :factory_bot, dir: 'spec/factories'
      g.factory_girl    true
      g.test_framework :rspec,
        fixtures: true,
        view_specs: false,
        routing_specs: false,
        controller_specs: false,
        request_specs: true
      (後略)

key based FactoryBot の設定

FactoryBot 自体の機能で関連などを簡単に作成できる. しかし,自動的に作成される関連では uniqueness などの制約テストがやりにくい. そこで,私の場合は自作の key based な FactoryBot の作り込みでテストを運用している. key based FactoryBot のためには,既存の FactoryBot が存在した時に,それを返す find_or_create に相当するメソッドが必要となる. maunovaha/fg_find_or_create.rb を参考に,少し改変して取り入れる.

  1. spec/factories および spec/support/create_factories ディレクトリを作成する.
  2. mkdir -p spec/{factories,support/create_factories}
  3. spec/support/fb_find_or_create.rb を作成する.
  4. module FactoryBot::Syntax::Methods
      def find_or_create(name, *attributes, &block)
        attributes = FactoryBot.attributes_for(name, *attributes)
        klass = FactoryBot.factory_by_name(name).build_class
        enums = klass.defined_enums
        find_attributes = attributes.clone
        find_attributes.delete(:password)
        find_attributes.delete(:password_confirmation)
        find_attributes.keys.each do |key|
          find_attributes[key] = enums[key.to_s][find_attributes[key]] if enums.has_key?(key.to_s)
        end
        result = klass.find_by(find_attributes, &block)
        result || FactoryBot.create(name, attributes, &block)
      end
    end
  5. spec/factories/teachers.rb を作成する.テストに関係しない属性は FactoryBot.define の方に書いておくと楽になる.
  6. FactoryBot.define do
      factory :teacher do
        password {'abcdefg'}
        password_confirmation {'abcdefg'}
      end
    end
  7. spec/support/create_factories/teacher_factory.rb を作成する.このメソッドでは,key に対応した FactoryBot が生成済みであれば,既存の FactoryBot を取得する.ない場合には key に対応した属性で新規 FactoryBot を生成する.
  8. TeacherFactoryHash = {
      hkob: %w[小林弘幸 hkob@example.com],
      tmcit: %w[高専太郎 taro@example.com],
    }
    
    # @param [Symbol] key オブジェクトを一意に決定するキー
    # @return [User] Teacher FactoryBot オブジェクト
    def teacher_factory(key)
      n, e = TeacherFactoryHash[key.to_sym]
      FactoryBot.find_or_create(
        :user,
        name: n,
        email: e,
      ) if n
    end
    
    # @param [Array<Symbol, String>] keys オブジェクトを一意に決定するキーの配列
    # @return [Array<Teacher>] Teacher FactoryBot オブジェクトの配列
    def teacher_factories(keys)
      keys.map { |k| teacher_factory(k) }
    end
    
    # @return [Array<Teacher>] Teacher FactoryBot オブジェクトの配列
    def teacher_all_factories
      teacher_factories TeacherFactoryHash.keys
    end

shared_examples の作成

毎回似たような仕様を書くのは面倒なので,RSpec では共通にできる example を用意することができる. ここでは私が普段よく利用する shared_examples を掲載しておく.

  1. spec/support/shared_examples.rb を以下のように用意する.長くなるのでこれの使い方は次回に回す.
  2. shared_examples_for :presence_validates do |keys|
      # keys を指定しない場合にも,valid だけは確認
      it { is_expected.to be_valid }
      # keys が指定された時には keys を一つずつ外しながら invalid を確認
      if keys
        keys.each do |key|
          context "when #{key} is nil" do
            it do
              subject[key] = nil
              is_expected.to_not be_valid
            end
          end
        end
      end
    end
    
    shared_examples_for :unique_validates do |keys, block|
      keys.each do |key|
        context "when another object has same #{key}" do
          it do
            another_object = block.call
            subject[key] = another_object[key]
            is_expected.to_not be_valid
          end
        end
      end
    end
    
    shared_examples_for :plural_unique_validates do |keys, block|
      context "when another object has same #{keys.join ', '}" do
        it do
          another_object = block.call
          keys.each do |key|
            subject[key] = another_object[key]
          end
          is_expected.to_not be_valid
        end
      end
    
      context "when another object has at least one different keys(#{keys.join ', '})" do
        it do
          keys.each do |except_key|
            another_object = block.call
            compare = true
            keys.each do |key|
              if key == except_key
                compare = false if subject[key] == another_object[key]
              else
                another_object[key] = subject[key]
              end
            end
            expect(another_object).to be_valid if compare
            another_object.destroy
          end
        end
      end
    end
    
    shared_examples_for :destroy_validates do
      context 'should destroy' do
        it do
          klass = subject.class
          expect { subject.destroy }.to change(klass, :count).by -1
        end
      end
    end
    
    shared_examples_for :reject_destroy_validates do
      context 'should not destroy' do
        it do
          klass = subject.class
          expect { subject.destroy }.not_to change(klass, :count)
        end
      end
    end
    
    shared_examples_for :belongs_to do |model, hash|
      has_many_relations = hash[:has_many]
      has_one_relations = hash[:has_one]
      children = hash[:children] || model.to_s.pluralize
      child = hash[:child] || model
      context "when destroying #{model}" do
        if has_many_relations
          has_many_relations.each do |relation|
            it "#{relation}.#{children}.count should decrease" do
              parent = subject.send(relation)
              expect { subject.destroy }.to change(parent.send(children), :count).by(-1)
            end
          end
        end
    
        if has_one_relations
          has_one_relations.each do |relation|
            it "#{relation}.#{child} should be nil" do
              parent = subject.send(relation)
              subject.destroy
              expect(parent.send(child, :true)).to be_nil
            end
          end
        end
      end
    end
    
    shared_examples_for :dependent_destroy do |model, relations|
      relations.each do |relation|
        context "when destroying #{relation}" do
          it 'should be destroyed by dependency' do
            parent = subject.send(relation)
            expect { parent.destroy }.to change(model.to_s.pluralize.classify.constantize, :count).by(-1)
          end
        end
      end
    end
    
    shared_examples_for :destroy_nullify_for_relations do |model, relations|
      relations.each do |relation|
        context "when destroying #{model}.#{relation}" do
          it "#{model} should set null to #{relation}" do
            parent = subject.send(relation)
            parent.destroy
            expect(subject.reload.send(relation)).to be_nil
          end
        end
      end
    end
    
    shared_examples_for :reject_destroy_for_relations do |model, relations|
      relations.each do |relation|
        context "when destroying #{model}.#{relation}" do
          it "#{model} should reject destroying #{relation}" do
            parent = subject.send(relation)
            parent.destroy
            expect(parent.errors[:base].size).to eq(1)
          end
        end
      end
    end
    
    shared_examples_for :mst_block do |block|
      it "should receive above methods(mst)" do
        block.call(targets).each do |method, array|
          print "mst: #{method}\n"
          array.each_slice(2) do |(v, a)|
            expect(subject.send(method, *v)).to eq a
          end
        end
      end
    end
    
    shared_examples_for :msta_block do |block|
      it "should receive above methods(msta)" do
        block.call(targets).each do |method, array|
          print "msta: #{method}\n"
          array.each_slice(2) do |(v, a)|
            expect(subject.send(method, *v)).to match_array a
          end
        end
      end
    end
    
    shared_examples_for :amst_block do |block|
      it "should receive above methods(amst)" do
        block.call(targets).each do |method, array|
          print "amst: #{method}\n"
          array.each_slice(2) do |(v, a)|
            expect(subject.map { |o| o.send(method, *v) }).to eq a
          end
        end
      end
    end
    
    shared_examples_for :amsta_block do |block|
      it "should receive above methods(amsta)" do
        block.call(targets).each do |method, array|
          print "amsta: #{method}\n"
          array.each_slice(2) do |(v, a)|
            answers = subject.map { |o| o.send(method, *v) }
            answers.zip(a).each do |ans, a_a|
              expect(ans).to match_array a_a
            end
          end
        end
      end
    end
    
    shared_examples_for :mst do |method, block|
      it "should receive #{method}" do
        array = block.call
        array.each_slice(2) do |(v, a)|
          expect(subject.send(method, *v)).to eq a
        end
      end
    end
    
    shared_examples_for :msta do |method, block|
      it "should receive #{method}" do
        array = block.call
        array.each_slice(2) do |(v, a)|
          expect(subject.send(method, *v)).to match_array a
        end
      end
    end
    
    shared_examples_for :amst do |method, block|
      it "should receive #{method}" do
        array = block.call
        array.each_slice(2) do |(v, a)|
          expect(subject.map { |o| o.send(method, *v) }).to eq a
        end
      end
    end
    
    shared_examples_for :amsta do |method, block|
      it "should receive #{method}" do
        array = block.call
        array.each_slice(2) do |(v, a)|
          answers = subject.map { |o| o.send(method, *v) }
          answers.zip(a).each do |ans, a_a|
            expect(ans).to match_array a_a
          end
        end
      end
    end
    
    shared_context :prepare_factories do |symbols, block|
      symbols.each do |symbol|
        let!(symbol) { block.call(symbol) }
      end
    end
    
    shared_examples_for :gp do |klass|
      subject { klass } if klass
      it 'should receive gp' do
        expect(subject.gp).to eq [subject, "#{ subject.name.underscore }_id".to_sym]
        expect(subject.gp(false)).to eq [subject, "#{ subject.name.underscore }_id".to_sym]
        expect(subject.gp(true)).to eq [subject, :id]
      end
    end
    
    shared_examples_for :response_status_check do |value|
      it "response should be #{value}" do
        subject.call; expect(response.status).to eq value
      end
    end
    
    shared_examples_for :response_body_includes do |strs|
      it "response body includes #{strs}" do
        subject.call
        Array(strs).each { |str| expect(response.body).to include str }
      end
    end
    
    shared_examples_for :response_body_not_includes do |strs|
      it "response body does not include #{strs}" do
        subject.call
        Array(strs).each { |str| expect(response.body).not_to include str }
      end
    end
    
    shared_examples_for :redirect_to do
      it { subject.call; expect(response).to redirect_to(return_path) }
    end
    
    shared_examples_for :increment_object_by_create do |klass|
      it { expect { subject.call }.to change(klass, :count).by 1 }
    end
    
    shared_examples_for :not_increment_object_by_create do |klass|
      it { expect { subject.call }.not_to change(klass, :count) }
    end
    
    shared_examples_for :decrement_object_by_destroy do |klass|
      it { expect { subject.call }.to change(klass, :count).by -1 }
    end
    
    shared_examples_for :change_object_count_by_create_or_destroy do |klass, n|
      it { expect { subject.call }.to change(klass, :count).by n }
    end
    
    shared_examples_for :change_object_value_by_update do |klass, key, value|
      it do
        pre_value = object.send(key)
        expect { subject.call }.to change { klass.find(object.id).send(key) }.from(pre_value).to(value)
      end
    end
    
    shared_examples_for :not_change_object_value_by_update do |klass, key|
      it { expect { subject.call }.not_to change { klass.find(object.id).send(key) } }
    end
    
    shared_examples_for :flash_notice_message do |str|
      it { subject.call; expect(flash.now[:notice]).to eq str }
    end
    
    shared_examples_for :flash_alert_message do |str|
      it { subject.call; expect(flash.now[:alert]).to eq str }
    end

長くなったので今日はここまで