rails-jsdom

Browserless interaction tests for Rails apps built with Stimulus, Turbo and importmap.

rails-jsdom lets you write tests that read like system tests — visit, click_on, fill_in, assert_selector, assert_text — without Capybara, a test server or a browser. Your real pages render through Rails, your real Stimulus controllers and Turbo run in jsdom, and every request the page makes is answered by Rails in the same test process.

Interaction tests written this way can be as much as 4–10x faster than equivalent Rails system tests, run deterministically, and fail on JavaScript errors. rails-jsdom aims to match the API available in system tests that users are already familiar with.

Using a coding agent? Point it at the instructions for coding agents, also published as Markdown at jakehow.github.io/rails-jsdom/agents.md. They cover setting the gem up, writing interaction tests, and converting a system test suite.

class CartInteractionTest < RailsJsdom::TestCase
  test "adding an item updates the cart through its Turbo Stream" do
    sign_in_as users(:shopper)
    visit product_path(products(:mug))
    fill_in "Quantity", with: 2
    click_on "Add to cart"

    assert_dom "#cart-count", text: "2"
    assert_text "Added to your cart"
  end
end

Contents

How it works

  1. Each test worker starts one persistent Node process running jsdom. It stays up for the whole run.

  2. On start, Node bundles every Stimulus controller under app/javascript/controllers once with esbuild, resolving @hotwired/turbo-rails, @hotwired/stimulus and your other pins through your app’s importmap and Propshaft load path. The versions under test are exactly the ones your app ships.

  3. visit makes a real integration request (get), follows redirects, and loads the response HTML into a fresh jsdom window at the request’s URL. Turbo and Stimulus boot; controllers connect.

  4. Every fetch the page makes — your controllers’ own calls, Turbo Drive visits, Turbo form submissions, Turbo Frames — is sent back to Ruby and answered by Rails in-process, with the test’s cookies and session, following redirects. Turbo then renders the response exactly as it would in a browser, including Turbo Stream responses.

  5. After every action, the runner settles the page deterministically: it runs timers on a virtual clock and waits until no Rails request is pending.

  6. Assertions read a snapshot of the live DOM: assert_dom, assert_select and css_select work as in any Rails test because document_root_element is the page.

  7. JavaScript errors, unhandled promise rejections, Stimulus errors and unexpected alert, confirm or prompt dialogs fail the action that caused them.

Trade-offs compared with system tests

What you gain

What you lose

jsdom implements the DOM and runs JavaScript, but it does not lay out or paint pages. These cannot be tested with rails-jsdom; if they matter to you, keep a small number of real-browser system tests for them:

Layout assertions are brittle in any case; prefer asserting behaviour — what is in the DOM, what is hidden, what the URL is, what Rails received.

Partly supported

These work through the library imitating the browser, so the parts that depend on layout or the platform stay out of reach:

Not supported yet

These are limits of this library, not of jsdom, and could be added:

Where the time goes

The esbuild bundle is built once per test worker. Each visit then costs a Rails render, a jsdom parse and a Turbo/Stimulus boot, and each interaction costs whatever Rails requests it makes. If several tests repeat the same visit and interactions only to check different things about the result, each one pays for them again; checking those things in one test pays once.

Requirements

These are the versions CI tests and that issues are fixed for. Ruby 3.2 has reached end of life. Older Rails releases may still work, but they are not tested or supported.

Installation

Add the gem to the test group and install it:

group :test do
  gem "rails-jsdom"
end
bundle install
bin/rails generate rails_jsdom:install

The install generator:

  1. Adds require "rails_jsdom" to test/test_helper.rb, after require "rails/test_help".

  2. Creates test/interactions/ for your interaction tests. Any test directory works; bin/rails test picks them up.

  3. Adds jsdom and esbuild to the devDependencies in package.json, creating the file if your app has none. The ranges (^29.1.1, ^0.28.2) allow newer minor and patch releases but never a new major version. The gem loads both from your app’s node_modules.

  4. Sets min-release-age=7 in .npmrc, unless it is already set, so npm installs only versions published at least 7 days ago. See Supply chain.

  5. Warns if your npm is older than 11.10, which ignores min-release-age.

  6. Runs npm install. Pass --skip-npm to run it yourself.

  7. Adds /node_modules to .gitignore.

  8. Warns if config/cable.yml does not use the test adapter in the test environment, which Turbo Stream broadcasts need.

Running it again changes nothing that is already set up.

Supply chain

jsdom and esbuild run with your test process’s access to your machine and CI, so a compromised release of either, or of anything they depend on, could take them over. Compromised npm releases are usually found and removed within days. The install generator sets up three defences:

Writing tests

Inherit from RailsJsdom::TestCase, which is an ActionDispatch::IntegrationTest, so fixtures, sign_in helpers that post to your session controller, perform_enqueued_jobs and all Rails assertions work as usual.

class ProjectFilterInteractionTest < RailsJsdom::TestCase
  setup do
    sign_in_as users(:owner)
    visit projects_path
  end

  test "filtering by name leaves one card visible" do
    fill_in "search", with: "Billing"

    assert_dom "[data-project-filter-target=card]:not([hidden])", count: 1
  end

  test "an unknown name shows the empty state instead of the grid" do
    fill_in "search", with: "no-such-project"

    assert_dom "[data-project-filter-target=noResults]:not([hidden])"
    assert_dom "[data-project-filter-target=grid][hidden]"
  end
end

Patterns:

API

The full API reference is at jakehow.github.io/rails-jsdom. Tests inherit from RailsJsdom::TestCase; every helper is documented in RailsJsdom::TestHelper, with the locator rules, filters, scoping, dialogs and settling.

System test methods

