Fast Rails Tests: How Minitest and Fixtures Outperform Factory-Heavy Suites
A Rails app with 3,000 tests shouldn’t take 20 minutes to run. If yours does, factories are probably the reason.
I migrated a production Rails 8 app from FactoryBot to fixtures last quarter. The full suite went from 14 minutes to 3 minutes 40 seconds. No test logic changed. Same assertions, same coverage. The only difference was how test data got into the database.
Here’s what I learned, including the parts that weren’t obvious.
Why Factories Get Slow
FactoryBot (and similar libraries) create database records at runtime. Every create(:user) call runs an INSERT statement. Most factories trigger callbacks, which trigger more INSERTs. A test that needs a user with a team, three projects, and some permissions might run 15+ database operations before the first assertion.
Multiply that by 3,000 tests and you’re spending most of your test time on setup, not verification.
Fixtures work differently. Rails loads fixture data once via a single bulk INSERT per table, wraps each test in a transaction, and rolls back after each test. No per-test database writes for setup. The data is just there.
# FactoryBot approach — runs INSERTs every single test
test "user can publish a draft" do
user = create(:user, role: :editor)
post = create(:post, author: user, status: :draft)
post.publish!
assert post.published?
end
# Fixture approach — data already loaded, zero setup INSERTs
test "user can publish a draft" do
post = posts(:draft_post)
post.publish!
assert post.published?
end
The fixture version isn’t just shorter. It runs roughly 4x faster because it skips database setup entirely. If you’ve been optimizing your database queries but your tests are still slow, data setup is likely the bottleneck, not query performance.
Setting Up Fixtures That Don’t Make You Miserable
The biggest complaint about fixtures is maintainability. People remember Rails 2-era YAML files with 500 lines of tangled cross-references. Modern fixtures in Rails 8 are considerably better.
File structure
Each model gets a YAML file in test/fixtures/:
# test/fixtures/users.yml
alice:
name: Alice Chen
email: alice@example.com
role: editor
team: engineering
bob:
name: Bob Park
email: bob@example.com
role: viewer
team: engineering
# test/fixtures/teams.yml
engineering:
name: Engineering
created_at: <%= 2.years.ago %>
# test/fixtures/posts.yml
draft_post:
title: Unpublished Draft
status: draft
author: alice
created_at: <%= 1.day.ago %>
published_post:
title: Live Article
status: published
author: alice
published_at: <%= 3.hours.ago %>
Association references use the fixture name directly. Rails resolves author: alice to the correct foreign key automatically.
ERB in fixtures
Fixtures support ERB, which handles dynamic values:
recent_login:
user: alice
ip_address: 192.168.1.1
created_at: <%= 30.minutes.ago %>
session_token: <%= SecureRandom.hex(32) %>
Keep fixture files focused
Create only the records your tests actually reference. You don’t need 50 users “just in case.” Start with 5-8 records per model and add more when specific tests require them.
Migrating From FactoryBot to Fixtures
Ripping out FactoryBot across a large codebase in one go is painful and unnecessary. Here’s the incremental approach that worked for me.
Step 1: Create fixtures from your most-used factories.
Look at which factories get called most often:
grep -r "create(:" test/ | sed 's/.*create(:\([a-z_]*\).*/\1/' | sort | uniq -c | sort -rn | head -10
Start with the top 3-5. Write fixture YAML files that cover the same data shapes.
Step 2: Migrate one test file at a time.
Pick a test file. Replace create(:thing) calls with fixture references. Run the file. Fix any breakage. Commit.
# Before
setup do
@user = create(:user, role: :admin)
@project = create(:project, owner: @user)
end
# After
setup do
@user = users(:alice) # alice has role: admin in fixtures
@project = projects(:main_project)
end
Step 3: Keep FactoryBot for edge cases.
Some tests genuinely need unique data combinations that don’t warrant a named fixture. That’s fine. The goal isn’t zero factories — it’s moving the bulk of your data setup to fixtures so the common case is fast.
In the app I migrated, 85% of factory calls converted to fixture references. The remaining 15% stayed as factories for tests that needed unusual attribute combinations.
Parallel Testing in Rails 8
Rails ships with built-in parallel testing. Combined with fixtures, this is where the real speed gains compound.
# test/test_helper.rb
class ActiveSupport::TestCase
parallelize(workers: :number_of_processors)
fixtures :all
end
With parallelize, Rails creates separate database instances for each worker process and loads fixtures into each one. Tests run across all CPU cores simultaneously.
If you’ve already containerized your Rails app with Docker, running parallel tests in CI with multiple cores is straightforward. On a 6-core machine, this alone cut the 3:40 suite down to about 1:15. Combined with the fixture migration, we went from 14 minutes to 75 seconds. That’s an 11x speedup.
Parallelization gotchas
Shared state: Tests that write to the filesystem, talk to external services, or use class-level mutable state will break under parallel execution. Each worker runs in its own process, so instance variables in test classes are isolated, but shared resources like temp files or mock servers need coordination.
Database ordering: Don’t assume test execution order. If test A creates a record and test B expects to find it, that assumption breaks with parallelization. Fixtures solve most of this — the data is always there.
Transactional fixtures: Parallel testing with processes (the default) can’t use transactional fixtures across workers. Rails handles this automatically by using database truncation per worker instead. You don’t need to configure anything, but be aware that self.use_transactional_tests = false in specific test classes may interact oddly with parallel workers.
Minitest Patterns That Speed Up Your Tests
Beyond data setup, a few Minitest habits keep suites fast.
Use assert_changes and assert_difference
test "publishing increments the published count" do
post = posts(:draft_post)
assert_difference -> { Post.published.count }, 1 do
post.publish!
end
end
These are clearer than before/after variable comparisons and more resistant to fixture data changes.
Avoid setup blocks for test-specific data
If only one test in a file needs specific data, create it inline rather than in setup. Every line in setup runs for every test in the file.
# Slow — runs for all 12 tests in this file
setup do
@expired_token = create(:api_token, expires_at: 1.day.ago)
end
# Better — only runs when this test executes
test "rejects expired tokens" do
expired = api_tokens(:expired_token) # fixture approach
# or: expired = create(:api_token, expires_at: 1.day.ago) # factory only when needed
result = AuthService.validate(expired)
assert_equal :expired, result.status
end
Profile slow tests
Minitest has built-in slow test reporting:
rails test --verbose 2>&1 | grep "seconds" | sort -t= -k2 -rn | head -20
Or use the minitest-reporters gem for formatted output including per-test timing:
# test/test_helper.rb
require "minitest/reporters"
Minitest::Reporters.use! Minitest::Reporters::MeanTimeReporter.new
This shows your slowest tests, which are almost always the ones doing the most database work during setup.
Fixtures With Has-Many-Through and Polymorphic Associations
The tricky part of fixtures is complex associations. Here’s how to handle them cleanly.
Has-many-through (join tables)
# test/fixtures/project_memberships.yml
alice_on_main:
user: alice
project: main_project
role: maintainer
bob_on_main:
user: bob
project: main_project
role: viewer
Name the join record something descriptive. alice_on_main is instantly readable.
Polymorphic associations
# test/fixtures/comments.yml
comment_on_post:
body: "Looks good to me"
commentable_type: Post
commentable_id: <%= ActiveRecord::FixtureSet.identify(:published_post) %>
author: alice
ActiveRecord::FixtureSet.identify(:fixture_name) returns the deterministic ID Rails assigns to a named fixture. This resolves the polymorphic foreign key correctly.
STI (Single Table Inheritance)
# test/fixtures/notifications.yml
email_notification:
type: EmailNotification
recipient: alice
subject: "Your post was published"
slack_notification:
type: SlackNotification
recipient: bob
channel: "#deployments"
Set the type column explicitly. Rails instantiates the correct subclass when you call notifications(:email_notification).
When Factories Still Make Sense
Fixtures aren’t universally better. Use factories when:
- Generating unique combinations: A test that needs 100 users with randomized attributes. Writing 100 fixture entries is absurd.
- Testing validation edge cases: When you want to test that invalid data raises errors, building the invalid record inline is clearer than maintaining a “broken” fixture.
- Temporary data: Records that should only exist for one test and whose presence would confuse other tests if they were fixtures.
The practical split I’ve landed on: fixtures for the stable, commonly-referenced data that most tests need. Factories (or plain Model.new) for the exceptions.
Benchmarks: Real Numbers From a Production App
The app: a Rails 8.0 project management tool. PostgreSQL 16, Ruby 3.3.
| Metric | FactoryBot Only | Fixtures + Parallel | Improvement |
|---|---|---|---|
| Total tests | 3,247 | 3,247 | — |
| Full suite time | 14m 12s | 1m 15s | 11.4x faster |
| Average test time | 262ms | 23ms | 11.4x faster |
| DB setup time (per test) | 180ms | ~0ms | eliminated |
| CI pipeline (GitHub Actions) | 22m | 4m | 5.5x faster |
If you’re running your tests in GitHub Actions CI/CD, the difference is even more noticeable in your monthly bill. The CI improvement is less dramatic because GitHub Actions runners have only 2 cores, so parallel testing gives a smaller multiplier. Still, the fixture migration alone accounts for roughly half the improvement even on CI.
FAQ
Can I use fixtures and FactoryBot in the same test suite?
Yes. Both work fine side by side. Load fixtures globally in test_helper.rb with fixtures :all, then use FactoryBot in specific tests where you need custom records. The only caveat: factory-created records can collide with fixture IDs if you manually set IDs in your factories. Let Rails handle ID assignment and you won’t hit this.
Do fixtures work with database_cleaner?
You don’t need database_cleaner with fixtures. Rails’ transactional test wrapper handles cleanup automatically — each test runs in a transaction that rolls back. If you’re using database_cleaner for factory-based suites, you can drop it entirely once you migrate to fixtures with transactional tests.
How do I handle fixtures for models with complex validations or callbacks?
Fixture data bypasses model validations and callbacks during loading. This is intentional — fixtures represent known-good database state. If a model requires a password hash, generate it in the fixture with ERB: password_digest: <%= BCrypt::Password.create('password') %>. For callbacks that set computed fields, set those fields directly in the YAML.
Will parallel testing break my system tests with Capybara?
System tests (browser tests) require extra care. Each parallel worker needs its own server port, and Capybara handles this automatically in Rails 8 when using driven_by :selenium. However, system tests that depend on specific fixture data visible in the browser UI should reference fixtures consistently. The main risk is test pollution through shared browser state — use setup { Capybara.reset_sessions! } if you see flaky system tests under parallel execution.
How do I keep fixture files from growing out of control?
Audit fixtures quarterly. Delete any named fixture that no test references. A quick check: grep -r "fixture_name" test/ reveals whether a fixture is used. Keep each YAML file under 30 records. If a fixture file grows beyond that, you probably have tests creating overly specific data that should use inline record creation instead.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation