なぜこの記事を書いたか
5年ほど Rails を書いていて、テストデータの準備は途中から factory_bot 全部派 になっていた。
fixture は古い、User.new は手抜き、と内心で思っていた節がある。
でも数年経って、別の理由で気持ちが悪くなってきた。
- factory が「裏で何を作っているか」がコードから読めない
-
create(:order)の裏で User と Item を連鎖生成していて、テストが遅い - 「このテストは何を検証しているのか」が、factory の trait 名から逆引きになる
このまま漫然と factory_bot 一択を続けてよいのか確信が持てなくなったので、
同じテストを3つのスタイルで書き、実行時間と SQL クエリ数を実測 してみた。
この記事は、その計測結果と、自分なりの使い分け基準の更新履歴です。
実験設計
環境
- Ruby 3.4.5
- Rails 7.2.3.1
- RSpec 7.1
- factory_bot_rails 6.4
- SQLite3(開発機メモリ上)
- macOS arm64 / M系チップ
比較する3スタイル
| 略称 | 説明 |
|---|---|
| fixture |
spec/fixtures/users.yml を YAML で定義し fixtures :users で参照 |
| factory_bot |
create(:user, :admin) のようにファクトリ経由で生成 |
| build(個別生成) | 各テスト内で User.new(...) / User.create!(...) を直書き |
テスト内容
User モデル(has_many :orders)に対して、3スタイルそれぞれで 同じ10ケース を書いた。
ケースは大きく3カテゴリ:
-
ユニット検証(DB不要) —
admin?のメソッド、validates :email, format:のバリデーション -
集計(関連あり) —
user.total_paid_amount(paid な orders の amount 合計) -
スコープ(複数ユーザー) —
User.where(role: "admin")
計測手法
- 各スペックファイルを 独立した RSpec プロセスで5回実行 し、平均と中央値を取る
-
ActiveSupport::Notifications.subscribe("sql.active_record")で SQL 発行を捕捉し、
INSERT / SELECT / UPDATE / DELETE / TRANSACTION / OTHERに分類してカウント - 計測は
before(:suite)からafter(:suite)までの経過時間(Rails 起動時間は含まない) - 各実行前に
User.delete_all/Order.delete_allで初期状態を揃える
結果
サマリ(10ケース × 5回平均)
| Style | elapsed avg (ms) | elapsed median (ms) | total SQL | INSERT | SELECT | TRANSACTION |
|---|---|---|---|---|---|---|
| fixture | 20.59 | 20.13 | 49 | 0 | 19 | 22 |
| factory_bot | 28.14 | 27.79 | 83 | 15 | 18 | 50 |
| build | 26.56 | 26.43 | 75 | 13 | 16 | 46 |
観察
- fixture が最速: factory_bot より約 27% 速い。クエリ数は約 41% 少ない。
- factory_bot は build より遅い: 同じ「テストごとに作る」スタイルなのに、factory_bot のほうが約 6% 遅く、SQL クエリは 8 件多い。
-
TRANSACTION 件数が多い: fixture 22 / factory 50 / build 46。
createのたびにSAVEPOINTが増えるのが効いている。 - fixture の INSERT 数はゼロ: 起動時に一括で挿入された後は再挿入されない(後述)。
なぜこの差が出るのか — 根拠を引く
fixture の挙動
Rails 公式の Testing Guide(Section 3.2.5 Fixtures in Action)が
ロードフローを明示している:
Loading involves three steps:
- Remove any existing data from the table corresponding to the fixture
- Load the fixture data into the table
- Dump the fixture data into a method in case you want to access it directly
つまり fixture は テストスイート開始時に一度だけ DELETE + INSERT され、その後は各テストの
トランザクションが上に重なる。同ガイド Section 3.3 によれば:
By default, Rails automatically wraps tests in a database transaction that is rolled back once completed.
各テストはこの「fixture が入った状態」を起点に、自分の変更はロールバックされる。
結果として、テスト本体での INSERT は 0、SELECT のみ で済むケースが多い。
今回の計測で INSERT = 0 だったのはこのため。
factory_bot の挙動
factory_bot 公式の Build strategies の項(GETTING_STARTED.md)には次のように書かれている:
build— Returns a User instance that's not saved
create— Returns a saved User instance
create は1回ごとに INSERT を発行し、Rails のテストトランザクションの中で SAVEPOINT を発行する。
今回の factory_bot スペックには create(:user, ...) が 13 回、create(:order, ...) が 5 回ある。
INSERT 数が 15 件、TRANSACTION(SAVEPOINT / RELEASE 含む)が 50 件に膨らんでいる理由は
ここに対応する。
User.new 直書きの挙動
User.new は DB を一切叩かない(バリデーションだけならこれで十分)。
User.create! は INSERT を発行するが、factory_bot のような「定義の解決」「trait の合成」のオーバーヘッドがない分、
わずかに速い(今回は約 1.5 ms / 8 クエリ程度の差)。
コードの読みやすさはどうか
実行コストだけ見ると fixture 一択に見えるが、コードの読みやすさ・修正容易性 は別軸。
スペック1ファイル内の記述量(行数)
| Style | spec 行数 | 付随データ(yml / factory) | 合計 |
|---|---|---|---|
| fixture | 59 | 14 + 24 = 38 | 97 |
| factory_bot | 69 | 23 + 10 = 33 | 102 |
| build | 69 | 0 | 69 |
build は付随ファイルが要らないので、合計の行数は最も少ない。
ただし「同じ初期化を10回書く」のが嫌で、結局ヘルパに切り出したくなる衝動と戦う必要がある。
「テストが何をしているか」の読みやすさ
3スタイルで同じユニットテストを並べると、違いがはっきりする。
fixture:
it "returns true for admin role" do
expect(users(:alice).admin?).to be true
end
「Alice が admin である」という前提を読み手は別ファイル(users.yml)に見に行かないと確認できない。
factory_bot:
it "returns true for admin role" do
user = create(:user, :admin)
expect(user.admin?).to be true
end
:admin という trait 名で意図は伝わるが、create は実際には DB に書き込んでいる
(このユニットテストでは不要な副作用)。
build(個別):
it "returns true for admin role" do
user = User.new(name: "X", email: "x@example.com", role: "admin")
expect(user.admin?).to be true
end
このテストだけ見れば「role=admin の User に対して admin? が true を返す」と一目で読める。
代償として、検証に関係ない name / email の値も書くハメになる。
自分の結論
「全部 factory_bot」でも「全部 fixture」でもなく、テストの性質で使い分ける。
1. マスタ系データ(変更頻度が低く、ほぼ全テストで必要) → fixture
- 通貨コード、決済方式コード、国マスタなど
- 「ベンダーが過去に返してきたエラーコードの実例集」のような 仕様の参考データ
- 公式ガイドも
"best managed when only used for default data that can be applied to the common case"と言っている
2. ドメインの典型的な状態を起点に検証したい → factory_bot
- 「オーソリ済みの注文」「3DS完了済みのユーザー」のような業務状態
- trait で状態のバリエーションを表現
- ただし ユニットテストには
createではなくbuildを使う(INSERT 0 件で済む)
3. モデル単体の挙動・値オブジェクトのテスト → 個別 build
- DBが要らない、関連も要らない、属性の境界値を見たいだけ
- factory を使うと「なぜこの factory を呼んでいるか」が読み手のノイズになる
-
User.new(...)のほうがそのテスト1ファイルで完結する
使い分けを判断するときに自問していること
| 問い | yes → 寄せる先 |
|---|---|
| マスタ/全テスト共通の前提か | fixture |
| 「業務的に意味のある状態」を起点にしたいか | factory_bot |
| 検証対象は1つの属性 or 1つのメソッドか | build |
| そのテストで DB 永続化は本当に必要か | No なら build / factory_bot の build
|
計測してみて、自分が更新できたこと
書く前は「fixture が速いのは知ってるが、保守性で factory_bot が勝つはず」と思っていた。
実測してみて、この主張は条件付きでしか正しくない ことが分かった。
- スイートが小さいうちは差は小さい(今回は最大でも約 8ms)
- スイートが 1000 件規模になると、1テスト 5ms の差が積み上がって体感に来る
- factory_bot の "create がデフォルト" 文化が、無自覚に INSERT を量産している
特に最後の発見は、自分のプロジェクトのテストを見直すきっかけになった。
ユニットテストで create を使っている箇所は、ほぼすべて build で置き換え可能だった。
残った疑問
- テスト数が増えるとどう変わるか: 今回は10ケースだけで計測。100 / 1000 ケース規模での挙動はまだ計測していない
- fixture の保守性は本当に悪いのか: 「YAML が肥大化して読めなくなる」と言われるが、定量的な比較は今回していない
-
factory_bot を
build中心で運用したら fixture に近づくか: 構造的にはそうなるはず。次の検証ネタにしたい
「全部 factory_bot」を続けるか迷っている人がいたら、まず自分のスイートで同じ計測をしてみてほしい。
判断は数字を見てからのほうがいい。
参考
- Rails Guides: Testing Rails Applications — 公式の fixture / transactional tests 仕様
- factory_bot GETTING_STARTED.md — 公式の build strategies / traits / sequences
再現コード(抜粋)
SQL 計測フック
# spec/support/sql_counter_hook.rb
RSpec.configure do |config|
config.before(:suite) do
SqlPerExample.totals = Hash.new(0)
$sql_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
$sql_subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
ev = ActiveSupport::Notifications::Event.new(*args)
next if ev.payload[:name] == "SCHEMA"
op = case ev.payload[:sql]
when /\Abegin/i, /\Acommit/i, /\Arollback/i, /\Asavepoint/i, /\Arelease/i
"TRANSACTION"
when /\Ainsert/i then "INSERT"
when /\Aselect/i then "SELECT"
when /\Aupdate/i then "UPDATE"
when /\Adelete/i then "DELETE"
else "OTHER"
end
SqlPerExample.totals[op] += 1
SqlPerExample.totals[:total] += 1
end
end
end
ベンチマークランナー(抜粋)
# bin/benchmark.rb
SPECS = {
"fixture" => "spec/models/user_with_fixture_spec.rb",
"factory" => "spec/models/user_with_factory_spec.rb",
"build" => "spec/models/user_with_build_spec.rb"
}
SPECS.each do |label, path|
RUNS.times do |i|
out, _, _ = Open3.capture3({ "SPEC_LABEL" => label },
"bundle exec rspec #{path} --no-color")
# 出力から elapsed / SQL カウントをパースして集計
end
end