Understanding Factory Bot syntax by coding your own Factory Bot

by Jason Swett,

What we’re going to do and why

When you look at a Factory Bot factory definition, the syntax might look somewhat mysterious. Here’s an example of such a factory.

FactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

The goal of this tutorial is to demystify this syntax. The way we’ll do this is to write our own implementation of Factory Bot from scratch. Or, more precisely, we’ll write an implementation of something that behaves indistinguishably from Factory Bot for a few narrow use cases.

Concepts we’ll learn about

Blocks

Factory Bot syntax makes heavy use of blocks. In this post we’ll learn a little bit about how blocks work.

Message sending

We’ll learn the nuanced distinction between calling a method on an object and sending a message to an object.

method_missing

We’ll learn how to use Ruby’s method_missing feature so that we can define methods for objects dynamically.

Our objective

Our goal with this post will be to write some code that makes the factory below actually work. Notice that this factory is indistinguishable from a Factory Bot factory except for the fact that it starts with MyFactoryBot rather than FactoryBot.

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

In addition to this factory code, we’ll also need some code that exercises the factory.

Exercising the factory

Here’s some code that will exercise our factory to make sure it actually works. Just like how our factory definition mirrors an actual Factory Bot factory definition, the below code mirrors how we would use a Factory Bot factory.

user = MyFactoryBot.create(:user)
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"

After we get our factory working properly, the above code should produce the following output.

First name: John
Last name: Smith

Let’s get started.

How to follow along

If you’d like to code along with this tutorial, I have a sample project that you can use.

The code in this tutorial depends on a certain Rails project, so I’ve created a GitHub repo at https://github.com/jasonswett/my_factory_bot where there’s a Dockerized Rails app, the same exact Rails app that I used to write this tutorial. You’ll find instructions to set up the project on the GitHub page.

The approach

We’ll be writing our “Factory Bot clone” code using a (silly) development methodology that I call “error-driven development”. Error-driven development works like this: you write a piece of code and try to run it. If you get an error when you run the code, you write just enough code to fix that particular error, and nothing more. You repeat this process until you have the result you want.

The reason I sometimes like to code this way is that it prevents me from writing any code that hasn’t been (manually) tested. Surprisingly enough, this “methodology” actually works pretty well.

Building the factory

The first thing to do is to create a file called my_factory_bot.rb and put it at the Rails project root.

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"

Then we’ll run the code like this:

$ rails run my_factory_bot.rb

The first thing we’ll see is an error saying that MyFactoryBot is not defined.

uninitialized constant MyFactoryBot (NameError)

This is of course true. We haven’t yet defined something called MyFactoryBot. So, in the spirit of practicing “error-driven development”, let’s write enough code to make this particular error go away and nothing more.

class MyFactoryBot
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"

Now, if we run the code again (using the same rails run command from above), we get a different error.

undefined method `define' for MyFactoryBot:Class (NoMethodError)

This is also true. The MyFactoryBot class doesn’t have a method called define. So let’s define it.

class MyFactoryBot
  def self.define
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"

Now we get a new error.

undefined method `create' for MyFactoryBot:Class (NoMethodError)

This of course comes from the user = MyFactoryBot.create(:user) line. Let’s define a create method in order to make this error go away. Since we’re passing in an argument, :user, when we call create, we’ll need to specify a parameter for the create method. I’m calling the parameter model_sym since it’s a symbol that corresponds to the model that the factory is targeting.

class MyFactoryBot
  def self.define
  end

  def self.create(model_sym)
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"

Now we get an error for the next line.

undefined method `first_name' for nil:NilClass (NoMethodError)

We’ll deal with this error, but not just yet, because this one will be a little bit tricky, and there are some certain other things that will make sense to do first. Let’s temporarily comment out the lines that call first_name and last_name.

class MyFactoryBot
  def self.define
  end

  def self.create(model_sym)
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
#puts "First name: #{user.first_name}"
#puts "Last name: #{user.last_name}"

Now, if we run the file again, we get no errors.

Making it so the factory block gets called

Right now, the block inside of MyFactoryBot.define isn’t getting used at all. Let’s add block.call to the defined method so that the block gets called.

class MyFactoryBot
  def self.define(&block)
    block.call
  end

  def self.create(model_sym)
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
#puts "First name: #{user.first_name}"
#puts "Last name: #{user.last_name}"

Now when we run the file we get the following error.

undefined method `factory' for main:Object (NoMethodError)

