DEV Community

Cover image for FactoryBot: the secret weapon called @overrides
Povilas Jurčys
Povilas Jurčys

Posted on

FactoryBot: the secret weapon called @overrides

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 :(
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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...>}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)