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.