無気力生活 (ノ ´ω`)ノ ~゜

脱力系エンジニア。てきとーに生きてます。

FactoryBotでfind_or_createしたいよね、という話

# 本記事の環境
ruby: 2.5.0
Rails: 5.1

みなさん、FactoryBot使ってますか?

昔からRails開発するときお世話になっているgemで、気軽にFixtureに相当するものが生やせるのが素敵です。もはやこれナシにはRspecを食せない体になってしまいました。

さて、今回Spec書いてて、複数のhas_oneのassociationがあるモデルを使ったspecを書くことになりました。 関連するモデルが5,6個あり、これをFactoryBotで漏れなく作る定義を書くの面倒だったので、Spec中のメソッドに関連モデルをまとめてcreateしてくれるメソッドを生やしました。

例えば、こんな感じのすごく簡単なやつ(実際はhashの値によってtrait切り替えたり、transientに値投げ込んだりと、下以上にいろいろやってます)

def make_associational_article(**hash)
  company = FactoryBot.create(:company, id: hash[:company_id])
  user = FactoryBot.create(:user, company: company)
  FactoryBot.create(:article, :plain, :latest, user: user)
end

ところが、これを使って書いたSpec走らせると、すぐDuplicate Entryを食らってしまったわけです(´・ω・`)同じcompanyに所属するユーザーの投稿情報がどう見えるか?のケースで発生します。

まあ上の実装見ると臭う人は臭うと思うんですが、単純にcreate叩いてるだけなので同じcompany_id指定すればID競合が発生します。まあそうですよね。

ならば、先に同じIDあるかどうかチェックして、なければ作ればいいじゃない。

def make_associational_article(**hash)
  company = Company.find_by(id: hash[:company_id]) || FactoryBot.create(:company, id: hash[:company_id])
  user = FactoryBot.create(:user, company: company)
  FactoryBot.create(:article, :plain, :latest, user: user)
end

確かにこれで動くけど、クソダサい(´・ω・`)

これ類似パターンあったら全部に書くんか? と、いろいろ考えた結果、FactoryBot.find_or_createがあればよいとの結論に達します。ならば拡張しましょう。

よくネットで見るのは、これ。

gist.github.com

FactoryBotはFactoryGirl名前変わっただけなので、全く同じ方法でメソッドの追加ができます。取り急ぎ動かしてみたら期待通りの挙動をします。

最初はこれで要件を満たせそうな雰囲気したんですが、traitが食えません(´・ω・`) 条件ごとのtrait作って基本の生成ロジックを薄く作ることが多いので、trait食えないのは痛手。



実際にどのように実装しているかを中のコード見ながら考えていたんですが、traitを食わせてfactoryに反映するには、FactoryBot.attributes_for(name, :trait, :trait...)の呼び出しが必要そうです。

FactoryBot.attributes_forの実態はstrategy_syntax_method_registrar.rbにあるんですが、こんな実装になっています。

From: vendor/bundle/ruby/2.5.0/gems/factory_bot-4.10.0/lib/factory_bot/strategy_syntax_method_registrar.rb @ line 19:
Owner: FactoryBot::Syntax::Methods
Visibility: public
Number of lines: 3

define_syntax_method(strategy_name) do |name, *traits_and_overrides, &block|
  FactoryRunner.new(name, strategy_name, traits_and_overrides).run(&block)
end

なるほど。Factoryの定義名がstrategy_nameに入ってきて、traits_and_overridesにtrait名と、上書きする値の定義をいれると、もろもろ定期用済みのattributesがHashで返ってくるんですね。

元々がtraitやtransientを考慮して作ってあるらしく、冒頭で紹介したコードを少し工夫することができれば要件は満たしそうです( ・`ω・´)

なるべく自然な呼び出し方で、traitも使いたい!!となると、こんな実装でいけます。

# spec/support/factory_bot.rb

module FactoryBot
  module Syntax
    module Methods
      # 拡張
      def find_or_create(name, *traits, **attributes, &block)
        klass = FactoryBot.factory_by_name(name).build_class
        # 渡したままで投げ込みたいので、*traitsにして配列展開させない
        attrs = FactoryBot.attributes_for(name, *traits).merge(attributes)

        # 一度対象モデルのインスタンスを作ってfind_byすることで、transientやassociationのパラメータがSQLクエリに吐かれないようにしておく
        find_attrs = klass.new(attrs).attributes

        # unique_indexで値重複あると死ぬ(´・ω・`)unique_keyの指定があるモデルであれば、それのみで抽出チェックし、
        # それ以外は(とりあえず)attributesの全一致で判定している
        if klass.ancestors.include?(ActiveRecord::Base)
          find_attrs.slice!(*klass.connection.indexes(klass.table_name).select(&:unique).flat_map(&:columns).uniq)
        end
        klass.find_by(find_attrs, &block) || FactoryBot.create(name, attrs, &block)
    end

これで改修したmake_associational_articleはこうなりました。

def make_associational_article(**hash)
  company = FactoryBot.find_or_create(:company, id: hash[:company_id])
  user = FactoryBot.find_or_create(:user, company: company)
  FactoryBot.find_or_create(:article, :plain, :latest, user: user)
end

問題のあったcompany以外も適用して、全く同じ設定値のFactoryが生成済みなら新しく作らないようにしました。

上のコードを見て、一部の方は

user = FactoryBot.find_or_create(:user, company: company)
FactoryBot.find_or_create(:article, :plain, :latest, user: user)

で、引数の構成違うけど大丈夫なの?と思われる方もいるかも知れません。

大丈夫なんです。例えば consoleでこんなコード書いてみると動きが分かります。

def test(name, *traits, **attributes)
  p name
  p traits
  p attributes
end

test(:symbol) 
:symbol
[]
{}

test(:symbol, a: 1, b:"aaa")
:symbol
[]
{:a=>1, :b=>"aaa"}

test(:symbol, :trait1, :trait2)
:symbol
[:trait1, :trait2]
{}

test(:symbol, :trait1, :trait2, a:1, b:"aaa", c: 1.2)
:symbol
[:trait1, :trait2]
{:a=>1, :b=>"aaa", :c=>1.2}

いい感じに取り分けてくれるんですよね(`・ω・´)

これでtraitある時、上書きする値がある時といった細かな場合分けも問題なく動作するようになりました。