FactoryBot

Making a has_many through reference easy to override

Posted by John Thomas 9-Dec-2018

Sometimes in Rails projects, I will reach for a has_many through: association even when there might only be a single associated model. For instance, I am working on a project where a User can have many accounts. A benefit of this is that a User can have a single log in and switch between accounts, Users can invite Users to their account, etc. But the drawback is that dealing with has_many associations are little bit more cumbersome in your code base and tests. In this post, I am going to go through a pattern I have used to make test setup a little cleaner.

Here is my model setup:

# app/models/user.rb

class User < ApplicationRecord
  has_many :account_memberships
  has_many :accounts, through: :account_memberships
end
# app/models/account.rb

class Account < ApplicationRecord
  has_many :account_memberships
  has_many :users, through: :account_memberships
end
# app/models/account_membership.rb

class AccountMembership < ApplicationRecord
  belongs_to :account
  belongs_to :user
end

For my application, when a User signs up, an account is automatically created for them, so that a user will always have at least 1 account. A factory setup for that might look like:

# spec/factories/user.rb

FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "#{n}.email@example.com" }
    password { 'password' }

    after(:build) do |user, _evaluator|
      user.accounts << build(:account)
    end
  end
end

Now let's say that I want to introduce projects, and project memberships. Those models might look like this:

# app/models/project.rb

class Project < ApplicationRecord
  belongs_to :account
  has_many :users, through: :project_memberships
end
# app/models/project_membership.rb

class ProjectMembership < ApplicationRecord
  belongs_to :project
  belongs_to :user
end

Now I want to introduce a validation on the project membership to ensure that only users associated with the project's account can be members to that account. Here is what a test might look like for that:

# spec/models/project_membership_spec.rb

require 'rails_helper'

RSpec.describe ProjectMembership, type: :model do
  let(:project) { create(:project) }

  it "is not valid when a member is not part of the project's account" do
    user = create(:user)
    project_memberhsip = ProjectMembership.new(project: project, user: user)

    expect(project_memberhsip).to_not be_valid
  end

  it "is valid when a user is part of the project's account" do
    user = create(:user)
    user.accounts << project.account
    project_memberhsip = ProjectMembership.new(project: project, user: user)

    expect(project_memberhsip).to be_valid
  end
end

And then I update my ProjectMembership model to make the tests pass:

# app/models/project_membership.rb

class ProjectMembership < ApplicationRecord
  belongs_to :project
  belongs_to :user

  validate :member_belongs_to_account

  private

  def user_belongs_to_account
    return if user.accounts.include?(project.account)
    errors.add(:user, 'does not belong to this account')
  end
end

That all works fine, but it would be nice to be able to pass in an account to the user factory on build/create. That would clean up our tests a bit

# spec/models/project_membership_spec.rb

require 'rails_helper'

RSpec.describe ProjectMembership, type: :model do
  let(:project) { create(:project) }

  it "is not valid when a member is not part of the project's account" do
    user = create(:user)
    project_memberhsip = ProjectMembership.new(project: project, user: user)

    expect(project_memberhsip).to_not be_valid
  end

  it "is valid when a user is part of the project's account" do
    user = create(:user, account: project.account)
    project_memberhsip = ProjectMembership.new(project: project, user: user)

    expect(project_memberhsip).to be_valid
  end
end

We can actually do this by using the transient method in FactoryBot. Our user factory will now look like this:

# spec/factories/user.rb

FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "#{n}.email@example.com" }
    password { 'password' }

    # This allows you to set an account that the user belongs to
    transient do
      account { build(:account) }
    end

    after(:build) do |user, evaluator|
      user.accounts << evaluator.account
    end
  end
end

Now we can create user factories that belong to an account easily in our tests. And all the above tests pass.

There is one potential issue with the factory above, and that is that we are setting the user.accounts association in the build step. Which means that unless we reload the user, then the user.accounts method call, could return stale data. A way around that would be to reload the model after create:

# spec/factories/user.rb

FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "#{n}.email@example.com" }
    password { 'password' }

    # This allows you to set an account that the user belongs to
    transient do
      account { build(:account) }
    end

    after(:build) do |user, evaluator|
      user.accounts << evaluator.account
    end

    after(:create) { |user, evaluator| user.reload }
  end
end

This will make sure your user factory has the latest data after create, but it will add an additional query to your database.