Skip to content

Instantly share code, notes, and snippets.

@jerrod
Created May 9, 2026 14:51
Show Gist options
  • Select an option

  • Save jerrod/7489024bc35b76be790f0b4ebfbdde18 to your computer and use it in GitHub Desktop.

Select an option

Save jerrod/7489024bc35b76be790f0b4ebfbdde18 to your computer and use it in GitHub Desktop.
Rails Test Suite Speedup — investigation playbook

Rails Test Suite Speedup — Methodology

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.


Step 1 — Profile, don't guess

COVERAGE=false bundle exec rspec --profile 30 > tmp/profile.log 2>&1

Two 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.


Step 2 — Common bugs that silently double the suite

These are not code smells — they're outright bugs that cost real seconds. Check every project for all of them.

2a. SimpleCov runs unconditionally

# spec/rails_helper.rb (top)
require "simplecov"
SimpleCov.start "rails" do
  enable_coverage :branch
end

Branch 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
end

2b. ActiveStorage analyzers + previewers spawning ffmpeg

If 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.

2c. ActiveStorage variants forcing image_processing

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
end

This lets you drop ImageMagick from CI entirely.

2d. Factories with default file attachments

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
end

That 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
end

Then update the 2-3 specs that actually need the file (spec_name#variant_method) to use create(:photo, :with_image).

2e. .env.test with hardcoded local values that override CI

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

2f. Faker timestamps without freeze_time

factory :article do
  published_at { Faker::Time.between(from: 2.years.ago, to: Time.zone.now) }
end

Time-dependent assertions ("newest first", "limit to 25") become intermittently flaky and you start re-running CI. Wrap with freeze_time or use deterministic offsets.


Step 3 — Outlier specs (factory bloat, sleep loops)

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 let or 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_stubbed replace create?" If the test only needs the object's IDs / methods, not a DB row, build_stubbed skips 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.


Step 4 — Per-example overhead

If the slowest single example is < 0.3 s but the suite is still slow, the cost is in setup that runs every example.

Outer before blocks creating dependencies most tests don't need

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
end

Push the wiring into context-specific before blocks. The lazy let is fine — it only fires if referenced.

Factory cascades

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.

Re-rendering views in controller specs

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.


Step 5 — Parallelism (after the suite is clean)

Only after Steps 1-4 are exhausted. Adding parallelism to a slow suite multiplies the slow part by N, not divides it.

parallel_tests gem

# 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.

CI: one job, parallel inside

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.

Knapsack Pro (paid) for spec balance

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.


Step 6 — Infra (last resort)

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.

Service container choice

  • mysql:8.0 boots in ~10 s (heavy first-run bootstrap). mariadb:11 boots in ~3 s and speaks the same wire protocol with the mysql2 adapter.
  • Drop services your test env doesn't actually use. config.cache_store = :null_store in test env means you don't need Redis.
  • Tighten health checks: --health-interval=2s --health-timeout=2s --health-retries=15 instead of the default 5/5/20.

Action versions

Bump actions/checkout, actions/upload-artifact, actions/cache to current latest annually. Old versions warn about Node deprecation and miss perf improvements.

Runner size

blacksmith-4vcpu8vcpu 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.


Triage checklist (one-page)

  1. COVERAGE=false bundle exec rspec --profile 30
  2. Search rails_helper.rb / spec_helper.rb for SimpleCov.start outside an if ENV["COVERAGE"] guard.
  3. Search config/environments/test.rb for analyzers / previewers config — should be [].
  4. Search the factory file for after(:build) blocks that do file IO unconditionally — split into a trait.
  5. Search spec/ for RSpec.configure blocks that stub ActiveStorage::Variant#processed — add one if the app has variants.
  6. 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?"
  7. Count file IO and attachment.attach/processed calls in factories. Each one is 50–100 ms.
  8. Profile a single slow file: bundle exec rspec spec/controllers/foo_spec.rb --profile. Outer before doing more than the test needs?
  9. CI: drop unused services. Pick the lighter DB image. Bump action versions.
  10. Only after all of the above: parallelism + bigger runner + Knapsack Pro.

Anti-patterns to refuse

  • "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-fast to 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.

Why the order matters

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment