Fabulous FactoryBot - Complex factories for Rails

Posted on · 1 minute read

In my previous post about FactoryBot we learned how to use its various features to simplify creating test data. In this post, we’ll put that knowledge to the test by looking at a - relatively simple - Rails application.

The Application

The application we are going to be using is something ground breaking that I just came up with. A note taking app. For multiple users! Truly amazing, I know 😉

Users may create notes and group them into projects. To share notes with other users access to projects can be controlled using project memberships. We don’t want everyone that we invite to a project to be able to write notes or invite other users, so these memberships support three roles: Admin, Reader and Writer.

Furthermore, not all users should be able to do everything in our app. We want to make big money, so users have must subscribe to a plan, which can be either free or pro. This enumeration is implemented using a table, because we want to be able to extend this at runtime. Each row in the plan table must therefore be unique.

Schema

Most of the attributes of our models must not be empty - a project without a name makes no sense after all. As we use a user’s email to identify them, that attribute must be unique. And so on. Our user model would therefore look something like this:

class User < ApplicationRecord
  has_secure_password

  belongs_to :plan

  validates :name, :email, presence: true
  validates :email, uniqueness: true
end

You can find the source code for the entire app on GitHub, if you want to poke around. Now, lets see how to write some tests for this application and use FactoryBot in the process.

Simple Factories

Let’s first create some minimal factories that construct valid instances of models. We just need to keep the various models’ constraints in mind. Nothing too exciting happens here - our focus is simply to create some factories that we can extend later.

factory :user do
  name { 'name' }
  password { 'password' }
  sequence(:email) { |i| "user#{i}@email.com" }
  plan
end

factory :membership do
  user
  project
  role { 'admin' }
end

factory :project do
  name { 'name' }
end

factory :note do
  title { 'title' }
  text { 'text' }
  project
end

Note that we used sequences to address the unique constraint on the email attribute and default values for those attributes that must not be empty.

It doesn’t really matter where the factories are located. While your application is small, you may prefer to have all of them in a single factories.rb file. The library factory_bot_rails (which I recommend you use) puts factories in various files depending on which models they construct. You can find those files in the test/factories folder.

The factories we created make it very simple to test, for example, model validations. We don’t need to create rows in the database so we can mostly rely on using build - the exception being testing unique constraints.

test 'user email must be present' do
  user = build(:user, email: nil)

  assert(user.invalid?)
end

test 'user email with duplicate is invalid' do
  create(:user, email: '[email protected]')
  user = build(:user, email: '[email protected]')

  assert(user.invalid?)
end

These test are fairly simple, because we do not need to worry about how the different models interact with each other. Things get interesting when you want to test logic that requires you to construct multiple models that depend on each other.

Complex Factories

Let’s consider some more complex use cases. Since our app should not allow users who are not members of a project to access the notes therein, we have to implement some sort of authorization mechanism. I used Pundit here. Consider this policy that controls how a user may interact with a given note.

class NotePolicy < ApplicationPolicy
  def show?
    Membership.find_by(user: user, project: record.project)
  end

  def update?
    membership = Membership.admin
      .or(Membership.writer)
      .find_by(user: user, project: record.project)
  end

  class Scope < Scope
    def resolve
      scope.joins(project: :memberships).where(project: { memberships: { user: user } })
    end
  end
end

A user should be able to read a note only if they are a member of the note’s project. They should be able to edit a note only if they have an admin or writer role in the project. To control which notes are displayed when the user navigates to the notes index page the scope class is used. Again, users should only see notes where they have a project membership.

Let’s think about what test data we need here. For example, to verify that a given user can read a given note you need:

  • A user
  • A project
  • A note
  • A membership that links the user and the note’s project

So we could create something like this:

test 'user with membership should be able to show note' do 
  note = create(:note)
  user = create(:user)
  create(:membership, user: user, project: note.project)

  policy = NotepPolicy.new(note, user)

  assert(policy.read?)
end

Note that this creates multiple more records in the database than is immediatly obvious: create(:note) implicitly creates a project and create(:user) implicitly creates a plan. This is fine because we need all this data for the test to actually work. But its also a bit verbose - we can do better.

We are going to create some advanced factories that allow us to create a note within a project so that an arbitrary user has access to it - and we are also going to make sure we can pass the user’s role in the project as well.

factory :note_for_user, parent: :note do
  transient do
    user { create(:user) }
    role { 'admin' }
  end
  project { association :project_for_user, user: user, role: role }
end

factory :project_for_user, parent: :project do
  transient do
    user { create(:user) }
    role { 'admin' }
  end

  after(:build) do |project, evaluator|
    project.memberships << build(:membership,
                                 project: project,
                                 user: evaluator.user,
                                 role: evaluator.role)
  end
end

This might look a bit wonky and confusing at first but bear with me - it’s not as bad as it looks. The factory note_for_user inherits from the note factory and accepts two additional arguments, namely the role and user, which it passes on to a second factory. That factory, project_for_user, utilizes these arguments in a hook. The hook is responsible for creating a membership for the passed user after the project has been created.