It makes sense that we would get an error that says “undefined method factory“. We of course haven’t defined any method called factory

The receiver for the “factory” method

Notice how the error message says undefined method `factory' for main:Object. What’s the main:Object part all about?

Sending a message vs. calling a method

In Ruby you’ll often hear people talk about “sending a message to an object” rather than “calling a method on an object”. The distinction between these things is subtle but significant.

Consider the following bit of code:

a = Array.new(5) # [nil, nil, nil, nil, nil]
a.to_s
a.to_i

The a variable is an instance of the Array class. When we do a.to_s, we’re sending the to_s message to the a object. The a object will happily respond to the to_s message and return a stringified version of the array: "[nil, nil, nil, nil, nil]"

The a object does not respond to (for example) the to_i method. If we send to_i to a, we get an error:

undefined method `to_i' for [nil, nil, nil, nil, nil]:Array

Notice the format of the last part of the error message. It’s value of receiving object:class of receiving object.

Understanding main:Object

In Ruby, every message that gets passed has a receiver, even when it doesn’t seem like there would be. When we do e.g. a.to_s, the receiver is obvious: it’s a. What about when we just call e.g. puts?

When we send a message that doesn’t explicitly have a receiver object, the receiver is a special object called main. That’s why when we call factory we get an error message that says undefined local variable or method `factory' for main:Object. The interpreter sends the factory message to main because we’re not explicitly specifying any other receiver object.

Changing the receiver of the factory message

If we want our program to work, we’re going to have to change the receiver of factory from main to something else. If the receiver were just main, then our factory method would have to just be defined out in the open, not as part of any object. If the factory method is not defined as part of any object, then it can’t easily share any data with any object, and we’ll have a pretty tough time.

We can change the receiver of the factory message by using a Ruby method called instance_exec.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.create(model_sym)
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
#puts "First name: #{user.first_name}"
#puts "Last name: #{user.last_name}"

The instance_exec method will execute our block in the context of self. We can see that now, when we run our file, our error message has changed. The receiver object is no longer main. It’s now MyFactoryBot.

undefined method `factory' for MyFactoryBot:Class (NoMethodError)

Let’s add the factory method to MyFactoryBot.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.factory(model_sym)
  end

  def self.create(model_sym)
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
#puts "First name: #{user.first_name}"
#puts "Last name: #{user.last_name}"

Now we don’t get any errors.

We know we’ll also need to invoke the block inside of the factory call, so let’s use another instance_exec inside of the factory method to do that.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.factory(model_sym, &block)
    instance_exec(&block)
  end

  def self.create(model_sym)
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
#puts "First name: #{user.first_name}"
#puts "Last name: #{user.last_name}"

Now it’s complaining, unsurprisingly, that there’s no method called first_name.

undefined method `first_name' for MyFactoryBot:Class (NoMethodError)

Adding the first_name and last_name methods

We of course need to define methods called first_name and last_name somewhere. We could conceivably add them on the MyFactoryBot class, but it would probably work out better to have a separate instance for each factory that we define, since of course a real application will have way more than just one factory.

Let’s make it so that the factory method creates an instance of a new class called MyFactory and then invokes the block on MyFactory.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.factory(model_sym, &block)
    factory = MyFactory.new
    factory.instance_exec(&block)
  end

  def self.create(model_sym)
  end
end

class MyFactory
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
#puts "First name: #{user.first_name}"
#puts "Last name: #{user.last_name}"

We of course still haven’t actually defined a method called first_name, so we still get an error about that, although the receiver is not MyFactory rather than MyFactoryBot.

undefined method `first_name' for #<MyFactory:0x0000ffff9a955ae8> (NoMethodError)

