コード日進月歩

しんくうの技術的な小話、メモ、つれづれ、など

Factory Botで数百を超えるレコードを作る場合はbuild_listとinsert_allと組み合わせると軽快に生成できる

すごく助かったのでメモがてら

環境

$ rails -v
Rails 7.1.2
factory_bot (6.4.4)
  activesupport (>= 5.0.0)
factory_bot_rails (6.4.2)
  factory_bot (~> 6.4)
  railties (>= 5.0.0)

やり方について

今回の事例

下記のように1000レコードを作成するようなテストを書く場合を想定する。

RSpec.describe SoccerMember, type: :model do
  context "1000人のメンバーが作成されたとき" do
    let!(:team) { create(:soccer_team) }
    before do
      ## この部分で SoccerMember を1000レコード分作る ##
    end
    it "正しい順番でならんでいること" do
      ## 略 ##
    end
  end
end

素直にcreate_listを使う場合と課題点

factoryBotにはcreate_listという機能があるので、素直に使うと以下のようになる。

RSpec.describe SoccerMember, type: :model do
  context "1000人のメンバーが作成されたとき" do
    let!(:team) { create(:soccer_team) }
    before do
      create_list(:soccer_member, 1000, soccer_team: team)
    end
    it "正しい順番でならんでいること" do
      ## 略 ##
    end
  end
end

ただし、この場合内部的には SoccerMember.create が1000回繰り返されてしまうため、SQLのINSERTが1000回実行することになるのでパフォーマンスとしてはよろしくはない。

大体案としての build_list + insert_all

やりたいことの内訳としては

  1. FactoryBotに定義したカラムの内容を生成してもらう
  2. それをRDB上にレコードとしてつくる

というような内訳になるので、1と2の作業を分離する。

1. カラムの内容生成

1の作業としてbuild_listでオブジェクトをつくる。

active_record_list = build_list(:soccer_member, 1000,
                                soccer_team: team,
                                created_at: Time.zone.now,
                                updated_at: Time.zone.now)

2. RDB上にレコードを作成する

2の作業として、insert_all で実行を行う。insert_allはActiveRecordのオブジェクトではなく、attributesのhashの配列がほしいので変換してあげる。

attributes_array = active_record_list.each_with_object([]) do |soccer_member , result_array|
  attribute_hash = soccer_member.attributes
  result_array.push(attribute_hash)
end

SoccerMember.insert_all(attributes_array)

もっと簡潔に書くと以下のようにも書ける

SoccerMember.insert_all(active_record_list.map(&:attributes))

注意点として、ActiveRecord独自のattributesを増やしているとそれも .attributes で取り出すときに含まれてしまうので注意。その場合はhashから取り除く必要がある。

実行時間の比較

比較として下記のように実行してみる。

RSpec.describe SoccerMember, type: :model do
  context "1000人のメンバーが作成されたとき" do
    let!(:team) { create(:soccer_team) }
    before do
      start_time = Time.zone.now.to_f

      create_list(:soccer_member, 1000, soccer_team: team)

      end_time = Time.zone.now.to_f
      elapsed_time = (end_time - start_time) * 1000
      puts "create_listのみ          生成時間: #{elapsed_time}ミリ秒"
    end
    it "正しい順番でならんでいること" do
      ## 略 ##
    end
  end

  context "1000人のメンバーが作成されたとき" do
    let!(:team) { create(:soccer_team) }
    before do
      start_time = Time.zone.now.to_f

      now_time = Time.zone.now
      # NOTE: 明示的にcreated_atとupdated_atを入れてあげないとnilになってしまってエラーになるので入れる
      active_record_list = build_list(:soccer_member, 1000,
                                      soccer_team: team,
                                      created_at: now_time,
                                      updated_at: now_time
      )
      SoccerMember.insert_all(active_record_list.map(&:attributes))

      end_time = Time.zone.now.to_f
      elapsed_time = (end_time - start_time) * 1000
      puts "build_list + insert_all 生成時間: #{elapsed_time}ミリ秒"
    end
    it "正しい順番でならんでいること" do
      ## 略 ##
    end
  end
end

そうなると結果は以下のようになり、だいぶ短縮された。

create_listのみ         生成時間: 2795.0141429901123ミリ秒
build_list + insert_all 生成時間: 140.3510570526123ミリ秒

参考リンク