Reputation: 6888
I have a User model that belongs to a Group. Group must have unique name attribute. User factory and group factory are defined as:
Factory.define :user do |f|
f.association :group, :factory => :group
# ...
end
Factory.define :group do |f|
f.name "default"
end
When the first user is created a new group is created too. When I try to create a second user it fails because it wants to create same group again.
Is there a way to tell factory_girl association method to look first for an existing record?
Note: I did try to define a method to handle this, but then I cannot use f.association. I would like to be able to use it in Cucumber scenarios like this:
Given the following user exists:
| Email | Group |
| [email protected] | Name: mygroup |
and this can only work if association is used in Factory definition.
Upvotes: 78
Views: 33027
Reputation: 12693
Another way to do it (that will work with any attribute and work with associations):
# config/initializers/factory_bot.rb
#
# Example use:
#
# factory :my_factory do
# change_factory_to_find_or_create
#
# some_attr { 7 }
# other_attr { "hello" }
# end
#
# FactoryBot.create(:my_factory) # creates
# FactoryBot.create(:my_factory) # finds
# FactoryBot.create(:my_factory, other_attr: "new value") # creates
# FactoryBot.create(:my_factory, other_attr: "new value") # finds
module FactoryBotEnhancements
def change_factory_to_find_or_create
to_create do |instance|
# Note that this will ignore nil value attributes, to avoid auto-generated attributes such as id and timestamps
attributes = instance.class.find_or_create_by(instance.attributes.compact).attributes
instance.attributes = attributes.except('id')
instance.id = attributes['id'] # id can't be mass-assigned
instance.instance_variable_set('@new_record', false) # marks record as persisted
end
end
end
# This makes the module available to all factory definition blocks
class FactoryBot::DefinitionProxy
include FactoryBotEnhancements
end
The only caveat is that you can't find by nil values. Other than that, it works like a dream
Upvotes: 0
Reputation: 25122
I was looking for a way that doesn't affect the factories. Creating a Strategy
is the way to go, as pointed out by @Hiasinho. However, that solution didn't work for me anymore, probably the API changed. Came up with this:
module FactoryBot
module Strategy
# Does not work when passing objects as associations: `FactoryBot.find_or_create(:entity, association: object)`
# Instead do: `FactoryBot.find_or_create(:entity, association_id: id)`
class FindOrCreate
def initialize
@build_strategy = FactoryBot.strategy_by_name(:build).new
end
delegate :association, to: :@build_strategy
def result(evaluation)
attributes = attributes_shared_with_build_result(evaluation)
evaluation.object.class.where(attributes).first || FactoryBot.strategy_by_name(:create).new.result(evaluation)
end
private
# Here we handle possible mismatches between initially provided attributes and actual model attrbiutes
# For example, devise's User model is given a `password` and generates an `encrypted_password`
# In this case, we shouldn't use `password` in the `where` clause
def attributes_shared_with_build_result(evaluation)
object_attributes = evaluation.object.attributes
evaluation.hash.filter { |k, v| object_attributes.key?(k.to_s) }
end
end
end
register_strategy(:find_or_create, Strategy::FindOrCreate)
end
And use it like this:
org = FactoryBot.find_or_create(:organization, name: 'test-org')
user = FactoryBot.find_or_create(:user, email: '[email protected]', password: 'test', organization: org)
Upvotes: 5
Reputation: 148
To ensure FactoryBot's build
and create
still behaves as it should, we should only override the logic of create
, by doing:
factory :user do
association :group, factory: :group
# ...
end
factory :group do
to_create do |instance|
instance.id = Group.find_or_create_by(name: instance.name).id
instance.reload
end
name { "default" }
end
This ensures build
maintains it's default behavior of "building/initializing the object" and does not perform any database read or write so it's always fast. Only logic of create
is overridden to fetch an existing record if exists, instead of attempting to always create a new record.
I wrote an article explaining this.
Upvotes: 7
Reputation: 382
I had a similar problem and came up with this solution. It looks for a group by name and if it is found it associates the user with that group. Otherwise it creates a group by that name and then associates with it.
factory :user do
group { Group.find_by(name: 'unique_name') || FactoryBot.create(:group, name: 'unique_name') }
end
I hope this can be useful to someone :)
Upvotes: 7
Reputation: 3174
You can to use initialize_with
with find_or_create
method
FactoryGirl.define do
factory :group do
name "name"
initialize_with { Group.find_or_create_by_name(name)}
end
factory :user do
association :group
end
end
It can also be used with id
FactoryGirl.define do
factory :group do
id 1
attr_1 "default"
attr_2 "default"
...
attr_n "default"
initialize_with { Group.find_or_create_by_id(id)}
end
factory :user do
association :group
end
end
For Rails 4
The correct way in Rails 4 is Group.find_or_create_by(name: name)
, so you'd use
initialize_with { Group.find_or_create_by(name: name) }
instead.
Upvotes: 114
Reputation: 656
You can also use a FactoryGirl strategy to achieve this
module FactoryGirl
module Strategy
class Find
def association(runner)
runner.run
end
def result(evaluation)
build_class(evaluation).where(get_overrides(evaluation)).first
end
private
def build_class(evaluation)
evaluation.instance_variable_get(:@attribute_assigner).instance_variable_get(:@build_class)
end
def get_overrides(evaluation = nil)
return @overrides unless @overrides.nil?
evaluation.instance_variable_get(:@attribute_assigner).instance_variable_get(:@evaluator).instance_variable_get(:@overrides).clone
end
end
class FindOrCreate
def initialize
@strategy = FactoryGirl.strategy_by_name(:find).new
end
delegate :association, to: :@strategy
def result(evaluation)
found_object = @strategy.result(evaluation)
if found_object.nil?
@strategy = FactoryGirl.strategy_by_name(:create).new
@strategy.result(evaluation)
else
found_object
end
end
end
end
register_strategy(:find, Strategy::Find)
register_strategy(:find_or_create, Strategy::FindOrCreate)
end
You can use this gist. And then do the following
FactoryGirl.define do
factory :group do
name "name"
end
factory :user do
association :group, factory: :group, strategy: :find_or_create, name: "name"
end
end
This is working for me, though.
Upvotes: 8
Reputation: 1051
I'm using exactly the Cucumber scenario you described in your question:
Given the following user exists:
| Email | Group |
| [email protected] | Name: mygroup |
You can extend it like:
Given the following user exists:
| Email | Group |
| [email protected] | Name: mygroup |
| [email protected] | Name: mygroup |
| [email protected] | Name: mygroup |
This will create 3 users with the group "mygroup". As it used like this uses 'find_or_create_by' functionality, the first call creates the group, the next two calls finds the already created group.
Upvotes: 0
Reputation: 6888
I ended up using a mix of methods found around the net, one of them being inherited factories as suggested by duckyfuzz in another answer.
I did following:
# in groups.rb factory
def get_group_named(name)
# get existing group or create new one
Group.where(:name => name).first || Factory(:group, :name => name)
end
Factory.define :group do |f|
f.name "default"
end
# in users.rb factory
Factory.define :user_in_whatever do |f|
f.group { |user| get_group_named("whatever") }
end
Upvotes: 19
Reputation: 22663
Usually I just make multiple factory definitions. One for a user with a group and one for a groupless user:
Factory.define :user do |u|
u.email "email"
# other attributes
end
Factory.define :grouped_user, :parent => :user do |u|
u.association :group
# this will inherit the attributes of :user
end
THen you can use these in your step definitions to create users and groups seperatly and join them together at will. For example you could create one grouped user and one lone user and join the lone user to the grouped users team.
Anyway, you should take a look at the pickle gem which will allow you to write steps like:
Given a user exists with email: "[email protected]"
And a group exists with name: "default"
And the user: "[email protected]" has joined that group
When somethings happens....
Upvotes: 2