Let’s define a first_name and last_name method on MyFactory in order to make this error message go away.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.factory(model_sym, &block)
    factory = MyFactory.new
    factory.instance_exec(&block)
  end

  def self.create(model_sym)
  end
end

class MyFactory
  def first_name
  end

  def last_name
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
#puts "First name: #{user.first_name}"
#puts "Last name: #{user.last_name}"

Now we get no errors.

Providing values for first_name and last_name

Let’s now come back and uncomment the last two lines of the file, the lines that output values for first_name and last_name.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.factory(model_sym, &block)
    factory = MyFactory.new
    factory.instance_exec(&block)
  end

  def self.create(model_sym)
  end
end

class MyFactory
  def first_name
  end

  def last_name
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"

We get an error.

undefined method `first_name' for nil:NilClass (NoMethodError)

Remember that every message in Ruby has a receiver. Apparently in this case the receiver for first_name when we do user.first_name is nil. In other words, user is nil. That’s obviously not going to work out. It does make sense, though, because MyFactoryBot.create(:user) has no return value.

Let’s try making it so that MyFactoryBot#create returns our instance of MyFactory.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.factory(model_sym, &block)
    @factory = MyFactory.new
    @factory.instance_exec(&block)
  end

  def self.create(model_sym)
    @factory
  end
end

class MyFactory
  def first_name
  end

  def last_name
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"

Now there are no errors but, but there are also no values present in the output we see.

First name: 
Last name:

Let’s have a closer look and see exactly what user is.

user = MyFactoryBot.create(:user)
puts user.class.name
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"

The user object is an instance of MyFactory.

MyFactory
First name: 
Last name:

This was maybe a good intermediate step but we of course want user to be an instance of our Active Record User class, just like how the real Factory Bot does. Let’s change MyFactoryBot#create so that it returns an instance of User.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.factory(model_sym, &block)
    @factory = MyFactory.new
    @factory.instance_exec(&block)
  end

  def self.create(model_sym)
    @factory.user
  end
end

class MyFactory
  attr_reader :user

  def initialize
    @user = User.new
  end

  def first_name
  end

  def last_name
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
puts user.class.name
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"

This gives no errors but there are still no values present.

In order for the first_name and last_name methods to return values, let’s make it so each one calls the block that it’s given.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.factory(model_sym, &block)
    @factory = MyFactory.new
    @factory.instance_exec(&block)
  end

  def self.create(model_sym)
    @factory.user
  end
end

class MyFactory
  attr_reader :user

  def initialize
    @user = User.new
  end

  def first_name(&block)
    @user.first_name = block.call
  end

  def last_name(&block)
    @user.last_name = block.call
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
puts user.class.name
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"

Now we see “John” and “Smith” as our first and last name values.

User
First name: John
Last name: Smith

Generalizing the factory

What if we wanted to add this? It wouldn’t work.

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
    email { "john.smith@example.com" }
  end
end

Obviously we can’t just have hard-coded first_name and last_name methods in our factory. We need to make it so our factory can respond to any messages that are sent to it (provided of course that those messages correspond to actual attributes on our Active Record models).

Let’s take the first step toward generalizing our methods. Instead of @user.first_name = block.call, we’ll do @user.send("first_name=", block.call), which is equivalent.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.factory(model_sym, &block)
    @factory = MyFactory.new
    @factory.instance_exec(&block)
  end

  def self.create(model_sym)
    @factory.user
  end
end

class MyFactory
  attr_reader :user

  def initialize
    @user = User.new
  end

  def first_name(&block)
    @user.send("first_name=", block.call)
  end

  def last_name(&block)
    @user.send("last_name=", block.call)
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

user = MyFactoryBot.create(:user)
puts user.class.name
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"

We can go even further than this. Rather than having methods called first_name and last_name, we can use Ruby’s method_missing to dynamically respond to any message that gets sent.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.factory(model_sym, &block)
    @factory = MyFactory.new
    @factory.instance_exec(&block)
  end

  def self.create(model_sym)
    @factory.user
  end
end

class MyFactory
  attr_reader :user

  def initialize
    @user = User.new
  end

  def method_missing(attr, *args, &block)
    # If the message that's sent is e.g. first_name, then
    # the value of attr will be :first_name, and the value
    # of "#{attr}=" will be "first_name=".
    @user.send("#{attr}=", block.call)
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
    email { "john.smith@example.com" }
  end
end

user = MyFactoryBot.create(:user)
puts user.class.name
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"
puts "Email: #{user.email}"

If we run our file again, we can see that our method_missing code handles not only first_name and last_name but email as well.

First name: John
Last name: Smith
Email: john.smith@example.com

More generalization

What if we want to have more factories besides just one for User? I happen to have a model in my Rails app called Website. What if I wanted to have a factory for that?

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

MyFactoryBot.define do
  factory :website do
    name { "Google" }
    url { "www.google.com" }
  end
end

Right now, it wouldn’t work because I have the User class hard-coded in my factory.

undefined method `name=' for #<User:0x0000ffff8f950d28> (NoMethodError)

