Intro
FactoryBot is a great tool that simplifies test setup logic by hiding object build complexity within factory files. Thus, instead of repeating the same model logic again and again, you simply write something like this:
create(:restaurant, :fancy)
create(:restaurant_order, :in_fancy_restaurant)
create(:dish_order, :in_fancy_restaurant, :steak)
Properly written factories look good and are more descriptive than a bunch of code lines that just build valid models. The factory will do all the dirty work for you and hide the model build login inside the factory file. The downside: in many cases, you are also the person who needs to create and maintain that good-looking-from-outside factory file.
As with every tool, there are some side effects that you should be aware of if you do not want to ruin your day thinking about weird things happening to you and what's the meaning of life.
In this post, we are going to focus on one of such challenges. We will talk about complex many-to-many, cross-dependent factories and how to properly create them.
So, how hard it can be to create a factory with multiple dependencies? Easy!.. If you know how to use factory bot internals in your favor. Read on and I will show you how!
Problem with many-to-many dependencies
Everything is fun and games until you introduce many-to-many associations. Imagine an Uber Eats-like app where you can choose your lunch from various restaurants and place orders. Each restaurant has its dishes and separate orders - you can't make a single order with dishes from different restaurants. It is a quite common limitation. A simplified relationship between models will look like this:
class Restaurant
has_many :dishes
end
class Order
belongs_to :restaurant
end
class Dish
belongs_to :restaurant
end
class OrderedDish
belongs_to :order
belongs_to :dish
end
And factories will look like this:
FactoryBot.define do
factory :restaurant do
name { 'TastyPizza' }
end
factory :order do
restaurant { association(:restaurant) }
end
factory :dish do
restaurant { association(:restaurant) }
end
factory :ordered_dish do
order { association(:order) }
dish { association(:dish) }
end
end
At first glance, it looks plain and simple, but hold your horses! Look closer to the :ordered_dish
factory. There are hidden associations in it and they work incorrectly. When you create :ordered_dish
you will also create one Order record, one Dish record, and... wait for it... two Restaurant records:
ordered_dish = create(:ordered_dish)
order_restaurant = ordered_dish.order.restaurant
dish_restaurant = ordered_dish.dish.restaurant
order_restaurant == dish_restaurant #=> false
It's like ordering KFC's chicken wings from McDonalds. It would be nice, but it's not how life works.
The simplest solution involves modifying our factory to reuse the restaurant instance from one of the associations, as shown below:
factory :ordered_dish do
order { association(:order) }
dish { association(:dish, order.restaurant) }
end
This is great, but there is a catch: each developer has to know that you are not allowed to pass the custom dish
attribute otherwise you will end up with the same two-Restaurants-instead-of-one problem:
dish = create(:dish)
ordered_dish = create(:ordered_dish, dish: dish)
dish.restaurant == ordered_dish.order.restaurant #=> false
This happens because passing the custom dish
attribute does not trigger the dish { association(:dish, order.restaurant) }
block, so the restaurant
instance is not shared. You could make a recursive dependency like this:
factory :ordered_dish do
order { association(:order, restaurant: dish.restaurant) }
dish { association(:dish, order.restaurant) }
end
In this case, it will work nicely as long as you pass custom order
or dish
, but it will give you a "stack level too deep" error if you try to create ordered_dish
with default associations:
create(:ordered_dish) # => error :(
It looks like there is no silver bullet in this situation. You have to choose and no choice is perfect. If only we could know in advance which attribute is non-default and passed by the user...
And there is a way how to know this.
Using @overrides for two-way dependencies
Have you ever wondered how FactoryBot DSL works? I mean, you write the name of an attribute or association and you do not get an undefined method
error. Well, FactoryBot developers put a lot of thought into that, used the method_missing
technique, and ensured that their DSL handler which they call Evaluator has almost zero predefined methods. Here is a stripped version of that class:
class FactoryBot::Evaluator
class_attribute :attribute_lists
private_instance_methods.each do |method|
undef_method(method) unless method.match?(/^__|initialize/)
end
def method_missing(method_name, ...)
if @instance.respond_to?(method_name)
@instance.send(method_name, ...)
else
SyntaxRunner.new.send(method_name, ...)
end
end
# ...
end
So there are only a few methods that you can't use in your factories. If you need to access some Evaluator-specific variables, you need to use instance variables. I do not like using instance variables, but given the context - I understand why this is an exception. I'm telling you this because I would like to introduce you to the @overrides
instance variable that you could use to check which attributes were customized. Its name is self-explaining. It's a Hash that contains all the custom attributes that were passed when you create a factory.
create(:oder_dish, order: create(:order))
# @overrides will be { order: #<Oder:0x00007f...>}
Now, with the help of @overrides
, we could check which attribute was customized and assign restaurant
accordingly. The code won't be as pretty as before, but it will work like a charm:
factory :ordered_dish do
order do
restaurant = @overrides[:dish]&.restaurant
restaurant ||= association(:restaurant)
association(:order, restaurant: restaurant) }
end
dish do
restaurant = @overrides[:order]&.restaurant
restaurant ||= association(:restaurant)
association(:dish, restaurant: restaurant) }
end
end
You can make this code a bit cleaner if you are OK with using transient attributes:
factory :ordered_dish do
transient do
restaurant do
@overrides[:order]&.restaurant ||
@overrides[:dish]&.restaurant ||
association(:restaurant)
end
end
order { association(:order, restaurant: restaurant) }
dish { association(:dish, restaurant: restaurant) }
end
The final result is longer, but it does not have any complex logic in it. It simply checks multiple places for restaurant presence. Most importantly, it makes the factory work flawlessly no matter how you use it. And this is the most important part. We did the impossible - we managed to deal with the cross-dependency challenge.
Summary
In our journey with FactoryBot, we've explored how to tackle complex scenarios like many-to-many associations and cross-dependencies between factories. It all started with a simple idea of creating clean, reusable factory code to streamline test setup.
We learned that creating factories isn't just about writing straightforward code. When dealing with interchained relationships things can get tricky. For instance, we discovered the problem of accidentally creating duplicate records when we shouldn't.
To overcome this challenge, we had to dive deep into FactoryBot's internals. We explored using the @overrides
instance variable, which helped us identify customized attributes passed during factory creation. By leveraging this knowledge, we were able to craft factories that adapted intelligently to different scenarios.
Remember, even in the world of coding, challenges are opportunities in disguise. By understanding the tools at our disposal and delving into their inner workings, we can conquer any coding puzzle that comes our way. So, keep exploring, keep learning, and keep building!
Until next time, happy coding!
Top comments (0)