📝

FactoryBotのcreate_listメソッドとパフォーマンスを考慮した代替案について

に公開

こんにちは、ツクリンクでソフトウェアエンジニアをしているHRT(@hrt_sc)です!

RSpecでテストを書いていると、複数のテストデータを作成したい場面によく遭遇します。
そんな時に重宝するのがFactoryBotのcreate_listメソッドですが、大量データを扱う際にはパフォーマンスが気になることも。
この記事では、create_listメソッドの内部実装、そして高速化のための代替手段まで、実際のベンチマーク結果とともに紹介します。

create_listメソッドとは

FactoryBotのcreate_listメソッドは、指定した数の同じファクトリのレコードをまとめて作成することができる便利なメソッドです。

create_listメソッドを使うことで下記のように書き換えることができます。

create(:user)
create(:user)
create(:user)
↓
create_list(:user, 3)

letメソッドを使って変数として定義する必要がなく、一行で書くことができるため、複数のレコードが存在する状態でテストしたい場合には可読性が良くなります。

また、レコードの保存は行わずにインスタンスのみを生成するbuild_listメソッドもあります。

create_listメソッドの実装について

結論から言うと、create_listメソッドはFactoryBot本体の中でメタプログラミングによって自動生成されています。
具体的には、define_list_strategy_methodというメソッドが使われており、create_listメソッドやbuild_listメソッドなどの「複数生成系」のメソッドがこのメソッドによって定義されています。

https://github.com/thoughtbot/factory_bot/blob/v6.5.0/lib/factory_bot/strategy_syntax_method_registrar.rb#L32-L45

create_listメソッドやbuild_listメソッドなどの定義には「ストラテジーパターン」というデザインパターンが使われており、createbuildなどの生成方法ごとに処理を切り替えられるようになっています。
つまり、create_listというメソッド名と、createというストラテジーを紐付けて、「指定したファクトリを指定回数だけ繰り返し生成し、配列で返す」メソッドを動的に定義しています。

ストラテジーパターンについてはあまり詳しくなかったのですが、下記の記事がとてもわかりやすかったです。
https://qiita.com/hankehly/items/1848f2fd8e09812d1aaf

create_listメソッドの代替案

create_listメソッドは可読性には優れていますが、内部的にcreateメソッドを指定回数繰り返して実行するため、当然INSERTも一件ずつ指定回数分発生することになります。
create_listメソッドで指定する件数が数件程度であればまだしも、数十件、数百件となるとパフォーマンスにも影響してきます。

それを解決するための代替案としては、下記のような方法があります。

insert_allメソッドを使う

insert_allメソッドは Rails6.0で追加されたメソッドで、複数レコードを一括登録(バルクインサート)することで、データベースへの負荷を削減し、処理を高速化します。
FactoryBotのattributes_forメソッドを使って、テスト用の属性値のハッシュを配列化してinsert_allメソッドでバルクインサートするといった流れです。

user_attributes_list = 100.times.map { attributes_for(:user) }
User.insert_all(user_attributes_list)

しかし、insert_allメソッドではバリデーションやコールバックの実行をスキップするため、使い方には注意する必要があります。

importメソッドを使う

importメソッドは「activerecord-import」というgemによって提供されているバルクインサートを行うためのメソッドです。
importメソッドはバリデーションの有無を引数で選択できるため、create_listメソッドと同様にバリデーションを実行したい場合に有用です。

バリデーションを行いたい場合

デフォルトでvalidate: trueになっているので、引数での指定は不要。

users = build_list(:user, 100)
User.import users

バリデーションをスキップしたい場合

users = build_list(:user, 100)
User.import(users, validate: false)

パフォーマンスの比較

Benchmarkを使って、下記3パターンで100件のレコード作成を100回ループして比較してみたいと思います。

  • create_list
  • attributes_for + insert_all
  • build_list + import
require 'benchmark'

# ループ回数
iterations = 100
# 1回あたりに作成するレコード数
record_count = 100

reports = {
  "create_list"             => [],
  "insert_all"              => [],
  "import | validate: true" => [],
  "import | validate: false"=> []
}

iterations.times do |i|
  # --- create_list ---
  reports["create_list"] << Benchmark.measure { create_list(:user, record_count) }
  User.delete_all # 次のテストのためにデータを削除

  # --- insert_all ---
  attrs = record_count.times.map { attributes_for(:user) }
  reports["insert_all"] << Benchmark.measure { User.insert_all(attrs) }
  User.delete_all # 次のテストのためにデータを削除

  # --- import | validate: true ---
  users = build_list(:user, record_count)
  reports["import | validate: true"] << Benchmark.measure { User.import(users, validate: true) }
  User.delete_all # 次のテストのためにデータを削除

  # --- import | validate: false ---
  users = build_list(:user, record_count)
  reports["import | validate: false"] << Benchmark.measure { User.import(users, validate: false) }
  User.delete_all # 次のテストのためにデータを削除
end

# 合計時間を計算
totals = reports.transform_values do |tms_array|
  tms_array.reduce(Benchmark::Tms.new, :+)
end

# 平均時間を計算
averages = totals.transform_values do |total_tms|
  total_tms / iterations
end

label_width = 25

puts "\n【#{iterations}回実行の平均時間】"
puts " " * (label_width + 1) + Benchmark::CAPTION
totals.each_key do |label|
  puts "#{label.ljust(label_width)} #{averages[label]}"
end

puts "\n【#{iterations}回実行の合計時間】"
puts " " * (label_width + 1) + Benchmark::CAPTION
totals.each_key do |label|
  puts "#{label.ljust(label_width)} #{totals[label]}"
end

結果は下記の通りになりました。
insert_allメソッドが最も速度面では優れていることがわかりますね。

【100回実行の平均時間】
                                user     system      total        real
create_list                 0.552637   0.050266   0.602903 (  0.765731)
insert_all                  0.009381   0.000109   0.009490 (  0.010396)
import | validate: true     0.058950   0.000286   0.059236 (  0.060235)
import | validate: false    0.038483   0.000153   0.038636 (  0.039610)

【100回実行の合計時間】
                                user     system      total        real
create_list                55.263683   5.026618  60.290301 ( 76.573149)
insert_all                  0.938094   0.010892   0.948986 (  1.039624)
import | validate: true     5.894987   0.028639   5.923626 (  6.023523)
import | validate: false    3.848317   0.015257   3.863574 (  3.961023)

まとめ

パフォーマンス比較の結果を踏まえると、下記のような使い分けを行うのが良いのではないかと考えています。

  • バリデーションやコールバックをスキップしても問題ない場合はinsert_allメソッド
  • activerecord-import」gemを入れており、バリデーションを行いたい場合はimportメソッド
  • 少量のレコード作成かつ可読性を重視したい場合はcreate_listメソッド

FactoryBotには便利なメソッドが用意されている一方で、それぞれの実装を理解して適切に使い分けることがパフォーマンスを考える上で重要です。
可読性とパフォーマンスのバランスを考慮した選択を心がけたいですね。

Discussion