FactoryBot is one of my favourite testing tools β I was a fan prior to joining thoughtbot. It’s one of those tools I miss immediately when working outside the Ruby ecosystem.
Recently I’ve been working on a Rails project that doesn’t need any database persistence and therefore doesn’t use an object-relational mapper like ActiveRecord. Instead data is fetched from an external JSON API and parsed into value objects written in plain Ruby.
At the outset, the value objects needed for unit tests were manually written for each example. As further attributes were added to the models this quickly became tedious; the value objects created for previous tests also needed updating.
Fortunately FactoryBot can be used with plain Ruby objects, bringing all the advantages of using factories in tests.
A gentle start…
Side note: The examples use inline classes and factory definitions for brevity. They’re also written as specs for self-proof each step works as intended.
Let’s begin with the simplest factory for a plain Ruby object:
it "supports building a PORO object" do
class User
attr_accessor :name
end
FactoryBot.define do
factory :user do
name "Amy"
end
end
user = FactoryBot.build(:user)
expect(user.name).to eq "Amy"
end
The notable point here is the use of the build
strategy with the factory.
Using the create
strategy raises an exception:
NoMethodError: undefined method `save!' for #
This exception occurs because the User
class doesn’t implement #save!
. When
using ActiveRecord this would have been defined by inheriting from
ApplicationRecord
, which includes
ActiveRecord::Persistence
.
Immutability
Next let’s update the User
model toβ¦
- Be immutable by switching the
attr_accessor
to anattr_reader
. - Be instantiated with a
Hash
since the real data will be populated from JSON.
The updated model looks like this:
class User
attr_reader :name
def initialize(data = {})
@name = data[:name]
end
end
With the original factory definition an exception is raised:
NoMethodError: undefined method `name=' for #
FactoryBot’s default approach to initializing an object is to call #new
and
then assign each attribute. This behaviour can be overridden by specifying
initialize_with
in the factory definition:
it "supports custom initialization" do
class User
attr_reader :name
def initialize(data)
@name = data[:name]
end
end
FactoryBot.define do
factory :user do
name "Amy"
initialize_with { new(attributes) }
end
end
user = FactoryBot.build(:user)
expect(user.name).to eq "Amy"
end
Parsing nested resources
Let’s imagine the external API response includes nested resources similar to the following JSON structure:
{
"name": "Bob",
"location": {
"city": "New York"
}
}
The nested location
object is parsed by handing the relevant attributes to a
dedicated model:
class Location
attr_reader :city
def initialize(data)
@city = data[:city]
end
end
class User
attr_reader :name, :location
def initialize(data)
@name = data[:name]
@location = Location.new(data[:location])
end
end
To create this nested structure in tests it’s necessary to combine the :user
factory attributes with attributes from a :location
factory:
it "supports constructing nested models" do
class Location
attr_reader :city
def initialize(data)
@city = data[:city]
end
end
class User
attr_reader :name, :location
def initialize(data)
@name = data[:name]
@location = Location.new(data[:location])
end
end
FactoryBot.define do
factory :location do
city "London"
initialize_with { new(attributes) }
end
factory :user do
name "Amy"
location { attributes_for(:location) }
initialize_with { new(attributes) }
end
end
user = FactoryBot.build(:user)
expect(user.name).to eq "Amy"
expect(user.location.city).to eq "London"
end
It becomes clearer why this works by printing the factory-generated attributes for a user:
puts FactoryBot.attributes_for(:user)
# => {:name=>"Amy", :location=>{:city=>"London"}}
The attributes mimic the nested structure and this Hash
is passed to
User#initialize
by the custom initialize_with
block.
Time to lint
The final piece of the FactoryBot + plain Ruby objects puzzle is setting up factory linting. Linting is typically run before the test suite to avoid a sea of red test failures if a factory isn’t valid.
Using the built-in FactoryBot.lint
method from a Rake task produces:
rake aborted!
FactoryBot::InvalidFactoryError: The following factories are invalid:
 user - undefined method `save!' for #<User:0x007fc890ae0e88 @name="Amy"> (NoMethodError)
The undefined #save!
method is being called because the #lint
method ends up
constructing objects with the create
strategy. Here are two options to work
around this:
Option A: #skip_create
Adding #skip_create
to each factory definition makes them
compatible with FactoryBot.lint
:
FactoryBot.define do
factory :location do
city "London"
skip_create
initialize_with { new(attributes) }
end
factory :user do
name "Amy"
location { attributes_for(:location) }
skip_create
initialize_with { new(attributes) }
end
end
Introducing #skip_create
also allows for the create
strategy to be used
within tests:
FactoryBot.create(:user)
Option B: Lint by building
The built-in lint can be mimicked by defining a custom Rake task to build each factory:
namespace :factory_bot do
desc "Lint factories by building"
task lint_by_build: :environment do
if Rails.env.production?
abort "Can't lint factories in production"
end
FactoryBot.factories.each do |factory|
lint_factory(factory.name)
end
end
private
def lint_factory(factory_name)
FactoryBot.build(factory_name)
rescue StandardError
puts "Error building factory: #{factory_name}"
raise
end
end
Illustrated with a deliberately broken factory like-so:
class User
attr_reader :name
def initialize(data)
@name = data.fetch(:name) # <- Name is now required
end
end
FactoryBot.define do
factory :user do
# name "Amy" <- Uh oh
initialize_with { new(attributes) }
end
end
Running the factory_bot:lint_by_build
Rake task outputs:
Error building factory: user rake aborted! KeyError: key not found: :name /path/models.rb:5:in `fetch' /path/models.rb:5:in `initialize' /path/factories.rb:8:in `block (3 levels) in' /path/Rakefile:15:in `lint_factory'
The output names the invalid factory and provides a stack trace, then the task
exits with a status of 1
. The non-zero exit status is important β if this Rake
task is used in shell script with set -e
or composed with other tasks β it’ll
skip the remaining steps and fail fast:
# Rakefile
task(:default).clear
task default: ["factory_bot:lint_by_build", :spec]
To recap
For FactoryBot + plain Ruby object bliss:
- Use
FactoryBot.build
instead ofFactoryBot.create
. - Use
initialize_with
to customize initialization. - Outer factories can use
attributes_for
to build nested resources. - For factory linting either:
- Add
#skip_create
to each factory definition. - Mimic the built-in linting by calling
FactoryBot.build
on each factory.
- Add
Disclaimer:
Looking for FactoryGirl? The library was renamed in 2017. Project name history can be found here.