TL;DR
factory :country do
to_create do |instance|
instance.id = Country.find_or_create_by(name: instance.name, code: instance.code).id
instance.reload
end
end
How did I come up with that solution? See below.
There seems to be no clear documentation on how to go about this. So, here's what I tried.
Before we begin, let's prepare our test environment, we will be using a Country
model, with attributes name
and code
:
# models/country.rb
class Country
validates :name, presence: true
validates :code, presence: true, length: { is: 2 }, uniqueness: { case_sensitive: false } # ISO-3166-ALPHA-2
end
# spec/factories/countries.rb
FactoryBot.define do
factory :country do
name { 'Canada' }
code { 'CA' }
end
end
Now, we're all good engineers and good engineers write unit tests. This will make verifying results easier. The RSpec.
Attempt 1. Overriding the initialize_with
block
This is the most voted answer on a question posted on StackOverflow. We can override a factory's default initialization behavior with:
factory :country do
initialize_with { Country.find_or_create_by(name: name, code: code) }
end
While the solution does work, it breaks the factory. Calling build(:country)
will now create a record in database if it does not exist.
Looking at the comments section, we see a suggestion to use .find_or_initialize_by
:
factory :country do
initialize_with { Country.find_or_initialize_by(name: name, code: code) }
end
This partially restores behaviour of build
as it does not create a record anymore. However, it still returns a persisted record if found.
This approach breaks the original behaviour of build
, which is to only initialize a record, and not return a persisted record. There is also a performance cost of accessing the database to perform a Country.find_by
. An ideal solution would be to only access the database on create
, to create the record. We should not need to read or write to database on build
.
Attempt 2. Overriding the to_create
block (v1)
Digging into FactoryBot documentation, we see a section on Custom Methods to Persist Objects.
By default, creating a record will call
save!
on the instance; since this may not always be ideal, you can override that behavior by definingto_create
on the factory
Let's try it out:
factory :country do
to_create { |instance| Country.find_or_create_by(name: instance.name, code: instance.code) }
end
From the results, we see that build
and create
works as expected.
However, create
seems to return the factory-pre-save instance instead of the created instance, which explains why the country.id
is nil
, but Country.count
increased. 🤔
Alright, we're close. All we need is a way to set the attributes back to it's own instance after calling the .find_or_create_by
so it can assume identity of the found-or-created object.
Attempt 3. Overriding the to_create
block (v2)
Now, we know the instance
in to_create { |instance| ... }
is the model object itself, which is an ActiveRecord model. Hence, we should be able to sort-of-force-update-the-attributes like so:
factory :country do
to_create { |instance| instance.attributes = Country.find_or_create_by(name: instance.name, code: instance.code).attributes }
end
Oh 💩it worked. But one last hurdle - country.persisted?
returns false. Easiest way to fix this is by .reload
-ing the instance:
factory :country do
to_create do |instance|
instance.attributes = Country.find_or_create_by(name: instance.name, code: instance.code).attributes
instance.reload
end
end
Yay, all green! 🚦
But, #reload
is not the ideal solution, as it performs an unnecessary database read, when all we want is for #persisted?
to behave correctly. Looking at the Rails documentation, we find that #persisted?
is the inverse of #new_record?
, which takes its value from instance variable @new_record
.
Which means, we could possibly do this:
factory :country do
to_create do |instance|
instance.attributes = Country.find_or_create_by(name: instance.name, code: instance.code).attributes
instance.instance_variable_set('@new_record', false)
end
end
Damn it worked 😎
Now, I agree this is hacky. Probably should just stick to .reload
to be safe. If this is not relevant to you, probably could just ignore the .reload
altogether.
Cover image by Freepik.
Top comments (9)
Hi !
Thanks for the article, nevertheless, it is working great when you don't have any other attributes in your object that needs to be validated.
I made a little git project to illustrate : github.com/ShamoX/country_test
commit: fd40c203 show the introduction of the problem.
Adding the new attribute here in the find_or_create_by doesn't work because we want inhabitants number to be random.
My solution then consist to first have to look for an existing entry only on the unique field, and then return the old record with it's own value...
What do you think ?
Hey Roland!
Firstly, apologies for the late reply. 🙏🏻
I'm happy that the article helped you!
If I understand your requirements correctly - you want to be able to find_by "country code", but create with random "inhabitants". Something lesser known in Rails - there is a method exactly just for that:
This would find_by "code", and return if exists. Else, it will create with "code", along with "name" and "inhabitants".
I wrote that off the top of my mind. Can you test to make sure it works?
I solved it a bit differently:
Not sure if it makes any difference with your sample above, but using
instance.attributes
makes it more generic as we don't have to explicitly mention each attribute.That’s neat! Yeah, this does seem simpler. Off the top of my head, I can’t think of any differences.
I went with a variation:
Nice, I assume it still works because the model will reload based on the assigned ID. This approach might be more performant than mine, because I attempt to update all attributes.
Great article! Didn't know you could do this on FactoryBot. Is this similar to the FactoryBot's
use_parent_strategy
?use_parent_strategy
is something different (more info on their docs), it tells FactoryBot whether or not to use the parent's strategy (eg: build or create).For example, given a model User and Country (User belongs_to Country), when use_parent_strategy=true, calling
build(:user)
will also build (instead of create) the associated Country, because it follows the "parent strategy ofbuild
".However, FactoryBot custom strategies is something different that I want to explore. Perhaps defining a new
find_or_create
strategy.nice !!
it works !!