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.
link to https://github.com/jasonswett/my_factory_bot is broken
Thanks for pointing that out. Should be fixed now.
Nice job! I try to tell people on interviews things like devise, kaminari, and such are just ruby classes that do stuff.
Thanks!
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.
$
“`
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.
Thanks. Fixed!