Sometimes the number of factories of a project grows fast and keeping them tidy can be tricky. Old versions, outdated factories, and inefficient ones can slow down the CI quite a lot. Here are some notes that I took to keep them in a good shape.
I’m using RSpec with FactoryBot, but some strategies can be applied to other frameworks.
Linting factories
FactoryBot proposes a rake take in the documentation to lint their factory which invokes the method FactoryBot.lint
.
Basically, it checks if the stubbed objects created (or built) by the factories are valid. It can be a good strategy to prepare a spec that applies this check periodically (like once per release or more often if you prefer), for example:
# spec/lib/factories_spec.rb
FACTORIES_TO_IGNORE = [:invalid_author, :invalid_post].freeze
RSpec.describe 'Factories collection', order: :defined do
let(:factories) do
factories = FactoryBot.factories
if FACTORIES_TO_IGNORE.any?
factories = factories.reject do |factory|
factory.name =~ /#{FACTORIES_TO_IGNORE.join('|')}/
end
end
factories
end
it 'builds valid entities' do
linting = FactoryBot.lint(factories, traits: true, strategy: :build)
expect(linting).to be_nil
end
it 'creates valid entities' do
linting = FactoryBot.lint(factories, traits: true, strategy: :create)
expect(linting).to be_nil
end
end
In the code above, we have 2 nested factories that produce invalid entities so we skip them from linting.
Checking record counts
When you have a complex hierarchy of factories, another type of check that can be pretty useful regards the number of records that get created by a factory. It is useful to prevent the creation of unexpected unused objects in the application.
# spec/lib/factories_spec.rb
# ...
it 'creates only the expected records with the post factory' do
expect { create(:post) }.to(
change(Post, :count).from(0).to(1).and(
change(Author, :count).from(0).to(1).and(
change(Profile, :count).from(0).to(1)
)
)
)
end
# ...
However, this check doesn’t permit monitoring the creation of other entities in the database. An alternative approach is to add a helper method to count all the records for each table, here is an example using Postgres:
# spec/support/database_helpers.rb
RSpec.shared_context 'database helpers' do
def count_all_records
special_tables = %w[ar_internal_metadata schema_migrations]
results = {}
ActiveRecord::Base.connection.execute('SELECT * FROM information_schema.tables').to_a.each do |result|
table = result['table_name']
next if result['table_schema'] != 'public' || result['table_type'] !~ /table/i || special_tables.include?(table)
cnt = ActiveRecord::Base.connection.execute("SELECT COUNT(*) FROM #{table}").first['count']
results[table] = cnt if cnt.positive?
end
results.sort_by { |_k, v| v }.to_h
end
end
Then the previous spec can be improved with:
# spec/lib/factories_spec.rb
# ...
it 'creates only the expected records with the post factory' do
expected_records = { 'posts' => 1, 'authors' => 1, 'profiles' => 1 }
expect { create(:post) }.to(
change { count_all_records }.from({}).to(expected_records)
)
end
# ...
Searching for unexpected DB records created on build
Sometimes you could have factories that create records also when using build
or build_stubbed
.
Perhaps due to inexperience with factory associations or due to outdated code.
A trivial check could be: grep create spec/factories/*
To validate all the available factories and traits you can setup a spec like this:
# spec/lib/factories_spec.rb
# ...
context "with the list of file's factories", order: :defined do
FactoryBot.factories.each do |factory|
it "doesn't create DB records when building a #{factory.name}" do
expect { build(factory.name) }.not_to(change { count_all_records })
traits = factory.defined_traits.map(&:name)
traits.each do |trait|
expect { build(factory.name, trait) }.not_to(change { count_all_records })
end
end
end
end
# ...
Another approach to track down the extra entities created during the execution of the test suite can be achieved by adding some tracking code in the rails_helper:
if ENV['OPT_TRACE_COMMITS']
ApplicationRecord.class_eval do
after_commit do
Rails.logger.debug { "> after_commit: #{self.class} # id: #{id} - #{respond_to?(:name) ? name : ''}" }
backtrace = caller.select { |line| line.include? '_spec.rb' }
backtrace.last(3).each do |trace|
Rails.logger.debug { " .. #{trace}" }
end
end
end
end
Additional checks
If you follow the convention that a factory filename has the plural form and it contains a factory with the matching singular form, it can be useful to search for factories with invalid filenames:
# spec/lib/factories_spec.rb
# ...
FACTORIES = begin
factories = Rails.root.join('spec/factories').glob('*.rb').map(&:basename).map(&:to_s).sort
factories.map do |factory_file|
factory_file.gsub(/\.rb\Z/, '')
end
end.freeze
FACTORIES.each do |factory_file|
it "finds a matching factory for #{factory_file} file" do
factory = factory_file.singularize
expect(FactoryBot.factories.registered?(factory)).to be_truthy
end
end
# ...
Feel free to leave me a comment to improve this post.