Source: TesterHome
Slow-running RSpec test suites drastically reduce development efficiency and extend CI pipeline waiting time. This article shares actionable, production-verified optimization practices summarized from a real Ruby on Rails project.
In previous content, I introduced how our team rolled out Test-Driven Development (TDD) across the full codebase. However, insufficient understanding of testing tool internals created unoptimized test logic that made full suite execution extremely slow. Below is a complete, replicable tuning workflow to accelerate laggy RSpec suites.
All performance tests ran on a 2020 non-M1 MacBook Pro with 8-core CPU and 8GB RAM.
bundle exec rspec spec/
Finished in 7 minutes 6 seconds (files took 4.07 seconds to load)
552 examples, 0 failures
We added nearly 200 new test cases to cover updated business logic, bringing total examples up to ~780. Even with more test coverage, total suite runtime dropped sharply to around 2 minutes 40 seconds.
Our CircleCI pipeline also saw major improvements: execution time shortened from over 10 minutes to only 4–5 minutes.
Almost all test performance bottlenecks originate from common test code anti-patterns. This guide breaks down each root cause and corresponding fixes. Advanced optimization techniques will be covered in a separate follow-up blog post.
These high-impact adjustments deliver the largest runtime reductions and should be prioritized during test refactoring.
The 80/20 rule fully applies to test suite performance: roughly 20% of test cases generate nearly all runtime overhead. Optimizing these slow examples yields far better results than random refactoring across all tests.
RSpec includes a native profiling tool to identify your longest-running specs. Execute the command below to output your 30 slowest test cases:
# Short command
bundle exec rspec -p 30
# Full equivalent flag
bundle exec rspec --profile 30
Best practice: Save profiling reports before refactoring. Full suite runs consume substantial time, and baseline data lets you quantify post-optimization improvements.
All slow test examples trace back to one of two recurring issues:
We cover targeted solutions for both problems in subsequent sections. Profiling is always your first step—blind manual code audits waste engineering hours.
Misused let declarations are one of the most overlooked sources of slow test suites. While let simplifies reusable test variable definitions, misunderstanding its lazy evaluation behavior introduces massive performance overhead.
A let code block lazy-loads its logic once for every single example within its parent test group, acting like a per-test before hook.
The snippet below demonstrates this behavior clearly:
describe 'test group' do
let(:hello) do
puts 'create data'
'hello'
end
it 'example1' do
expect(hello).to eq('hello')
end
it 'example2' do
expect(hello).to eq('hello')
end
end
Running this test prints two create data logs, confirming the let block re-runs independently for each it example.
When you generate batches of database records with create_list inside let, every test recreates the full dataset from scratch:
describe 'list feature test group' do
let(:list) { create_list(:user, 100) }
it 'test logic 1' do end
it 'test logic 2' do end
it 'test logic 3' do end
end
For N examples in the group, this code generates N × 100 database records. Visually the code appears to create one shared dataset, but repeated record creation adds multi-second delays across dozens of spec files.
Use before(:context) to generate bulk data once for all examples in a test group, then expose the shared variable via let:
describe 'optimized list test group' do
before(:context) do
@list = create_list(:user, 100)
end
let(:list) { @list }
it 'validate list data' do end
end
Add an after(:context) hook to manually delete records and avoid cross-test data contamination:
after(:context) do
@list.each(&:destroy)
end
For automated, scalable cleanup, refer to the DatabaseCleaner section later in this article. Start your refactor by using the RSpec profiler to locate all inefficient let usage across your codebase.
Database operations are the single biggest performance bottleneck for any Rails test suite. Bulk record creation amplifies latency exponentially, especially when combined with misused let syntax.
Most list feature validations only require 3–5 sample records to cover core business scenarios. Scale down factory batch counts wherever possible:
# Slow: Generates 100 records for every test case
let(:list) { create_list(:user, 100) }
# Optimized: 3 sample records satisfy all validation needs
let(:list) { create_list(:user, 3) }
Even when bulk data is required for edge case testing, limiting record volume prevents severe test slowdowns and shortens CI pipeline execution time.
Temporary database records generated during tests cause cross-contamination risks: leftover data from one test skews count queries and assertions in subsequent test cases. This section covers native RSpec transactional fixtures to resolve the issue.
The test suite below produces inconsistent failures due to residual database records:
require 'rails_helper'
describe 'user count validation' do
before do
@user = create(:user)
end
it 'returns a single user record' do
expect(User.count).to eq(1) # Passes
end
it 'expects two user records' do
expect(User.count).to eq(2) # Fails, database state remains unchanged
end
end
Manual cleanup logic like after { User.destroy_all } is repetitive, hard to maintain, and prone to human error.
One-line configuration enables automatic transaction rollbacks after every individual test:
RSpec.configure do |config|
config.use_transactional_fixtures = true
end
This config wraps each example in an independent database transaction. All data changes automatically roll back post-execution, guaranteeing a blank database state for every isolated spec.
Transactional fixtures only apply to individual examples, not full test groups. Data created with before(:context) persists across separate describe blocks and pollutes downstream tests:
describe 'first test group' do
before(:context) do
@user = create_list(:user, 10)
end
it 'verifies ten user records exist' do
expect(User.count).to eq(10) # Passes
end
end
describe 'second independent test group' do
it 'expects zero user records' do
expect(User.count).to eq(0) # Fails; 10 residual records remain
end
end
RSpec does not natively support wrapping an entire context block in a single transaction. The recommended workaround is one-time bulk data generation paired with post-context deletion, automated via DatabaseCleaner as outlined in the next section.
DatabaseCleaner is an official community-recommended Ruby gem for flexible, high-performance database isolation. It offers multiple configurable cleanup strategies to minimize test runtime and eliminate manual data deletion boilerplate.
DatabaseCleaner conflicts with RSpec’s built-in transactional fixtures. Disable the native fixture system before setting up the gem:
RSpec.configure do |config|
config.use_transactional_fixtures = false
end
RSpec.configure do |config|
# Runs once before the entire test suite initializes
config.before(:suite) do
DatabaseCleaner[:active_record].clean_with(:deletion)
DatabaseCleaner[:redis].strategy = :deletion
DatabaseCleaner[:redis].db = 'redis://localhost:6379/1'
end
# Runs before all examples inside context blocks tagged :cleaner_for_context
config.before(:all, :cleaner_for_context) do
DatabaseCleaner[:active_record].strategy = :truncation
DatabaseCleaner.start
end
# Runs before every standalone test example (skips shared context groups)
config.before(:each) do |example|
next if example.metadata[:cleaner_for_context]
DatabaseCleaner[:active_record].strategy = :transaction
DatabaseCleaner.start
end
# Cleans data after each independent test example
config.after(:each) do |example|
next if example.metadata[:cleaner_for_context]
DatabaseCleaner.clean
end
# Purges shared context data once all group examples finish running
config.after(:all, :cleaner_for_context) do
DatabaseCleaner.clean
end
end
Untagged test groups use the :transaction strategy for every example. Each test opens a standalone database transaction that auto-rolls back upon completion, replicating native Rails fixture functionality. All isolation logic is fully delegated to DatabaseCleaner after disabling built-in Rails fixtures.
Tag any describe or context block with :cleaner_for_context to reuse a single unified dataset across all examples in the group:
describe 'bulk data validation suite', :cleaner_for_context do
before(:context) do
@users = create_list(:user, 10)
end
let(:users) { @users }
let(:single_fake_user) { create(:user) }
it 'validates bulk user attributes' {}
it 'tests user filtering logic' {}
end
The conditional next if example.metadata[:cleaner_for_context] guard skips per-test transaction cleanup for shared-data groups to avoid accidental mid-group data erasure. The :truncation strategy fully resets tables once all examples in the context complete.
This hook executes exactly once before any test cases launch. It clears leftover records from prior test runs via the efficient :deletion strategy and configures Redis cleanup rules. Our test stack relies on Redis for caching, and Redis only supports the :deletion data purging strategy.DatabaseCleaner removes repetitive manual cleanup code while maintaining fully isolated test environments. Though pre-generating all test data upfront delivers theoretical peak speed, on-demand context-scoped data generation balances performance and long-term code maintainability for most engineering teams.
After resolving database bottlenecks, inconsistent test runtime fluctuations often remain. Many examples swing from milliseconds to 5–10 seconds per execution due to unstable network connections and un-mocked live third-party API calls.
Live outbound network traffic introduces unpredictable latency and external dependency failures into your test suite. The definitive solution: mock all external API endpoints with RSpec doubles and stubs to simulate success and error responses without real HTTP requests.
Our Rails application integrates the WeChat Pay invoke_unifiedorder endpoint. Without stubs, every test triggers live external network calls. RSpec class doubles fully isolate the payment service layer:
def submit_payment_order
WxPay::Service.invoke_unifiedorder(1, 2)
end
describe 'WeChat Pay order creation' do
it 'stubs unified payment interface response' do
mock_payment_service = class_double('WxPay::Service').as_stubbed_const(:transfer_nested_constants => true)
expect(mock_payment_service).to receive(:invoke_unifiedorder).with(1, 2) { { return_code: 'SUCCESS' } }
expect(submit_payment_order).to match({ return_code: 'SUCCESS' })
end
end
This stub bypasses all real network communication and returns a predefined mock payload, cutting single-test runtime from 5–10 seconds down to 100–500 milliseconds. We applied identical mocking patterns for all third-party integrations including logistics quotation services and WeChat open APIs.
Run your full test suite offline. Any tests that hang for extended periods contain uncapped external API requests requiring stub implementation.
The following tweaks deliver modest secondary performance improvements but do not produce the massive runtime reductions of the core strategies above. Implement these after resolving major bottlenecks.
Rails enables verbose logging by default in test environments (log level 0), writing every executed SQL query to disk. Heavy disk I/O from excessive logging creates unnecessary overhead during large suite runs with minimal debugging value. Restrict logs to only critical error events:
# Default log level (0): Logs SQL statements, info, warnings, errors
Rails.logger.level
=> 0
# Update log level to only capture fatal errors (level 3)
Rails.logger.level = :error
Rails.logger.level
=> 3
This single adjustment drastically reduces disk write volume during full test suite execution.
Tune your test database pool configuration to eliminate connection starvation during parallel request test execution:
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
test:
<<: *default
database: huiliu_web_test
pool: 10
A pool size of 1 frequently causes hanging request tests due to exhausted database connections. Larger pool capacity prevents connection blocking, though performance gains are negligible for lightweight unit test suites with minimal concurrent database traffic.
This blog consolidates the complete end-to-end RSpec test suite optimization workflow deployed on our production Rails project. While our optimized suite still cannot match the sub-1-minute runtime of Ruby China’s Homeland project (500+ examples), we achieved dramatic speed improvements over the original laggy CI pipeline. Additional advanced test tuning techniques will be covered in future articles.
All shared tactics help backend developers eliminate common slow-test anti-patterns and shorten waiting times for both local development test runs and remote CI feedback pipelines.