The optimal way to create a set of records with FactoryBot.create_list & FactoryBot.build_list
Hernan Velasquez
Posted on June 19, 2021
The purpose
Recently I advised a fellow Rubyist trying to create 20 records in rspec to change something like:
# arbitrarily, assign even numbers to employee_number in this example
let(:first) { create (:employee, employee_number: 2) }
let(:second) { create (:employee, employee_number: 4) }
let(:third) { create (:employee, employee_number: 6) }
let(:fourth) { create (:employee, employee_number: 8) }
let(:fifth) { create (:employee, employee_number: 10) }
...
let(:tenth) { create (:employee, employee_number: 20) }
To something like:
let(:employees) { create_list(:employee, 20) }
But unfortunately this wasn't that simple, so I want to share this quick write up to expose the problems we faced and the final solution we reached.
The uniqueness validation
For the sake of this example, let's build our employee model as:
class Employee
validates_uniqueness_of :employee_number
end
And the migration:
add_column :employees, :employee_number, :integer, default: 0
So any attempt to call create_list will result in an exception Validation failed: employee_number should be unique since FactoryBot will try to create the 20 records with this field in 0. Even trying:
let(:employees) { create_list(:employee, 20, employee_number: 20) }
Will also fail since FactoryBot will try to create the 20 records with this field in 20.
First approach, the block
Reading FactoryBot documentation, we found that you can pass a block to create_list to manipulate the record to be created, so our next approach was doing:
let(:employees) {
create_list(:employee, 20) do |record, i|
# arbitrarily, assign even numbers to employee_number in this example
record.employee_number = i * 2
end
}
First approach failed, why?
Unfortunately FactoryBot folks haven't documented the use of the block properly. When we read the docs the first time, we believed that create_list was built as something like
def create_list
object = build the object
yield(object) if block_given?
object.save!
end
But the way they built this was something like:
def create_list
object = build the object
object.save!
yield(object) if block_given?
end
So what we see here is the same effect of:
let(:employees) { create_list(:employee, 20) }
Trying to create 20 employees with employee_number set to its default value, 0, basically because he is saving the object before giving us the opportunity to manipulate the employee_number as we see fit.
Second approach, saving in the block.. will it work?
So yeah... next think we thought of was doing:
let(:employees) {
create_list(:employee, 20) do |record, i|
# arbitrarily, assign even numbers to employee_number in this example
record.employee_number = i * 2
record.save!
end
}
But, this will work? The answer ..... NOPE... but, why?
On the first iteration, it will create an employee with employee_number 0 * 2 = 0
On the second iteration, expect FactoryBot to create an employee with employee_number 1 * 2 = 2, but as he creates the record before yielding, then it first creates the record with its default (guess what, 0), so yeap, we'll get Validation failed: employee_number should be unique again.
Even if we don't have the uniqueness validation, it seems weird to create a persisted object (insert) to immediately update it (update).
Third and final approach, using build_list instead.
So, how is the best way to create a set of records using FactoryBot when you have weird validations to take care of?
let(:employees) {
build_list(:employee, 20) do |record, i|
# arbitrarily, assign even numbers to employee_number in this example
record.employee_number = i * 2
record.save!
end
}
build_list was intended to just build the object in memory without persisting them, so we can use it in combination to save! within a block to create the list we wanted without triggering the validations before saving them.
Its better to use save! instead of save so you will see any other validation error in case it happens to you.
Happy testing!
Posted on June 19, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.