Creating More Specific Factories with Traits
In a previous post, I wrote about creating more specific FactoryBot factories using FactoryBot's inheritance and nested factories capabilities. In this post, I will cover creating more specific factories leveraging traits instead.
Similar to inheritance, you can use traits to create additional "presets" for your factories. One advantage of traits, and the reason I find myself using them more often, is their composability. Instead of relying on everything being defined upfront in your nested factory, you can combine the pieces you need at test time to build the perfect factory.
Defining Our Models
For our example, we will be working with two models: Camera
and MemoryCard
, where a Camera
can have zero or more MemoryCard
s.
While you may be more familiar with using FactoryBot with Rails and ActiveRecord
, you can create factory definitions for any Ruby class. For our Camera
and MemoryCard
classes, we are going to use simple Ruby Struct
s:
Camera =
Struct.new(
:manufacturer,
:frame_size,
:memory_cards
)
MemoryCard =
Struct.new(:storage_capacity)
A Basic Factory
As a general practice, you will want to define a factory for every model in your system. This factory should include any required attributes necessary to create an instance of the model. While our models do not have any form of validation, we will pretend a Camera
requires a manufacturer
and a frame_size
, and that a MemoryCard
requires storage_capacity
. With these requirements in mind, we can create our baseline factories.
FactoryBot.define do
factory :camera do
manufacturer { 'Kodak' }
frame_size { '35x24' }
end
factory :memory_card do
storage_capacity { '32GB' }
end
end
With this in place, we can now easily create instances of our models without explicitly setting all of the attributes.
FactoryBot.build(:camera)
=> <Camera
manufacturer="Kodak",
frame_size="35x24",
memory_cards=nil>
FactoryBot.build(:memory_card)
=> <MemoryCard
storage_capacity="32GB">
Adding Traits
Our baseline factories provide an easy way to create new, valid instances of our models. If our test requires any different attributes, we can set them when creating our factory. For example, maybe we need to test how we calculate a camera's crop factor. Since the crop factor is a calculation based on the frame size, we could create factories with various frame sizes:
describe '#crop_factor' do
context 'when working with a full-frame camera' do
let(:camera) do
FactoryBot.build(:camera, frame_size: '35x24')
end
end
context 'when working with an APS-C camera' do
let(:camera) do
FactoryBot.build(:camera, frame_size: '23.6x15.6')
end
end
end
If we find that we regularly set frame_size
to a few standard dimensions, we may consider defining some traits.
The syntax for creating a trait is similar to creating a factory. We have a trait
block that we use to name our trait and, within the trait block, we use the same syntax to set values for our attributes. Below, we have defined traits that represent standard sensor types, specifically, their frame sizes.
factory :camera do
trait :full_frame do
frame_size { '35x24' }
end
trait :aps_c do
frame_size { '23.6x15.6' }
end
end
In our test, we can now use these traits when creating new Camera
records. Using factories this way allows us to work with standard sensor sizes by name and not dimensions.
# use trait and baseline attribute
FactoryBot.build(:camera, :aps_c)
=> <Camera
manufacturer="Kodak",
frame_size="23.6x15.6",
memory_cards=nil>
# use trait and specify attributes
FactoryBot.build(
:camera,
:full_frame,
manufacturer: "Nikon"
)
=> <Camera
manufacturer="Nikon",
frame_size="35x24",
memory_cards=nil>
As you can see above, traits will inherit the default values set up in our baseline factory while still allowing us to override any individual attributes if needed.
Now that we have introduced traits, I want to mention that, for this example, it could make sense to set frame_size
directly when building our factories. Since crop_factor
is a mathematical formula based on frame_size
, it may be easier to understand our test expectations if we see the actual frame_size
(as opposed to it potentially being obfuscated by our trait). This concern reveals the subtly of dealing with traits and the potential to introduce mystery guests. For a casual photographer like myself, the relationship between frame_size
and crop_factor
is not something I understand well. In my case, having the frame_size
set directly in the test could provide a signal of how it is involved in the crop_factor
calculation. On the other hand, you will likely find that an experienced team working on this application will have industry expertise and know full_frame
means 35x24 and how that impacts the crop_factor
calculation. These concerns are a balance you will want to experiment with as your application, test suite, and team evolves.
Combining Traits
One of the main reasons I find myself reaching for traits over inheritance is the ability to combine traits when creating new records.
To try out combining traits, imagine that we need to support searching for a camera by any of its attributes. Starting with a single attribute, we can use our existing trait for frame_size
and write a test like the following.
describe '#search' do
context 'when searching by frame size' do
let(:full_frame_camera) do
build(:camera, :full_frame)
end
let(:aps_c_camera) do
build(:camera, :aps_c)
end
end
end
Our current context only deals with searching for a single attribute, but we want our search ability to handle filtering on multiple attributes at once. To test this, we can try searching by frame_size
and manufacturer
. Before we write the test, we will define traits for some common manufacturer
s.
factory :camera do
trait :fujifilm do
manufacturer { 'Fujifilm' }
end
trait :nikon do
manufacturer { 'Nikon' }
end
end
We can now combine our different traits just like we would specify attributes when creating a factory.
FactoryBot.build(:camera, :aps_c, :fujifilm)
=> <Camera
manufacturer="Fujifilm",
frame_size="23.6x15.6",
memory_cards=nil>
FactoryBot.build(:camera, :nikon, :full_frame)
=> <Camera
manufacturer="Nikon",
frame_size="35x24",
memory_cards=nil>
While this is equivalent to specifying the frame_size
and manufacturer
attributes directly, well-named traits will often make it easier to understand what is important about the record we are creating. The ability to mix-and-match traits also makes it easy for us to build up more varied combinations in our tests.
describe '#search' do
context 'when searching by multiple attributes' do
let(:full_frame_nikon) do
build(:camera, :full_frame, :nikon)
end
let(:aps_c_nikon) do
build(:camera, :aps_c, :nikon)
end
let(:aps_c_fujifilm) do
build(:camera, :aps_c, :fujifilm)
end
end
end
Working with Associations
Without traits, if we wanted our camera to include a memory card, we would use FactoryBot to create a MemoryCard
and pass it in when building out the camera.
context 'when a camera has a memory card' do
let(:memory_card) do
build(:memory_card)
end
let(:camera) do
build(:camera, memory_cards: [memory_card])
end
end
Creating a MemoryCard
provides us with the flexibility to customize the memory_cards
we supply to our camera record. However, we may find we often do not care about the storage_capacity
of the memory card in the camera, but rather just that the camera has a memory card. In these cases, explicitly creating a MemoryCard
record in our test could add unnecessary noise and be more easily managed with a trait.
We can create a trait on our Camera
model that indicates this camera includes a MemoryCard
.
factory :camera do
trait :with_memory_card do
memory_cards { [build(:memory_card)] }
end
end
Our trait sets our memory_cards
association to be a single-element array with a factory-built MemoryCard
. We can simply build
our association because we are working with Struct
s and not ActiveRecord
-backed models. If you are in a Rails app, you will want to review the different options for specifying an association with FactoryBot and decide which makes sense for your situation.
FactoryBot.build(:camera, :with_memory_card)
=> <Camera
manufacturer="Kodak",
frame_size="35x24",
memory_cards=[
<MemoryCard storage_capacity="32GB">
]>
We can now update our original example to no longer explicitly create the memory card.
context 'when a camera has a memory card' do
let(:camera) { build(:camera, :with_memory_card) }
end
Leveraging Transient Attributes
We can go a step further with our trait and add the ability to specify how many memory cards a Camera
has. For this, we can use transient attributes. Transient attributes allow you to pass variables into FactoryBot that are not a part of the model you are creating but will be referenceable during the factory build process.
The syntax for transient attributes is very similar to the syntax of setting attributes on a factory.
factory :camera do
trait :with_memory_card do
transient do
number_of_cards { 1 }
end
memory_cards do
Array.new(number_of_cards) { build(:memory_card) }
end
end
end
Here, we have a transient
block that sets up an attribute, number_of_cards
, and defaults it to 1
. When we create our memory_cards
associations, we create an Array
with number_of_cards
elements where each element defaults to a new, factory-built instance of a MemoryCard
.
Just like we can explicitly set attributes when creating a factory, we can explicitly set our transient attributes as well.
# use default value for `number_of_cards`
FactoryBot.build(
:camera,
:nikon,
:full_frame,
:with_memory_card
)
=> <Camera
manufacturer="Nikon",
frame_size="35x24",
memory_cards=[
<MemoryCard storage_capacity="32GB">
]>
# set value for `number_of_cards`
FactoryBot.build(
:camera,
:nikon,
:full_frame,
:with_memory_card,
number_of_cards: 2
)
=> <Camera
manufacturer="Nikon",
frame_size="35x24",
memory_cards=[
<MemoryCard storage_capacity="32GB">,
<MemoryCard storage_capacity="32GB">
]>
This pattern is useful in tests that interact with associated records but do not require any particular setup for the associated objects.
describe '#store_picture' do
context 'when there are multiple memory cards' do
let(:camera) do
FactoryBot.build(
:camera,
:with_memory_card,
number_of_cards: 2
)
end
end
end
Inheritance is still on the table
While I rarely reach for the option of inheritance anymore, using traits does not prevent you from using this ability. You can even take advantage of traits in your nested factories if necessary.
factory :camera do
factory :wedding_camera do
full_frame
number_of_cards { 2 }
with_memory_card
end
factory :prosumer_camera do
manufacturer { 'Sony' }
aps_c
with_memory_card
end
end
FactoryBot.build(:wedding_camera)
=> <Camera
manufacturer="Kodak",
frame_size="35x24",
memory_cards=[
<MemoryCard storage_capacity="32GB">,
<MemoryCard storage_capacity="32GB">
]>
FactoryBot.build(:prosumer_camera)
=> <Camera
manufacturer="Sony",
frame_size="23.6x15.6",
memory_cards=[
<MemoryCard storage_capacity="32GB">
]>
By creating traits, you can use the same tools when defining factories as you can when building custom records in your tests.
Conclusion
Traits are a go-to feature of FactoryBot for me because they provide easy-to-use building blocks for building out custom models. While you will not need to set every attribute via trait, hopefully, this post helped show you how to use them. As you begin to use traits in your projects, I recommend reading through the documentation as it covers additional use cases.