Testing Configuration
This guide explains how to configure React on Rails for optimal testing with RSpec, Minitest, or other test frameworks.
Quick Start
For most applications, the recommended approach is React on Rails TestHelper with build_test_command:
# config/initializers/react_on_rails.rb
ReactOnRails.configure do |config|
config.build_test_command = "RAILS_ENV=test bin/shakapacker"
end
Then wire TestHelper into your test framework. If your app uses both RSpec and Minitest, wire both files.
RSpec — add to spec/rails_helper.rb:
require "react_on_rails/test_helper"
RSpec.configure do |config|
ReactOnRails::TestHelper.configure_rspec_to_compile_assets(config)
end
Minitest — add to test/test_helper.rb:
require "react_on_rails/test_helper"
class ActiveSupport::TestCase
setup do
ReactOnRails::TestHelper.ensure_assets_compiled
end
end
RSC and Node Renderer System Tests
React Server Components add one more moving part to the standard test setup: system tests need compiled client, server, and RSC bundles, and the Rails test process must be able to reach a node-renderer process that uses the test bundle cache.
Use this recipe for Capybara, system, and end-to-end tests that exercise stream_react_component, RSCRoute, or the rsc_payload_route.
This recipe uses the React on Rails TestHelper with build_test_command: Rails checks whether generated bundles are stale, runs your test build command when needed, and fails fast if compilation fails. The full lifecycle example is RSpec-focused because it uses before(:suite) and after(:suite) hooks; Minitest suites can reuse the ENV setup and ReactOnRails::TestHelper.ensure_assets_compiled call, but must start and stop the renderer from their own suite-level harness such as Minitest.after_run { ... } plus a suite-level startup helper. See Two Approaches to Test Asset Compilation for the underlying compilation tradeoffs.
1. Set Renderer ENV Before Rails Boots
The Pro initializer reads renderer settings while Rails boots. Set test renderer ENV values before requiring config/environment in spec/rails_helper.rb. Generated Pro apps read REACT_RENDERER_URL; some older or custom initializers read RENDERER_URL, so the example sets both names. Keep the worker ID normalization in a small support file so the Rails boot preamble and renderer lifecycle code cannot drift apart.
# spec/support/rsc_test_worker.rb
module RscTestWorker
ID = ENV.fetch("TEST_ENV_NUMBER", "")
.gsub(/[^0-9]/, "")
.then { |worker_id| worker_id.empty? ? "0" : worker_id }
end
# spec/rails_helper.rb
ENV["RAILS_ENV"] ||= "test"
require_relative "support/rsc_test_worker"
ENV["RENDERER_PORT"] ||= (3900 + RscTestWorker::ID.to_i).to_s
renderer_url = "http://127.0.0.1:#{ENV["RENDERER_PORT"]}"
ENV["REACT_RENDERER_URL"] ||= renderer_url # used by config.renderer_url = ENV["REACT_RENDERER_URL"]
ENV["RENDERER_URL"] ||= renderer_url # used by some older/custom initializers
ENV["RENDERER_SERVER_BUNDLE_CACHE_PATH"] ||=
File.expand_path("../tmp/node-renderer-bundles-test-#{RscTestWorker::ID}", __dir__)
require_relative "../config/environment"
TEST_ENV_NUMBER is set by the parallel_tests gem. It uses "" for the first worker, then "2", "3", and so on (skipping "1"), so the example uses ports 3900, 3902, 3903, and leaves a harmless gap at 3901. That unused 3901 port is expected; keeping the normalized worker ID stable matters more than filling every port number. If another service already uses ports in the 3900 range, set RENDERER_PORT before this snippet or change the base port in the example. If you use a different parallelization tool, update spec/support/rsc_test_worker.rb to normalize that tool's worker ID to a stable, path-safe RscTestWorker::ID so every worker gets a unique port, cache path, and renderer log.
If you run tests in parallel, each worker needs its own RENDERER_PORT and RENDERER_SERVER_BUNDLE_CACHE_PATH. Sharing a renderer cache across parallel workers can produce stale-bundle and missing-bundle failures that look like flaky RSC timeouts.
2. Compile Test Bundles Up Front
Configure the React on Rails test helper to compile assets before the first RSC example. Your build_test_command must build all bundles needed by the test environment, not only the browser bundle.
# config/initializers/react_on_rails.rb
ReactOnRails.configure do |config|
config.build_test_command = "NODE_ENV=test RAILS_ENV=test bin/shakapacker"
end
The Quick Start command only sets RAILS_ENV for the minimal browser-bundle case. The RSC recipe also sets NODE_ENV=test so JavaScript build scripts, webpack/shakapacker config, and the node-renderer all see the test environment consistently. If your app's build does not branch on NODE_ENV, the simpler Quick Start command is still enough for non-RSC tests.
# spec/rails_helper.rb
require "react_on_rails/test_helper"
RSpec.configure do |config|
ReactOnRails::TestHelper.configure_rspec_to_compile_assets(
config,
:rsc,
:js,
:server_rendering,
:controller
)
config.define_derived_metadata(file_path: %r{spec/(system|features)}) do |metadata|
metadata[:rsc] = true
end
end
require_relative "support/rsc_node_renderer"
Warning: Passing any metatags replaces the TestHelper defaults. Include
:js,:server_rendering, and:controlleralongside:rscif your suite mixes RSC and non-RSC specs, or those tag groups will no longer trigger compilation.
The derived metadata block intentionally tags every system and feature spec so the first browser request cannot race ahead of RSC bundle compilation. If only some directories exercise RSC, narrow the regex (for example, spec/(system/rsc|features/rsc)) or tag those examples manually to avoid compiling for unrelated system tests.
Tag request specs that hit the RSC payload endpoint explicitly with :rsc. If your suite uses a different tag scheme, pass the complete list of tags that should trigger compilation. The important part is that the build runs before the first request that can upload bundles to the node renderer.
3. Start One Test Renderer Per Worker
Start the renderer in before(:suite) after assets are compiled and stop it in after(:suite). The example below assumes your app has a node-renderer package script that launches the node-renderer server, reads RENDERER_PORT and RENDERER_SERVER_BUNDLE_CACHE_PATH from ENV, and serves the RSC test bundle cache. See Node Renderer JavaScript Configuration for launch-file and package.json script examples. Replace the package-manager command with the one your project uses.
Warning: The generated
renderer/node-renderer.jstemplate hardcodesserverBundleCachePathtopath.resolve(__dirname, '../.node-renderer-bundles'), so it ignoresRENDERER_SERVER_BUNDLE_CACHE_PATHuntil you change it. Without this edit every parallel worker shares the same cache directory and you will see stale-bundle and missing-bundle flakes that look like RSC timeouts. Update the launch file to read the env var:// renderer/node-renderer.js
const config = {
serverBundleCachePath:
process.env.RENDERER_SERVER_BUNDLE_CACHE_PATH || path.resolve(__dirname, '../.node-renderer-bundles'),
port: Number(process.env.RENDERER_PORT) || 3800,
// ...
};The renderer package's built-in default chain is
RENDERER_SERVER_BUNDLE_CACHE_PATH || RENDERER_BUNDLE_PATH || '/tmp/react-on-rails-pro-node-renderer-bundles'. The middle term is intentionally omitted here becauseRENDERER_BUNDLE_PATHis deprecated; if your existing renderer config relies on it, migrate toRENDERER_SERVER_BUNDLE_CACHE_PATHbefore adopting this snippet so the per-worker cache path actually takes effect.
# spec/support/rsc_node_renderer.rb
require "fileutils"
require "socket"
require_relative "rsc_test_worker"
module RscNodeRenderer
module_function
def wait_until_ready!(host:, port:, timeout_seconds: 30, log_path: nil, pid: nil)
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds
saw_reset = false
loop do
begin
Socket.tcp(host, port, connect_timeout: 1).close
break
rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT
# Port not yet open; renderer still booting.
rescue Errno::ECONNRESET
# Connection reset: renderer bound the port but closed the connection before accepting.
# Fall through — the PID check below will detect a dead process and raise before the deadline.
# If the deadline expires without a successful connect, the deadline error mentions the reset
# so a port already used by another service is easier to diagnose than a generic timeout.
saw_reset = true
rescue Errno::EADDRNOTAVAIL, Errno::EHOSTUNREACH, SocketError => e
raise "Cannot reach node renderer at #{host}:#{port}. " \
"Check the host configuration (#{e.class}: #{e.message})."
end
if pid
begin
# Heuristic early-exit check: if the launcher process has already died, raise now rather than
# waiting for the deadline. For `pnpm run <script>`, pnpm typically stays resident while Node
# is alive, but process managers that exec directly into Node (or daemonize) will exit here even
# though the renderer is still starting, surfacing a misleading ESRCH. The TCP probe above is
# the authoritative readiness signal; this check is only a fast-fail shortcut for the common
# pnpm/npm/yarn case. Replace it with an app-specific health check if your launcher daemonizes.
Process.kill(0, pid)
rescue Errno::ESRCH
hint = log_path ? " Check #{log_path} for startup errors." : ""
raise "Node renderer process (pid #{pid}) exited before binding to #{host}:#{port}.#{hint}"
rescue Errno::EPERM
# Process exists but we lack permission to signal it (different UID, seccomp, container boundary).
# Continue waiting for the TCP port — the port probe is authoritative for readiness.
end
end
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
hint = log_path ? " Check #{log_path} for startup errors." : ""
reset_hint = saw_reset ? " (TCP connections were reset — another process may already be using this port)" : ""
raise "Node renderer did not boot on #{host}:#{port} within #{timeout_seconds}s.#{hint}#{reset_hint}"
end
sleep 0.1
end
end
end
# Intentional suite-level closure state shared by before(:suite) and after(:suite).
# This avoids relying on RSpec hook instance variables while keeping mutation local to this support file.
rsc_node_renderer_pid = nil
rsc_node_renderer_waiter = nil
RSpec.configure do |config|
config.before(:suite) do
next unless ENV["RSC_NODE_RENDERER_TESTS"] == "1"
cache_path = ENV.fetch("RENDERER_SERVER_BUNDLE_CACHE_PATH") do
raise "RENDERER_SERVER_BUNDLE_CACHE_PATH is not set. " \
"Follow Step 1 of this guide to set it before Rails boots so every parallel worker " \
"gets a unique renderer bundle cache directory."
end
cache_path = cache_path.strip
raise "RENDERER_SERVER_BUNDLE_CACHE_PATH is empty." if cache_path.empty?
expanded_cache_path = File.expand_path(cache_path, Rails.root.to_s)
FileUtils.mkdir_p(Rails.root.join("tmp"))
tmp_root = Rails.root.join("tmp").to_s
unless expanded_cache_path.start_with?("#{tmp_root}#{File::SEPARATOR}")
raise "RENDERER_SERVER_BUNDLE_CACHE_PATH must be inside Rails.root/tmp " \
"(got: #{expanded_cache_path}). " \
"This path is deleted and recreated on every test run, so only paths " \
"inside Rails.root/tmp are permitted to prevent accidental data loss."
end
FileUtils.rm_rf(expanded_cache_path)
FileUtils.mkdir_p(expanded_cache_path)
renderer_env = {
"NODE_ENV" => "test",
"RAILS_ENV" => "test",
"RENDERER_PORT" => ENV.fetch("RENDERER_PORT") do
raise "RENDERER_PORT is not set. " \
"Follow Step 1 of this guide to set it before Rails boots so every parallel worker " \
"gets a unique renderer port."
end,
"RENDERER_SERVER_BUNDLE_CACHE_PATH" => expanded_cache_path
}
renderer_port = begin
Integer(renderer_env["RENDERER_PORT"])
rescue ArgumentError
raise "RENDERER_PORT must be an integer port number " \
"(got: #{renderer_env['RENDERER_PORT'].inspect})"
end
begin
Socket.tcp("127.0.0.1", renderer_port, connect_timeout: 1).close
raise "RENDERER_PORT #{renderer_env['RENDERER_PORT']} is already in use. " \
"A previous test run may have left an orphaned node renderer. " \
"Kill it manually or restart the CI job."
rescue Errno::ECONNREFUSED
# Port refused immediately — nothing is listening; safe to spawn.
rescue Errno::ETIMEDOUT
# SYN was silently dropped (firewall/throttle); assume nothing is listening and proceed.
# Less certain than ECONNREFUSED on loopback, but treating it as fatal would block tests
# whenever a stray DROP rule is present.
rescue Errno::ECONNRESET
raise "RENDERER_PORT #{renderer_env['RENDERER_PORT']} accepted and reset a connection. " \
"Another service may already be using it."
rescue Errno::EADDRNOTAVAIL, Errno::EHOSTUNREACH, SocketError => e
raise "Cannot probe RENDERER_PORT #{renderer_env['RENDERER_PORT']}: #{e.class}: #{e.message}"
end
FileUtils.mkdir_p(Rails.root.join("log"))
renderer_log_path = Rails.root.join("log/node-renderer-test-#{RscTestWorker::ID}.log").to_s
rsc_node_renderer_pid = Process.spawn(
renderer_env,
"pnpm", # replace with "npm", "yarn", or "bun" if that is your package manager
"run",
"node-renderer",
chdir: Rails.root.to_s,
out: renderer_log_path,
err: [:child, :out],
pgroup: true # place pnpm and its child Node process in a new process group
)
rsc_node_renderer_waiter = Process.detach(rsc_node_renderer_pid)
renderer_timeout_value = ENV.fetch("RSC_NODE_RENDERER_BOOT_TIMEOUT", "30")
renderer_timeout = begin
Integer(renderer_timeout_value)
rescue ArgumentError
raise "RSC_NODE_RENDERER_BOOT_TIMEOUT must be an integer number of seconds " \
"(got: #{ENV['RSC_NODE_RENDERER_BOOT_TIMEOUT'].inspect})"
end
begin
RscNodeRenderer.wait_until_ready!(
host: "127.0.0.1",
port: renderer_port,
timeout_seconds: renderer_timeout,
log_path: renderer_log_path,
pid: rsc_node_renderer_pid
)
rescue StandardError
begin
Process.kill("-TERM", rsc_node_renderer_pid)
rescue Errno::ESRCH, Errno::EPERM
# Already stopped or no permission to signal the process group; matches the after(:suite)
# cleanup so the original startup failure isn't masked by an EPERM here.
end
rsc_node_renderer_waiter&.join(2)
rsc_node_renderer_pid = nil
rsc_node_renderer_waiter = nil
raise
end
end
config.after(:suite) do
pid = rsc_node_renderer_pid
next unless pid
# Sends SIGTERM to the entire process group so pnpm and the Node child both stop.
# Raises Errno::ESRCH if the group is already gone; caught by rescue below.
Process.kill("-TERM", pid)
# Thread#join returns the waiter thread when the process exits, and nil on timeout.
# SIGKILL only fires when SIGTERM did not stop the process within the join window.
unless rsc_node_renderer_waiter&.join(5)
begin
Process.kill("-KILL", pid)
rescue Errno::ESRCH
# Process stopped between the join timeout and the SIGKILL; nothing more to do.
else
unless rsc_node_renderer_waiter&.join(5)
warn "Node renderer process group #{pid} did not stop after SIGKILL; " \
"it may still occupy the renderer port for the next CI retry."
end
end
end
rescue Errno::ESRCH
# Already stopped.
rescue Errno::EPERM
warn "No permission to stop node renderer process group #{pid}; " \
"it may need manual cleanup."
ensure
rsc_node_renderer_pid = nil
rsc_node_renderer_waiter = nil
end
end
Require this file from spec/rails_helper.rb after loading react_on_rails/test_helper, unless your suite already loads spec/support/**/*.rb. On slow CI workers, increase RSC_NODE_RENDERER_BOOT_TIMEOUT instead of adding sleeps.
Caveats:
- The TCP probe above is a fallback for renderers that do not expose a health endpoint; if your renderer has one, replace the probe with an HTTP health check. A successful TCP connection only proves the port is accepting connections, not that route handlers or bundle manifests are fully initialized.
- A reset during the pre-spawn probe usually means another service is already using the port and closing connections immediately.
- The
connect_timeoutcall is enough for127.0.0.1because an unused localhost port refuses the connection immediately. If you adapt the helper for a remote renderer, the operating system may still apply a longer TCP timeout. - The deadline is checked after each socket probe, so very tight timeouts can overshoot by up to
connect_timeout + sleepper iteration (roughly 1.1 s with the defaults above). - If CI hard-kills the Ruby process before
after(:suite)runs, clear any orphaned renderer processes or occupied renderer ports before retrying the job. pgroup: trueand the negative-PIDProcess.kill("-TERM", pid)/Process.kill("-KILL", pid)calls are POSIX-only. On Windows they raiseNotImplementedError, so adapt the spawn options and shutdown calls (for example, kill only the spawned PID and rely on the renderer to clean up its child) if you need to run this guide on a native Windows host. CI on Linux/macOS and WSL is unaffected.- The helper relies on the Step 2
configure_rspec_to_compile_assetssetup so bundles are available before renderer-backed examples run. Loadsupport/rsc_node_rendererafter registeringconfigure_rspec_to_compile_assetsso modern RSpec can trigger compilation withwhen_first_matching_example_definedbefore suite hooks start the renderer. Older RSpec falls back tobefore(:example, metatag), so compile assets in your CI job before running the spec process if your launcher validates bundles at boot or your suite starts renderer-backed requests outside examples tagged for compilation.
In CI, set RSC_NODE_RENDERER_TESTS=1 for jobs that need the renderer. For local development, leaving it unset lets you run non-RSC specs without starting another process.
4. Write A Capybara RSC Smoke Test
Keep the first system test boring: visit a route that streams one Server Component and assert on visible HTML plus one hydrated Client Component interaction.
RSpec.describe "Story page", :rsc, :js, type: :system do
it "renders the streamed RSC page and hydrates client controls" do
# Replace story_path and selectors with your app's RSC route and content.
visit story_path("ruby-rails-react")
expect(page).to have_css("h1", text: "Ruby, Rails, and React")
expect(page).to have_button("Save")
click_button "Save"
expect(page).to have_text("Saved")
end
end
Request specs are still useful for the payload endpoint:
RSpec.describe "RSC payload endpoint", :rsc, type: :request do
it "returns an RSC payload stream" do
# Replace this path if your app customizes config.rsc_payload_generation_url_path
# or rsc_payload_route.
get "/rsc_payload/StoryPage", params: { props: { slug: "ruby-rails-react" }.to_json }
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("application/x-ndjson")
chunks = response.body.lines.map(&:chomp).reject(&:empty?).map { |line| JSON.parse(line) }
# "html" is emitted by the default React on Rails RSC renderer; verify custom renderer output explicitly.
expect(chunks).to include(include("html" => anything))
end
end
5. Stub External APIs At The Right Boundary
Ruby stubbing tools such as WebMock and VCR only intercept requests from the Rails process. They do not intercept HTTP requests made by JavaScript running inside the separate node-renderer process.
Prefer one of these patterns:
- Fetch external data in Rails, stub it with WebMock/VCR, and pass deterministic props into the RSC tree.
- Point Node-rendered code at a local fake API server started by the test harness.
- Inject an API base URL through props or environment variables so tests never call the real service.
Avoid letting system tests depend on live third-party APIs. RSC failures from external API drift usually look like renderer timeouts or missing payload chunks, which sends debugging in the wrong direction.
6. Parallelization Checklist
- Use one renderer port per worker.
- Use one renderer bundle cache directory per worker.
- Clear the renderer cache before starting the renderer.
- Compile assets before the renderer accepts requests.
- Do not share a mutable fake API server across workers unless it isolates state per worker.
- If flakes remain, serialize the RSC system-test group first, then re-enable parallelism once port/cache isolation is proven.
Two Approaches to Test Asset Compilation
React on Rails supports two mutually exclusive approaches for compiling webpack assets during tests:
Approach 1: React on Rails Test Helper + build_test_command (Recommended)
Best for: Most applications, especially SSR, large suites, and explicit build control
Configuration:
# config/initializers/react_on_rails.rb
ReactOnRails.configure do |config|
config.build_test_command = "NODE_ENV=test RAILS_ENV=test bin/shakapacker"
# Or use your project's package manager with a custom script:
# config.build_test_command = "pnpm run build:test" # or: npm run build:test, yarn run build:test
end
In config/shakapacker.yml, keep test compilation off to avoid mixing approaches:
test:
<<: *default
compile: false
public_output_path: webpack/test
Then configure your test framework:
RSpec:
# spec/rails_helper.rb
require "react_on_rails/test_helper"
RSpec.configure do |config|
# Ensures webpack assets are compiled before the test suite runs
ReactOnRails::TestHelper.configure_rspec_to_compile_assets(config)
end
See lib/react_on_rails/test_helper.rb for more details and customization options.
By default, the helper triggers compilation for examples tagged with :js, :server_rendering, or :controller. You can pass custom metatags as an optional second parameter if you need compilation for other specs — for example, if you use Webpack to build CSS assets for request and feature specs:
# spec/rails_helper.rb
RSpec.configure do |config|
ReactOnRails::TestHelper.configure_rspec_to_compile_assets(config, :requires_webpack_assets)
config.define_derived_metadata(file_path: %r{spec/(features|requests)}) do |metadata|
metadata[:requires_webpack_assets] = true
end
end
Minitest:
# test/test_helper.rb
require "react_on_rails/test_helper"
class ActiveSupport::TestCase
setup do
ReactOnRails::TestHelper.ensure_assets_compiled
end
end
Alternatively, you can use a Minitest plugin to run the check in before_setup:
module MyMinitestPlugin
def before_setup
super
ReactOnRails::TestHelper.ensure_assets_compiled
end
end
class Minitest::Test
include MyMinitestPlugin
end
Asset detection settings:
The following settings in config/initializers/react_on_rails.rb control how the test helper detects stale assets:
ReactOnRails.configure do |config|
# Define the files to check for Webpack compilation when running tests.
config.webpack_generated_files = %w( manifest.json )
# If you're not hashing the server bundle, include it in the list:
# config.webpack_generated_files = %w( server-bundle.js manifest.json )
end
Important: The
build_test_commandmust not include the--watchoption. If you have separate server and client bundles, the command must build all of them.
How it works:
- Compiles assets at most once per test run, and only when they're out of date (stale)
- The helper checks the Webpack-generated files folder (configured via
public_root_pathandpublic_output_pathinconfig/shakapacker.yml). If the folder is missing, empty, or contains files listed inwebpack_generated_fileswithmtimes older than any source files, assets are recompiled. - Uses the
build_test_commandconfiguration - Fails fast if compilation has errors
Pros:
- ✅ Explicit control over compilation timing
- ✅ Assets compiled only once per test run
- ✅ Clear error messages if compilation fails
- ✅ Can customize the build command
- ✅ Reliable for SSR tests because assets are built before first render
Cons:
- ⚠️ Requires additional configuration in test helpers
- ⚠️ More setup to maintain
- ⚠️ Requires
build_test_commandto be set
When to use:
- You want to compile assets exactly once before tests
- You need to customize the build command
- You want explicit error handling for compilation failures
- Your test suite is slow and you want to optimize compilation
- You run SSR tests and need server bundles available before first request
Approach 2: Shakapacker Auto-Compilation (Alternative)
Best for: Simpler non-SSR test setups or teams that prefer minimal configuration
Configuration:
# config/shakapacker.yml
test:
<<: *default
compile: true
public_output_path: webpack/test
And remove React on Rails TestHelper wiring:
- Remove
config.build_test_command - Remove
ReactOnRails::TestHelpercalls inspec/rails_helper.rbortest/test_helper.rb
How it works:
- Shakapacker compiles assets on demand when packs are requested
- No React on Rails TestHelper setup is required
Pros:
- ✅ Simpler setup
- ✅ Works across frameworks without helper wiring
Cons:
- ⚠️ Less explicit compilation timing
- ⚠️ May compile multiple times in long or parallel runs
- ⚠️ For SSR tests, first-request ordering can matter if server bundles are not prebuilt
Don't Mix Approaches
Do not use both approaches together. They are mutually exclusive:
# config/shakapacker.yml
test:
compile: true # ← Don't do this...
# config/initializers/react_on_rails.rb
config.build_test_command = "RAILS_ENV=test bin/shakapacker" # ← ...with this
# spec/rails_helper.rb
ReactOnRails::TestHelper.configure_rspec_to_compile_assets(config) # ← ...and this
This will cause assets to be compiled multiple times unnecessarily.
Migrating Between Approaches
From React on Rails Test Helper → Shakapacker Auto-Compilation
-
Set
compile: trueinconfig/shakapacker.ymltest section:test:
compile: true
public_output_path: webpack/test -
Remove test helper configuration from spec/test helpers:
# spec/rails_helper.rb - REMOVE these lines:
require "react_on_rails/test_helper"
ReactOnRails::TestHelper.configure_rspec_to_compile_assets(config) -
Remove or comment out
build_test_commandin React on Rails config:# config/initializers/react_on_rails.rb
# config.build_test_command = "RAILS_ENV=test bin/shakapacker" # ← Comment out
From Shakapacker Auto-Compilation → React on Rails Test Helper
-
Set
compile: falseinconfig/shakapacker.ymltest section:test:
compile: false
public_output_path: webpack/test -
Add
build_test_commandto React on Rails config:# config/initializers/react_on_rails.rb
config.build_test_command = "RAILS_ENV=test bin/shakapacker" -
Add test helper configuration:
# spec/rails_helper.rb (for RSpec)
require "react_on_rails/test_helper"
RSpec.configure do |config|
ReactOnRails::TestHelper.configure_rspec_to_compile_assets(config)
end
Verifying Your Configuration
Use the React on Rails doctor command to verify your test configuration:
bundle exec rake react_on_rails:doctor
To auto-apply supported test-setup fixes (recommended path), run:
FIX=true bundle exec rake react_on_rails:doctor
The doctor will check:
- Whether
compile: trueis set in shakapacker.yml - Whether
build_test_commandis configured - Whether test helpers are properly set up
- Whether each detected framework (RSpec/Minitest) is wired independently
- Whether you're accidentally using both approaches
Troubleshooting
Assets not compiling during tests
Problem: Tests fail because JavaScript/CSS assets are not compiled.
Solution: Check which approach you're using:
-
If using Shakapacker auto-compilation:
# config/shakapacker.yml
test:
compile: true # ← Make sure this is true -
If using React on Rails test helper:
- Verify
build_test_commandis set - Check that test helper is configured in spec/test helper
- Run
bundle exec rake react_on_rails:doctor
- Verify
Assets compiling multiple times
Problem: Tests are slow because assets compile repeatedly.
Solutions:
-
If using Shakapacker auto-compilation:
- Switch to React on Rails test helper for one-time compilation
- Or ensure
cache_manifest: truein shakapacker.yml
-
If using React on Rails test helper:
- This shouldn't happen - assets should compile only once
- Check that you don't also have
compile: truein shakapacker.yml
Stale assets not recompiling
Problem: You added a source file but the test helper doesn't trigger recompilation.
Cause: The test helper compares mtimes of source files against generated output files. If you add a source file that has an older timestamp than the existing output (e.g., copied from another directory or restored from version control), it won't be detected as a change.
Solution: Clear out your Webpack-generated files directory to force recompilation:
rm -rf public/webpack/test
Build command fails
Problem: build_test_command fails with errors.
Check:
-
Does
bin/shakapackerexist and is it executable?ls -la bin/shakapacker
chmod +x bin/shakapacker # If needed -
Can you run the command manually?
RAILS_ENV=test bin/shakapacker -
Are your webpack configs valid for test environment?
Test helper not found
Problem: LoadError: cannot load such file -- react_on_rails/test_helper
Solution: Make sure react_on_rails gem is available in test environment:
# Gemfile
gem "react_on_rails" # Not in a specific group
# Or explicitly in test group:
group :test do
gem "react_on_rails"
end
Performance Considerations
Asset Compilation Speed
Shakapacker auto-compilation:
- Compiles on first request per test process
- May compile multiple times in parallel test environments
- Good for: Small test suites, simple webpack configs
React on Rails test helper:
- Compiles once before entire test suite
- Blocks test start until compilation complete
- Good for: Large test suites, complex webpack configs
Faster Development with Watch Mode
If you're using the React on Rails test helper and want to avoid waiting for compilation on each test run, run your build command with the --watch flag in a separate terminal:
RAILS_ENV=test bin/shakapacker --watch
# Or with your package manager:
# pnpm run build:test --watch
# npm run build:test -- --watch
# yarn run build:test --watch
This keeps webpack running and recompiling automatically when files change, so your tests start faster.
Note: The
--watchflag should only be used in a separate terminal process — never include it inbuild_test_command, which must exit after compilation.
Automatic Dev Asset Reuse (Static Mode)
When you run bin/dev static, React on Rails automatically detects the fresh development assets and reuses them for tests — no extra commands or environment variables needed.
# Terminal 1: Start static development
bin/dev static
# Terminal 2: Just run tests — they automatically use dev assets
bundle exec rspec
How it works: When bundle exec rspec (or Minitest) runs and test assets are stale or missing, the TestHelper checks if development assets in public/packs/ are:
- Present (manifest.json exists)
- Static mode (not HMR — no
http://URLs in manifest entries) - Fresh (manifest newer than all source files)
If all checks pass, React on Rails temporarily overrides Shakapacker's test config to point at the development output. You'll see:
====> React on Rails: Reusing development assets from packs
(detected fresh static-mode webpack output, skipping test compilation)
No shakapacker.yml changes are needed. The override only lasts for the test process.
Running bin/dev (HMR) and Tests Together
HMR assets are served from webpack-dev-server memory and contain http:// URLs in the manifest, so they cannot be reused by tests. When using HMR mode, you have two options:
Option A: Let TestHelper compile on demand (simplest)
# Terminal 1
bin/dev
# Terminal 2 — TestHelper runs build_test_command automatically if assets are stale
bundle exec rspec
This works but adds compilation time to the first test run.
Option B: Use a test watcher for fast iteration
# Terminal 1
bin/dev
# Terminal 2 — keeps test assets fresh in the background
bin/dev test-watch
# Terminal 3
bundle exec rspec
bin/dev test-watch auto-selects watch mode:
auto(default): picksclient-onlyif another shakapacker watcher is already running; otherwisefullfull: always builds test client + server bundles (--test-watch-mode=full)client-only: only builds test client bundles (--test-watch-mode=client-only)
Which Mode Should I Use?
| Scenario | Recommendation |
|---|---|
| General development | bin/dev static — simpler, no FOUC, tests just work |
| Need Hot Module Replacement | bin/dev + bin/dev test-watch for fast test iteration |
| CI / no dev server running | Just bundle exec rspec — TestHelper compiles automatically |
| Only running a few tests | bin/dev static + bundle exec rspec spec/path/to_spec.rb |
Migration to bin/dev test-watch
If you previously ran manual test watcher commands, migrate to the new wrapper:
-
Old:
RAILS_ENV=test bin/shakapacker --watch -
New:
bin/dev test-watch -
Old:
RAILS_ENV=test CLIENT_BUNDLE_ONLY=yes bin/shakapacker --watch -
New:
bin/dev test-watch --test-watch-mode=client-only
Advanced: Manual Shared Output (Alternative)
If you prefer to manually share output paths instead of using automatic detection:
-
Set the test output path equal to development in
config/shakapacker.yml:development:
public_output_path: packs
test:
public_output_path: packs -
Run static development mode and tests:
bin/dev static # Terminal 1
bundle exec rspec # Terminal 2
Do not share output paths with bin/dev (HMR mode) — HMR manifests will cause test failures.
Caching Strategies
Improve compilation speed with caching:
# config/shakapacker.yml
test:
cache_manifest: true # Cache manifest between runs
Parallel Testing
When running tests in parallel (with parallel_tests gem):
Shakapacker auto-compilation:
- Each process compiles independently (may be slow)
- Consider precompiling assets before running parallel tests:
RAILS_ENV=test bin/shakapacker
bundle exec rake parallel:spec
React on Rails test helper:
- Compiles once before forking processes (efficient)
- Works well out of the box with parallel testing
CI/CD Considerations
GitHub Actions / GitLab CI
Option 1: Precompile before tests
- name: Compile test assets
run: RAILS_ENV=test bundle exec rake react_on_rails:assets:compile_environment
- name: Run tests
run: bundle exec rspec
Option 2: Use Shakapacker auto-compilation
# config/shakapacker.yml
test:
compile: true
# CI workflow
- name: Run tests (assets auto-compile)
run: bundle exec rspec
Docker
When running tests in Docker, consider:
- Caching
node_modulesbetween builds - Precompiling assets in Docker build stage
- Using bind mounts for local development
Best Practices
- Choose one approach - Don't mix Shakapacker auto-compilation with React on Rails test helper
- Use doctor command - Run
rake react_on_rails:doctorto verify configuration - Precompile in CI - Consider precompiling assets before running tests in CI
- Cache node_modules - Speed up installation with caching
- Monitor compile times - If tests are slow, check asset compilation timing
Summary Decision Matrix
| Scenario | Recommendation |
|---|---|
| Default setup | React on Rails test helper |
| SSR test coverage | React on Rails test helper |
| Large test suite | React on Rails test helper |
| Parallel testing | React on Rails test helper or precompile |
| CI/CD pipeline | Precompile before tests |
| Quick local tests | Shakapacker compile: true |
| Custom build command | React on Rails test helper |
Related Documentation
- Dev Server and Testing — How
bin/dev(HMR vs static) interacts with Capybara, Playwright, Minitest system tests, and SSR request specs - Configuration Reference
- Shakapacker Configuration
- TestHelper Source Code
Need Help?
- Forum: ShakaCode Forum
- Docs: React on Rails Guides
- Support: justin@shakacode.com