Let’s make it so that rather than hard-coding User, we set the class dynamically. Let’s also make it so that the argument you pass to the factory method (e.g. :user or :website) retries the appropriate factory. We can accomplish this by putting our factories into a hash.

class MyFactoryBot
  def self.define(&block)
    instance_exec(&block)
  end

  def self.factory(model_sym, &block)
    @factories ||= {}
    @factories[model_sym] = MyFactory.new(model_sym)
    @factories[model_sym].instance_exec(&block)
  end

  def self.create(model_sym)
    @factories[model_sym].record
  end
end

class MyFactory
  attr_reader :record

  def initialize(model_sym)
    @record = model_sym.to_s.classify.constantize.new
  end

  def method_missing(attr, *args, &block)
    @record.send("#{attr}=", block.call)
  end
end

MyFactoryBot.define do
  factory :user do
    first_name { "John" }
    last_name { "Smith" }
  end
end

MyFactoryBot.define do
  factory :website do
    name { "Google" }
    url { "www.google.com" }
  end
end

user = MyFactoryBot.create(:user)
puts user.class.name
puts "First name: #{user.first_name}"
puts "Last name: #{user.last_name}"
puts

website = MyFactoryBot.create(:website)
puts website.class.name
puts "Name: #{website.name}"
puts "URL: #{website.url}"

Now, when we run our file, both factories work.

First name: John
Last name: Smith
Email: john.smith@example.com

Name: Google
URL: www.google.com

Takeaways

  • Factory Bot syntax (and other DSLs) aren’t magic. They’re just Ruby.
  • Blocks can be a powerful way to make your code more expressive and understandable.
  • In Ruby, it’s often more useful to talk about “sending messages to objects” rather than “calling methods on objects”.
  • Every message sent in Ruby has a receiver. When a receiver is not explicitly specified, the receiver is a special object called main.
  • The method_missing method can allow your objects to respond to messages dynamically.

7 thoughts on “Understanding Factory Bot syntax by coding your own Factory Bot

  1. Chris Habgood

    Nice job! I try to tell people on interviews things like devise, kaminari, and such are just ruby classes that do stuff.

    Reply
  2. Ed Ruder

    This looks great, but I’m having trouble out the gate, getting the docker containers running on macOS Big Sur.

    I’m running the latest Docker Desktop (3.5.2). I’ve successfully used Docker in a number of projects on this machine. In the course of trying to get this working, to start fresh, I’ve deleted all containers, images, and volumes from Docker.

    When I run `docker compose run web`, I consistently end up (whether Docker is “clean” or not) failing towards the end of pulling and building everything, with an error:
    “`
    Could not find rake-13.0.3 in any of the sources
    Run `bundle install` to install missing gems.
    “`

    Any thoughts?

    Thanks in advance.

    Ed

    P.S. Full output of `docker compose run web`:
    “`
    $ docker compose run web
    [+] Running 16/16
    ⠿ redis Pulled 4.9s
    ⠿ cbdbe7a5bc2a Pull complete 2.3s
    ⠿ dc0373118a0d Pull complete 2.4s
    ⠿ cfd369fe6256 Pull complete 2.5s
    ⠿ 152ffd6a3b24 Pull complete 2.8s
    ⠿ 7c01860f13a3 Pull complete 2.8s
    ⠿ aa6ecacd3bee Pull complete 2.9s
    ⠿ postgres Pulled 5.9s
    ⠿ 4c0d98bf9879 Pull complete 0.7s
    ⠿ 7ff5918c11c3 Pull complete 0.7s
    ⠿ c393806625cd Pull complete 0.8s
    ⠿ 9307f3bcca3a Pull complete 3.8s
    ⠿ 5eee78b95230 Pull complete 3.9s
    ⠿ c0f2174cad0e Pull complete 3.9s
    ⠿ dd6b4e21c993 Pull complete 4.0s
    ⠿ 1011823211fa Pull complete 4.0s
    [+] Running 11/9
    ⠿ Network my_factory_bot_default Created 0.3s
    ⠿ Volume “my_factory_bot_bundler” Created 0.0s
    ⠿ Volume “my_factory_bot_bootsnap_cache” Created 0.0s
    ⠿ Volume “my_factory_bot_rails_cache” Created 0.0s
    ⠿ Volume “my_factory_bot_packs” Created 0.0s
    ⠿ Volume “my_factory_bot_node_modules” Created 0.0s
    ⠿ Volume “my_factory_bot_storage” Created 0.0s
    ⠿ Volume “my_factory_bot_postgresql” Created 0.0s
    ⠿ Volume “my_factory_bot_redis” Created 0.0s
    ⠿ Container my_factory_bot_postgres_1 Created 0.1s
    ⠿ Container my_factory_bot_redis_1 Created 0.1s
    [+] Running 2/2
    ⠿ Container my_factory_bot_postgres_1 Started 0.7s
    ⠿ Container my_factory_bot_redis_1 Started 0.6s
    [+] Running 0/1
    ⠿ web Error 1.3s
    [+] Building 1.1s (11/11) FINISHED
    => [internal] load build definition from Dockerfile 0.0s
    => => transferring dockerfile: 32B 0.0s
    => [internal] load .dockerignore 0.0s
    => => transferring context: 2B 0.0s
    => [internal] load metadata for docker.io/library/ruby:2.7.3-alpine 1.0s
    => [auth] library/ruby:pull token for registry-1.docker.io 0.0s
    => [1/6] FROM docker.io/library/ruby:2.7.3-alpine@sha256:b63a28fd862407f5a57486076c19003389d6a3c3f13dc6b198519064fbc12546 0.0s
    => CACHED [2/6] RUN apk add –no-cache build-base libffi-dev nodejs yarn tzdata postgresql-dev postgresql-client zlib-dev libxml2-dev li 0.0s
    => CACHED [3/6] RUN pip3 install -U selenium 0.0s
    => CACHED [4/6] RUN mkdir -p /usr/src/app 0.0s
    => CACHED [5/6] WORKDIR /usr/src/app 0.0s
    => CACHED [6/6] RUN gem install rails 0.0s
    => exporting to image 0.0s
    => => exporting layers 0.0s
    => => writing image sha256:adeef42afc25768698316974a553c29d4dd29a989bdae46a41baab1b757f4922 0.0s
    => => naming to docker.io/library/my_factory_bot 0.0s
    Could not find rake-13.0.3 in any of the sources
    Run `bundle install` to install missing gems.
    $
    “`

    Reply
  3. Serguei

    Great article, Jason, thanks a lot. I’d just like to point out a tiny typo: ” Let’s add block.call to the create method so that the block gets called.” should rather be ” Let’s add block.call to the define method so that the block gets called” as you were adding it exactly to the .define method.

    Reply

Leave a Reply

Your email address will not be published. Required fields are marked *