1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails テストデータ準備の使い分けを実測で比べる — fixture / factory_bot / `User.new` 直書きの3スタイル

1
Posted at

なぜこの記事を書いたか

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カテゴリ:

  1. ユニット検証(DB不要)admin? のメソッド、validates :email, format: のバリデーション
  2. 集計(関連あり)user.total_paid_amount(paid な orders の amount 合計)
  3. スコープ(複数ユーザー)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

観察

  1. fixture が最速: factory_bot より約 27% 速い。クエリ数は約 41% 少ない。
  2. factory_bot は build より遅い: 同じ「テストごとに作る」スタイルなのに、factory_bot のほうが約 6% 遅く、SQL クエリは 8 件多い。
  3. TRANSACTION 件数が多い: fixture 22 / factory 50 / build 46。create のたびに SAVEPOINT が増えるのが効いている。
  4. fixture の INSERT 数はゼロ: 起動時に一括で挿入された後は再挿入されない(後述)。

なぜこの差が出るのか — 根拠を引く

fixture の挙動

Rails 公式の Testing Guide(Section 3.2.5 Fixtures in Action)が
ロードフローを明示している:

Loading involves three steps:

  1. Remove any existing data from the table corresponding to the fixture
  2. Load the fixture data into the table
  3. 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」を続けるか迷っている人がいたら、まず自分のスイートで同じ計測をしてみてほしい。
判断は数字を見てからのほうがいい。


参考


再現コード(抜粋)

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
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?