Recently, I reviewed a pull request where setting up the object under the test was rather cumbersome. The setup spanned ten lines, each necessary to build a valid ActiveRecord object. Having ten lines for setup is not necessarily a problem; however, most of the tests did not care about the translation details, and the setup was already duplicated in the same test file.
Here’s a snippet of how the file looked before and after the pull request changes.
# spec/models/product_spec.rb - Before
RSpec.describe do
subject(:product) { FactoryBot.create(:product) }
it "is valid" do
expect(product).to be_valid
end
end
# spec/models/product_spec.rb - After
RSpec.describe do
subject(:product) do
picture = Rack::Test::UploadedFile.new("image.jpg", "image/jpg")
manual = Rack::Test::UploadedFile.new("manual.pdf", "application/pdf")
product = FactoryBot.build(:product)
product.translations.build(locale: "en", picture:, manual:)
product.translations.build(locale: "fr", picture:, manual:)
product.translations.build(locale: "hu", picture:, manual:)
product.save!
product
end
it "is valid" do
expect(product).to be_valid
end
end
The test setup became cumbersome because additional validations were needed to reflect the business expectations.
The model represents a product with localized files containing the product image and the manual. Previously, the product was valid without any translations, but now, at least one translation must be present with at least the picture. This created a somewhat complex challenge for the factory definition.
# app/models/product.rb
class Product < ApplicationRecord
translates :picture, :manual # Using the Globalize gem
accepts_nested_attributes_for :translations
+ validates :translations, length: { minimum: 1 }
+ validates_associated :translations
end
Solution
The goal was to return to a simple but expressive one-liner from the overly verbose test setup. After a couple of minutes of pairing, the following expression took shape to create the test objects. It is explicit about the translations enough for the test requirements and allows further refinement when needed.
subject(:product) { FactoryBot.create(:product, with_translations: [:en, :nl, :hu])}
Because at least one translation is required, the base factory has to create a translation. However, the localized content is not essential and is rarely checked.
I had the following considerations,
- For the majority of cases, the translation details are irrelevant
- It should be easy to create none or multiple translations
- It should be easy to specify the details of the translations when needed
With these in mind, I turned to the transient attributes of FactoryBot. This feature allows for hiding the complexity of object creation for testing behind transient attributes.
Transient attributes are attributes only available within the factory definition, and not set on the object being built.
I added an attribute containing a list of locale codes for which I wanted to generate translations.
This attribute allowed generating the required translations using this transient attribute in conjunction with the translations_attributes
method defined by the accepts_nested_attributes_for
method of ActiveRecord.
I set only a single locale code as a default, so the factory does the minimum to generate a valid object.
The list can be overridden using the with_translations
attribute to generate a different set of translations, allowing the generation of none.
# spec/support/factories/products.rb
FactoryBot.define do
factory :product do
brand { "ACME corp" }
price { 10.99 }
transient do
with_translations { %i[en] }
end
# Defined by accepts_nested_attributes_for :translations
translations_attributes do
picture = Rack::Test::UploadedFile.new("sample.jpg", "image/jpg", true)
manual = Rack::Test::UploadedFile.new("sample.pdf", "application/pdf", true)
with_translations.map do |locale|
{ locale:, picture:, manual: }
end
end
end
With the new factory definition, I could return to a single-liner to create the necessary objects without losing much of the explicitness of the test setup.
# spec/models/product_spec.rb - Before
RSpec.describe do
subject(:product) { FactoryBot.create(:product, with_translations: [:en, :fr, :hu]) }
it "is valid" do
expect(product).to be_valid
end
end
Conclusion
In conclusion, FactoryBot showed its power again, helping me keep the test concise and flexible. It is worth the time to glance through the examples of each FactoryBot feature to get an idea of when and where they can support writing excellent code.