Spec Performance
Installation
SKILL.md
Spec Performance
Slow tests reduce productivity and discourage running tests frequently. This skill covers techniques for optimizing RSpec test suite performance.
Profiling Slow Tests
Built-in Profiler
# Show 10 slowest examples
rspec --profile 10
# In spec_helper.rb for always-on profiling
RSpec.configure do |config|
config.profile_examples = 10
end
Detailed Timing
# spec/support/timing.rb
RSpec.configure do |config|
config.around(:each) do |example|
start = Time.now
example.run
elapsed = Time.now - start
puts "#{example.full_description}: #{elapsed.round(2)}s" if elapsed > 1
end
end
Database Optimization
Prefer build Over create
# Slow - hits database
let(:user) { create(:user) }
# Fast - in memory only
let(:user) { build(:user) }
# Fastest - stubbed, no DB
let(:user) { build_stubbed(:user) }
When to Use Each Strategy
| Strategy | Use When |
|---|---|
build |
Testing validations, object behavior |
build_stubbed |
Need ID, testing presentation |
create |
Testing DB queries, associations, callbacks |
Minimize Database Writes
# Slow - creates 3 users
it "lists users" do
create(:user)
create(:user)
create(:user)
expect(User.count).to eq(3)
end
# Better - single batch insert
it "lists users" do
User.insert_all([
{ email: "a@test.com" },
{ email: "b@test.com" },
{ email: "c@test.com" }
])
expect(User.count).to eq(3)
end
Use before(:all) Carefully
# Creates user once for all examples
before(:all) do
@user = create(:user)
end
after(:all) do
@user.destroy
end
# Warning: shared state between examples
# Use only for read-only data
let vs let!
let - Lazy Evaluation
let(:user) { create(:user) }
it "does something" do
# User created here, when first accessed
user.name
end
it "does something else" do
# User not created if not referenced
expect(true).to be true
end
let! - Eager Evaluation
let!(:user) { create(:user) }
it "has a user in database" do
# User already created before this runs
expect(User.count).to eq(1)
end
When to Use let!
# Use let! when:
# 1. Callback side effects needed
let!(:user) { create(:user) } # Triggers after_create callbacks
# 2. Database state must exist before test
let!(:existing_user) { create(:user, email: "taken@test.com") }
it "validates uniqueness" do
new_user = build(:user, email: "taken@test.com")
expect(new_user).not_to be_valid
end
Avoid Unnecessary let!
# Bad - always creates even when not needed
let!(:user) { create(:user) }
let!(:post) { create(:post, user: user) }
let!(:comment) { create(:comment, post: post) }
# Good - only create what's needed per test
let(:user) { create(:user) }
context "with posts" do
let(:post) { create(:post, user: user) }
it "lists posts" do
post # Triggers creation
expect(user.posts).to include(post)
end
end
Parallel Testing
parallel_tests Gem
# Gemfile
gem "parallel_tests", group: [:development, :test]
# Setup
rake parallel:setup
rake parallel:create
rake parallel:migrate
# Run tests
rake parallel:spec
# or
parallel_rspec spec/
Database Configuration
# config/database.yml
test:
database: myapp_test<%= ENV['TEST_ENV_NUMBER'] %>
Avoiding Parallelization Issues
# Use unique data per process
let(:email) { "user#{Process.pid}@test.com" }
# Avoid shared files
let(:file_path) { Rails.root.join("tmp/test_#{Process.pid}.txt") }
Mocking and Stubbing for Speed
Stub External Services
# Slow - real HTTP call
it "fetches data" do
result = ExternalApi.fetch(id: 1)
expect(result).to be_present
end
# Fast - stubbed response
it "fetches data" do
allow(ExternalApi).to receive(:fetch).and_return({ data: "test" })
result = ExternalApi.fetch(id: 1)
expect(result).to eq({ data: "test" })
end
Use WebMock for HTTP
# Gemfile
gem "webmock", group: :test
# spec/spec_helper.rb
require "webmock/rspec"
WebMock.disable_net_connect!(allow_localhost: true)
# In specs
stub_request(:get, "https://api.example.com/users")
.to_return(status: 200, body: { users: [] }.to_json)
Stub Time-Consuming Methods
# Slow - actual file processing
it "processes file" do
result = FileProcessor.process(large_file)
expect(result).to be_present
end
# Fast - stub the slow method
it "processes file" do
allow(FileProcessor).to receive(:process).and_return({ success: true })
result = FileProcessor.process(large_file)
expect(result).to eq({ success: true })
end
Test Data Optimization
Use build_stubbed for Speed
# build_stubbed creates objects with:
# - Fake IDs (but not nil)
# - Fake timestamps
# - No database writes
user = build_stubbed(:user)
user.id # => 1001 (fake but present)
user.persisted? # => true (pretends to be saved)
Minimal Factory Attributes
# Slow - lots of associations
factory :order do
user
shipping_address
billing_address
coupon
association :items, count: 5
end
# Fast - minimal required attributes
factory :order do
status { "pending" }
total { 100 }
trait :with_user do
user
end
end
Avoid N+1 in Test Setup
# Slow - N+1 queries in setup
let(:users) { create_list(:user, 10) }
before do
users.each { |u| create(:post, user: u) }
end
# Better - batch operations
before do
users = create_list(:user, 10)
Post.insert_all(users.map { |u| { user_id: u.id, title: "Post" } })
end
CI Optimization
Fail Fast
# spec/spec_helper.rb
RSpec.configure do |config|
config.fail_fast = ENV["CI"].present?
end
Bisect for Flaky Tests
# Find minimal set that reproduces failure
rspec --bisect
Example Status Persistence
# spec/spec_helper.rb
RSpec.configure do |config|
# Re-run only failed specs first
config.example_status_persistence_file_path = "spec/examples.txt"
end
Performance Checklist
Quick Wins
- Use
buildinstead ofcreatewhen possible - Use
build_stubbedfor presentation tests - Stub external HTTP calls
- Avoid
let!whenletworks
Medium Effort
- Set up parallel testing
- Profile and fix slowest specs
- Minimize factory associations
- Use
before(:all)for read-only setup
Advanced
- Database cleaner strategy optimization
- Shared database connections for system specs
- Spring preloader for development
- CI caching for gems and assets
Benchmarking Helpers
# spec/support/benchmark_helper.rb
module BenchmarkHelper
def measure(label = "Block", &block)
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = block.call
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
puts "#{label}: #{(elapsed * 1000).round(2)}ms"
result
end
end
# Usage in specs
include BenchmarkHelper
it "performs quickly" do
measure("User creation") { create(:user) }
measure("User build") { build(:user) }
end
Additional Resources
Reference Files
references/parallel-testing.md- Parallel test configurationreferences/ci-optimization.md- CI-specific optimizations
Example Files
examples/fast_spec.rb- Optimized spec patternsexamples/slow_spec.rb- Anti-patterns to avoid