Common Missteps with Rails Associations and Testing
I’ve seen some less-than stellar patterns around associations with Ruby on Rails especially when it comes to testing. These can make code more complex than necessary and make testing slower and/or more difficult.
Foreign key validations
class Author < ApplicationRecord
has_many :books
end
class Book < ApplicationRecord
belongs_to :author
validates :author_id, presence: true # Not needed!
end
The belongs_to
association requires the “owner” (an author in this case) to be set by default so validating the presence of the foreign key is not needed.
class Book < ApplicationRecord
belongs_to :author
end
book = Book.new
book.valid? # false
book.errors[:author] # [ "must exist" ]
Why is this a problem?
Primary keys are generated by the database at record creation. Building an object in memory and not persisting it to the database will mean the foreign key is not set so new unsaved objects will always be invalid if the owner is not yet created. Rails will create all associated records for us (as long as autosave: false
is not set on the association).
book = Book.new(author: Author.new(name:), title:)
book.valid? # true
book.save # true
book.persisted? # true
book.author.persisted? # true
Read more about Rails associations in the official guide and docs
Unnecessary persisted records in unit tests
Now we know Rails will create associated records for us and validates required associations.
let(:book) { create(:book, title: "the hobbit ") } # This could probably be build!
it "formats the title" do
expect(book.title).to eq "The Hobbit"
end
The FactoryBot
library is a popular and useful testing library for quickly creating test model objects in Rails test suites.
It provides two ways of generating new objects: build
and create
. The build
method does not persist the record to the database and is similar to calling Book.new
. The create
method saves the record to the database like Book.create
.
let(:book) { build(:book, title: "the hobbit ") } # Only in memory
it "formats the title" do
expect(book.title).to eq "The Hobbit"
end
Many unit tests are looking at Ruby methods that do not require data to be read from or written to the database. This includes most validations.
Where possible, use build()
to dramatically speed up test runs. It can be tempting to just create()
all the time since it Just Works™ but when we have thousands of examples to run this can really slow things down. This can also make us think about what can be done in memory before we ever touch the database.
Explicit association creations in factories
Building test objects is only going to be faster if our factories are defined without explicitly creating associated records.
FactoryBot.define do
factory :book do
author { create(:author) } # We don't need to create here!
title { "Through the Looking Glass" }
end
FactoryBot is wise to associations so in most cases we can just pass in the name of the associated record.
FactoryBot.define do
factory :book do
author # FactoryBot will see the relationship on the Book model definition
title { "Through the Looking Glass" }
end
If we need to specify some different attributes for an association in a factory, we can do it like this:
FactoryBot.define do
factory :book do
author
title { "Through the Looking Glass" }
trait :by_tolkien do
association :author, name: "J.R.R. Tolkien"
title { "The Hobbit" }
end
end
Keep create()
out of our factories to take advantage of in-memory only tests that skip database read/write requests.