A skill / prompt for any Rails app where the test suite is too slow. Took one app from 120s → 36s wall-clock by walking this list. Use it as an investigation playbook, not a checklist — every project has a different worst offender, but the same handful of patterns produce most of the slowness.
The order matters: profile first, fix code-side, then attack infra. Throwing CPU at a slow suite buys you a 30 % discount on something you should have crushed at the source.
COVERAGE=false bundle exec rspec --profile 30 > tmp/profile.log 2>&1Two sections of tmp/profile.log matter:
- Top N slowest examples — individual outliers. Anything ≥ 0.5 s in a controller/model spec is suspicious.
- Top N slowest example groups — average per example. A group with 0.5 s/example × 30 examples is a structural problem, not an outlier.
If the slowest example is < 0.3 s and the suite is still slow, the bottleneck is per-example overhead (boot, DB cleanup, factory chain), not any one test. Skip to step 4.
These are not code smells — they're outright bugs that cost real seconds. Check every project for all of them.
# spec/rails_helper.rb (top)
require "simplecov"
SimpleCov.start "rails" do
enable_coverage :branch
endBranch coverage instrumentation costs 25–40 % wall time. Most projects toggle a COVERAGE env var in bin/test and CI but leave rails_helper.rb ungated, so direct bundle exec rspec always pays the tax. Fix:
if ENV["COVERAGE"] == "true"
require "simplecov"
SimpleCov.start "rails" do
# …
end
endIf your factories attach blobs (Photo, Article, ProductVariant), every attach triggers an analyze job. Without ImageMagick / ffmpeg installed you get crashes; with them installed you pay 30–80 ms per attached factory blob. In test you don't need extracted metadata.
# config/environments/test.rb
config.active_storage.analyzers = []
config.active_storage.previewers = []Symptom in the test log: [mp3 @ 0xc53044000] Failed to find two consecutive MPEG audio frames. everywhere.
Even with analyzers off, helpers like cdn_url(asset.variant(:thumb)) often call attachment.processed to materialize the variant. That shells out to convert/libvips. In test you only render the URL into HTML for assertion — you never actually serve the image.
# spec/support/stub_active_storage_processing.rb
RSpec.configure do |config|
config.before(:each) do
[ActiveStorage::Variant, ActiveStorage::VariantWithRecord].each do |klass|
allow_any_instance_of(klass).to receive(:processed) { |v| v }
end
end
endThis lets you drop ImageMagick from CI entirely.
The pattern almost every Rails app has:
factory :photo do
# …
after(:build) do |photo|
photo.asset.attach(io: File.open("spec/fixtures/test_image.jpg"), …)
end
endThat File.open + checksum + ActiveStorage::Blob INSERT runs for every spec that creates a photo, even the 95 % that never inspect the asset. Make it opt-in:
factory :photo do
# …
trait :with_image do
after(:build) do |photo|
photo.asset.attach(…)
end
end
endThen update the 2-3 specs that actually need the file (spec_name#variant_method) to use create(:photo, :with_image).
Look for DATABASE_URL= or other connection strings in .env.test. dotenv-rails loads it on CI too. Move local-only overrides to .env.test.local (gitignore it):
.env.local
.env.development.local
.env.test.local
.env.production.local
factory :article do
published_at { Faker::Time.between(from: 2.years.ago, to: Time.zone.now) }
endTime-dependent assertions ("newest first", "limit to 25") become intermittently flaky and you start re-running CI. Wrap with freeze_time or use deterministic offsets.
For each top-15 example over 0.5 s, ask:
- "Why is N this big?" Test creates 30 records to verify a cap-at-25? Drop to 26. The smallest N that exercises the boundary is the right N.
- "Does the factory chain create more than I'm asserting on?" Article factory cascading to fresh Artist per record? Share one Artist via
letor pass it explicitly:create(:article, artist: shared_artist). - "Is there a sleep / large-loop pattern?" Tests like "throttle exemption: 400 requests" can usually be cut to "limit + small buffer". 320 proves the same thing as 400 if the cap is 300/min.
- "Could
build_stubbedreplacecreate?" If the test only needs the object's IDs / methods, not a DB row,build_stubbedskips the INSERT.
Do NOT batch these — fix one at a time, run only the affected spec, verify it still passes, commit. One file per commit.
If the slowest single example is < 0.3 s but the suite is still slow, the cost is in setup that runs every example.
RSpec.describe Phoenix::PhotosController do
let(:product) { create(:product) }
let(:photo) { create(:photo) }
before do
# Runs for EVERY example. Most don't need this wiring.
product.photos << photo
product.main_photo = photo
product.save
end
endPush the wiring into context-specific before blocks. The lazy let is fine — it only fires if referenced.
Run bundle exec rails console, then puts FactoryBot.build_stubbed(:photo).class.reflections. If the factory creates a Product → Artist → Image chain, every create(:photo) is 4+ INSERTs. Use build_stubbed for unrelated dependencies, or simplify the factory.
config.render_views in spec/rails_helper.rb makes every controller spec render the full view. Useful for catching template errors, costly per example. Either accept the cost (most projects do) or scope to render_views only in the specs that need it.
Only after Steps 1-4 are exhausted. Adding parallelism to a slow suite multiplies the slow part by N, not divides it.
# Gemfile
group :test do
gem "parallel_tests"
end# config/database.yml
test:
database: lovitt_test_<%= ENV["TEST_ENV_NUMBER"] %>Run with bundle exec parallel_rspec spec/ -n 4. Each worker gets its own DB.
Avoid the GHA-matrix shard pattern (each shard re-runs checkout + setup-ruby + db:schema:load). One job with parallel_rspec inside has 1× setup cost, then N-way internal parallelism.
- name: Set up parallel DBs (1 Rails boot, SQL replicate)
run: |
bundle exec rails db:create db:schema:load
mysqldump --no-data --column-statistics=0 lovitt_test > /tmp/schema.sql
for n in $(seq 2 ${PARALLEL_TEST_PROCESSORS}); do
mysql -e "CREATE DATABASE lovitt_test${n}"
mysql lovitt_test${n} < /tmp/schema.sql
done
- name: Run parallel_rspec
run: bundle exec parallel_rspec spec/ -n ${PARALLEL_TEST_PROCESSORS}Critical: parallel:create + parallel:load_schema boots Rails N+1 times. The mysqldump + restore loop boots Rails once and replicates via raw SQL. Saves several seconds.
parallel_tests and the GHA matrix splitter both balance by file count, which is wrong — your slowest 5 specs land in one worker and dominate wall time. Knapsack Pro records real timings and balances by time. Worth it for suites > 60 s where workers are noticeably uneven.
Only after the code-side wins are banked. Doubling vCPU only helps if parallel_rspec is your bottleneck — if 80 % of your job time is bundle install and db:schema:load, more cores does nothing.
mysql:8.0boots in ~10 s (heavy first-run bootstrap).mariadb:11boots in ~3 s and speaks the same wire protocol with themysql2adapter.- Drop services your test env doesn't actually use.
config.cache_store = :null_storein test env means you don't need Redis. - Tighten health checks:
--health-interval=2s --health-timeout=2s --health-retries=15instead of the default 5/5/20.
Bump actions/checkout, actions/upload-artifact, actions/cache to current latest annually. Old versions warn about Node deprecation and miss perf improvements.
blacksmith-4vcpu → 8vcpu is worth it if you can use the cores (-n 8). Above 8 vcpu the GHA service-container spinup, ruby setup, and schema load become the floor — more cores stop helping until you also collapse setup.
COVERAGE=false bundle exec rspec --profile 30- Search
rails_helper.rb/spec_helper.rbforSimpleCov.startoutside anif ENV["COVERAGE"]guard. - Search
config/environments/test.rbforanalyzers/previewersconfig — should be[]. - Search the factory file for
after(:build)blocks that do file IO unconditionally — split into a trait. - Search
spec/forRSpec.configureblocks that stubActiveStorage::Variant#processed— add one if the app has variants. - Look at top 10 slowest examples — for each, ask "is N as small as it could be?" and "does the factory chain create more than I assert on?"
- Count file IO and
attachment.attach/processedcalls in factories. Each one is 50–100 ms. - Profile a single slow file:
bundle exec rspec spec/controllers/foo_spec.rb --profile. Outerbeforedoing more than the test needs? - CI: drop unused services. Pick the lighter DB image. Bump action versions.
- Only after all of the above: parallelism + bigger runner + Knapsack Pro.
- "Just delete the slow test." No. Make it fast. The test exists for a reason.
- "Skip controller specs and write request specs instead." Different scope, different bug surface. Migrating doesn't delete the original cost.
- "Add
--fail-fastto mask the slowness." Faster failure isn't faster suite. Fix the suite. - "Mock the ActiveRecord layer." Tests that don't hit the DB don't catch migration breakage. Boundary mocks only — external APIs, time, env, filesystem.
A 60 % suite cleanup at the code level is reproducible on every dev machine and in every CI run, forever. A 30 % infra speedup vanishes the next time a slow factory is added to the suite. Do the cheap durable wins first; spend money on cores last.