One might be tempted to say we have just written two fairly simple factory methods using FactoryBot’s DSL. Big deal, right?

def note_for_user(user = create(:user), role='admin')
  project_for_user(user, role)
end

def project_for_user(user = create(:user), role='admin')
  project = create(:project)
  project.memberships << create(:membership
                                 project: project,
                                 user: evaluator.user,
                                 role: evaluator.role)
  project
end

It’s a bit more than that though. The most important difference is the fact that using FactoryBot’s DSL allows us to utilize different build strategies - our factory methods do not. This is because we have used after(:build). The hook will be called regardless of whether we use create or build, and that gives us a great deal of flexibility in writing our tests.

In addition FactoryBot’s factories are much easier to reuse and adapt than custom factory methods. Especially when you utilize traits. This again means better maintainability for our test code.

Let’s get back to our tests. When you look at the tests from before they can now be refactored to this:

test 'user with membership should be able to show note' do 
  user = create(:user)
  note = create(:personal_note, user: user)

  policy = NotePolicy.new(note, User.first)

  assert(policy.read?)
end

Looks like we just saved a single line of code. Wow.

Granted, this particular example does not look very impressive. Imagine your typical integration test though: You might have to create multiple projects for multiple users that have various levels of access to your project. These are the instances where these factories really pay off. In fact, let’s look at how to test the policy scope functionality.

Remember that we want to verify that users are restricted in which notes they can access. That is: They should be able to access notes that belong to a project which they are members of. Let’s compare an implementation without complex factories and one with them.

setup do
  @user = create(:user)
end

# Simple Factories
test 'scope returns notes where membership for users exists' do
  first_project = create(:project)
  create(:membership, user: user, project: first_project)
  user_note = create(:note_for_user, user: @user)
  second_user = create(:user)
  second_project = create(:project)
  create(:membership, user: second_user, project: second_project)
  another_note = create(:note_for_user, user: second_user)

  scope = NotePolicy::Scope.new(@user, Note)
  results = scope.resolve

  assert_includes(results, user_note)
  refute_includes(results, another_note)
end

# Complex Factories
test 'scope returns notes where membership for users exists' do
  user_note = create(:note_for_user, user: @user)
  another_note = create(:note_for_user)

  scope = NotePolicy::Scope.new(@user, Note)
  results = scope.resolve

  assert_includes(results, user_note)
  refute_includes(results, another_note)
end

I don’t know about you but I like the second option better.

Now, if you actually tried the above you might notice that we have another problem to solve. When creating multiple plans through create(:user) our validations fail because we are trying to create multiple identical plans through associations. After all, FactoryBot can’t know that there should always be only a single pro or basic plan. Effectively, we need FactoryBot to create singleton plan rows. To accomplish that we modify our plan factory:

factory :plan do
  name { 'pro' }
  to_create do |instance|
    instance.attributes = Plan.find_or_create_by(name: instance.name).attributes
    instance.instance_variable_set('@new_record', false)
  end
end

Here to_create is used to control how model instances are created. See Joey Changs post on Dev.to to read an in-depth explanation on how this works. The neat thing here is that you can still use build and build_stubbed to create new model instances of Plan - our modifications do not influence those strategies.

Now that is pretty much all there is to it. This is how you can create multiple nested models using FactoryBot.

When writing your tests this way always keep in mind that your regression tests should be as easy to read as possible. I think using FactoryBot to create nested models in the cases above helps readability, but there might be other instances where it might not. It is fairly simple to go totally overboard with FactoryBot and hurt your test code’s maintainability. Try to get the right balance between simplicity and terseness of your test code.

Complex Factories and Stubbing

There is one last thing to talk about, and that is how build_stubbed handles associations using through.

class Note < ApplicationRecord
  has_many :memberships, through: :project
end

Using our previous simple factories we can build valid stubbed instances of our notes, projects and memberships. When accessing memberships through notes we experience a little surprise however.

project = build_stubbed(:project, memberships: [build_stubbed(:membership)])
note = build_stubbed(:note, project: project)
project.memberships
#> #<ActiveRecord::Associations::CollectionProxy [#<Membership id: 1009, ... ]>
note.project.memberships
#> #<ActiveRecord::Associations::CollectionProxy [#<Membership id: 1009, ... ]>
note.memberships
#> #<ActiveRecord::Associations::CollectionProxy []> 

The reason why note memberships are empty when accessed directly is because a query is performed. Because there are no models in the database (because we did not use create) the result set is empty. In order to remedy this we must overwrite the association when using the build_stubbed strategy.

after(:stub) do |note, _|
  note.stubs(:memberships).returns(note.project.memberships)
end

This adds a bit of complexity to your factories but might be useful when dealing with tests that rely on model relations but need not interact with the database.

Summary

In this post we looked at a small Rails application and some of its tests. We saw how to utilize FactoryBot and its various build strategies and addressed problems that arise when constructing related models with specific constraints.

I hope that this post (and the previous one ) gave you some insight into how FactoryBot can help you write cleaner tests.