はじめに
RSpec などのテストで多数のレコードを作りたいとき、FactoryBot の create_list や build_list を使うとまとめて生成できて便利です。たとえば 100 件のレコードをまとめて作りたい場合は、次のように書きます。
create_list(:food, 100)
これで Food のレコードが 100 件作成・保存されます。ただし、もし 「レコードごとに別の値をセットしたい」 という場合はどうでしょうか?
たとえば、消費期限 (expiration_date) を 1 日ずつずらした 100 件のレコードを作りたい場合、次のように create_list にブロックを渡してみたくなるかもしれません。
create_list(:food, 100) do |food, i|
food.expiration_date = Date.today + i
end
しかし、設定したはずのexpiration_dateを見てみると…
Food.pluck(:expiration_date)
# => [nil, nil, nil,,,,,]
せっかくブロックで expiration_date を設定したのにも関わらず、(初期値を設定していないこともあり)全てnilになってしまいます。この理由は create_list の挙動にあります。
create_list の挙動
create_list(:food, 100) の内部処理は、ざっくり以下のような流れです。
- food オブジェクトを FactoryBot で build(生成)
- food を save(DB 保存)
- ブロックが渡されていれば、その 作成済みオブジェクト をブロックに渡す
- ブロック内でオブジェクトを変更しても、追加の save は実行されない
つまり、「レコードが保存されてからブロックが呼ばれる」 ため、ブロック内で属性を設定しても、その変更はデータベースに反映されません。
これを回避するには、ブロックの中で明示的に save! する必要があります。
create_list(:food, 100) do |food, i|
food.expiration_date = Date.today + i
food.save! # ここで再度セーブしないとDBに反映されない
end
この方法であれば、それぞれ異なる expiration_date を持つ 100 件のレコードを作れます。ただし、1 つのオブジェクトに対して 2 回 save を行うことになり非効率です。
build_list + save! で作る場合
先述の問題を解決するには、以下のようにbulid_listを使うと良いです。
build_list(:food, 100) do |food, i|
food.expiration_date = Date.today + i
food.save!
end
この書き方なら「生成 → 保存」の流れが素直になりますが、saveが100回走るため、SQL の発行回数も 100 回になります。テストデータがもっと膨大になると、パフォーマンスに悪影響を及ぼしかねません。
バルクインサート(insert_all)でまとめて保存する
大量データを扱う場合は、まとめて保存できる バルクインサート を利用する方法があります。
Rails 6 以降なら、insert_all を使ってまとめて挿入できます。
foods = build_list(:food, 100) do |food, i|
food.expiration_date = Date.today + i
end
Food.insert_all(foods.map(&:attributes))
これなら、SQL の発行は 1 回 で済むため、大量のテストデータを高速に作成できます。
ただし、insert_all はレコードのコールバックやバリデーションは通らないという点に注意が必要です。厳密にコールバックやバリデーションを通す必要があるなら、この方法は使わずに従来の save をするなどの工夫が要ります。
まとめ
- create_list にブロックを渡すと、オブジェクトは一度保存された後でブロックが呼ばれる
→ ブロック内で変更した属性がデータベースに反映されない - 強引にブロック内で再度 save! すれば反映されるが、1 オブジェクトにつき 2 回保存することになる
- そもそも一括で属性を変えて保存したいだけなら build_list + save! の方が直感的
- 大量データや性能重視の場合は build_list でオブジェクトを作ってから、insert_all で一度に保存すると高速