System test method Status Notes
visit âś…
refresh âś… Requests the current URL again, without a history entry
go_back, go_forward âś… Across Turbo Drive visits and full page loads
using_session ✅ Each session has its own cookies and page; broadcasts reach a session’s page when the test returns to it
click_on, click_link_or_button âś…
click_link âś…
click_button âś…
fill_in âś… Text-like fields only, as in system tests
check, uncheck âś…
choose âś…
select, unselect âś…
attach_file âś… Forms submit files to Rails as multipart requests
send_keys âś… Keys perform the common browser default actions; typing happens at the end of the value, with no caret or selection
within âś… CSS selector or an element from find; also scopes assert_dom
within_fieldset, within_table âś…
find, all, first âś… text:, exact_text:, visible: and the field filters
find_field, find_button, find_link âś… with:, checked:, unchecked:, disabled:, href:, type:
Element .click, .set, .text, .value, .[], .checked?, .selected?, .disabled?, .visible?, .send_keys, .select_option, .hover âś… Plus the finders and assertions inside the element; using an element that left the page raises
page âś… Every helper, plus body and title
assert_text, assert_no_text, refute_text âś… String or Regexp
assert_selector, assert_css âś… Counts visible elements, as in system tests; count:, minimum:, maximum:
assert_no_selector, assert_no_css, refute_selector âś…
assert_field, assert_no_field âś…
assert_checked_field, assert_unchecked_field âś…
assert_button, assert_no_button âś… Disabled buttons match only with disabled:
assert_link, assert_no_link âś…
assert_current_path, assert_no_current_path âś… ignore_query:, url:, Regexp
assert_title, assert_no_title âś…
has_text?, has_content?, has_no_text?, has_no_content? âś…
has_selector?, has_css?, has_no_selector?, has_no_css? âś…
has_field?, has_no_field?, has_checked_field?, has_unchecked_field? âś…
has_button?, has_no_button?, has_link?, has_no_link? âś…
has_current_path?, has_no_current_path?, has_title?, has_no_title? âś…
current_path, current_url âś…
evaluate_script âś… Awaits a returned promise
execute_script âś…
accept_alert âś… An unexpected alert fails the test
accept_confirm, dismiss_confirm âś… An unexpected confirm fails the test
accept_prompt, dismiss_prompt âś… An unexpected prompt fails the test
hover, double_click, right_click âś… Element methods in system tests; here they also take a locator. CSS :hover styles do not apply
within_frame ❌ Not planned: real iframes are mostly cross-origin; Turbo Frames already work
within_window, switch_to_window ❌ Not planned: check a target="_blank" link with assert_link, then visit it
drag_to ❌ Never: drag-and-drop needs layout, which jsdom does not have
take_screenshot ❌ Never: jsdom does not render pages
Waiting (default_max_wait_time, retrying assertions) — Not needed: the page settles before every action and assertion

Only in rails-jsdom

Method What it does
click Clicks any element by locator, including Stimulus action shorthand (click "dropdown#toggle")
mount_partial Renders a partial and loads it as a page, with no route
mount_html Loads an HTML fragment as a page
stub_fetch Answers a page request without Rails
page_text The visible text of the page or the current within scope

assert_dom, assert_select and css_select work as in any Rails test and read the live page.

Turbo Stream broadcasts

Pages that subscribe with turbo_stream_from receive broadcasts made during the test:

test "a new comment appears for everyone watching the task" do
  visit task_path(tasks(:open))
  perform_enqueued_jobs { Comment.create!(task: tasks(:open), body: "Looks good") }

  assert_text "Looks good"
end

After each action (and before each assertion), the runner finds the page’s <turbo-cable-stream-source> elements, verifies each signed stream name with Turbo::StreamsChannel.verified_stream_name, reads the broadcasts recorded by the ActionCable test adapter, and applies new Turbo Stream messages with Turbo.renderStreamMessage. Broadcasts made before the page subscribed are skipped, forged stream names receive nothing, and each broadcast is applied once. broadcast_*_later broadcasts arrive when the test performs the jobs.

Your test environment must use the ActionCable test adapter (config/cable.yml: test: adapter: test), which is the Rails default.

Timers and settling

setTimeout, setInterval and requestAnimationFrame run on a virtual clock. After each action the runner keeps running due timers and waits for pending Rails requests until nothing is due within a one-second horizon. Timers further out — polling intervals, auto-dismissing flashes — stay pending, so pages never loop forever. A test does not need, and should not use, sleeps or waits.

Continuous integration

CI needs Node and your app’s npm packages before the tests run:

- uses: actions/setup-node@v4
  with:
    node-version: 22
- run: npm ci
- run: bin/rails test

Each parallel test worker starts its own Node process the first time it runs an interaction test.

Troubleshooting

Limitations

See what you lose. In addition:

Development

bundle install
npm install
bundle exec rake test
bundle exec rubocop

bundle exec rake test runs, in order:

  1. ESLint over all JavaScript (bundle exec rake lint_js).

  2. The Node tests for the runtime and the ESLint rule (bundle exec rake test_runner).

  3. The Ruby tests.

CI runs the same, plus RuboCop.

This repository’s own .npmrc sets min-release-age=7 too, so npm install here skips releases from the last 7 days. Use npm 11.10 or later when you change package.json.

How the pieces fit

Tests

The library is tested against a small Rails app in test/dummy, with importmap, Turbo and Stimulus.

When you add or change a helper, add its real usage to the dummy app and cover it with interaction tests in test/interactions:

The Node runtime has its own unit tests in test/runner/*.test.js, written with Node’s built-in test runner:

Standards

These are enforced by rake test and RuboCop, and CI fails if any of them fails.

Releases are published to RubyGems from GitHub Actions with trusted publishing when a v* tag is pushed.

License

MIT — see LICENSE.