Tips for Using FactoryBot Without an ORM

Oli Peate

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 an attr_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:

&#22; user - undefined method &#96;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 of FactoryBot.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.

Disclaimer:

Looking for FactoryGirl? The library was renamed in 2017. Project name history can be found here.