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
-
Each test worker starts one persistent Node process running jsdom. It stays up for the whole run.
-
On start, Node bundles every Stimulus controller under
app/javascript/controllersonce with esbuild, resolving@hotwired/turbo-rails,@hotwired/stimulusand your other pins through your app’s importmap and Propshaft load path. The versions under test are exactly the ones your app ships. -
visitmakes 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. -
Every
fetchthe 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. -
After every action, the runner settles the page deterministically: it runs timers on a virtual clock and waits until no Rails request is pending.
-
Assertions read a snapshot of the live DOM:
assert_dom,assert_selectandcss_selectwork as in any Rails test becausedocument_root_elementis the page. -
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
-
Speed. No browser, no Capybara server, no Puma, no Chrome. A test is a Rails request plus a jsdom page. As much as 4–10x faster than the same behaviour in system tests.
-
Determinism. Timers run on a virtual clock and the runner waits for pending requests itself. There are no sleeps, waits or retries to tune.
-
Real code paths. Real controllers, real views, real Stimulus controllers, real Turbo, real session — only the browser engine is replaced.
-
Clear failures. A JavaScript exception, an unhandled rejection or a dialog you did not expect fails the test instead of being swallowed by the browser.
-
Parallel-friendly. Each test worker gets its own Node process; nothing is shared between workers.
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:
-
Computed CSS — colours, fonts, backgrounds, hover styles.
-
Layout and geometry —
getBoundingClientRect, centring, gaps, overflow, wrapping, “these cards are the same height”. -
CSS media-query breakpoints — elements shown or hidden only by responsive CSS at a viewport width (for example Tailwind’s
md:hidden).matchMediais stubbed so controllers don’t crash, but CSS does not respond to width. Responsive behaviour driven by JavaScript, such as a mobile menu toggle, is still testable. -
Theme switching where the theme only changes CSS.
-
Screenshots and other visual evidence.
-
Drag and drop — drag-and-drop libraries find the drop target from element positions, which jsdom reports as zero.
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:
-
Keyboard input.
send_keysperforms the common browser actions itself: typing and Backspace at the end of a text field, Enter submitting a form or pressing a focused button or link, Space pressing a focused button or checkbox, Tab and Shift+Tab moving focus, and the arrow keys, Home and End stepping a range or number field. Caret movement, text selection, IME input and OS shortcuts are not supported. -
Several users at once.
using_sessiongives each user their own cookies and page, and Turbo Stream broadcasts reach every page that subscribed. Pages do not run at the same time: a page receives what happened while it was in the background when the test returns to it.
Not supported yet
These are limits of this library, not of jsdom, and could be added:
-
Third-party scripts loaded from a CDN. Remote importmap pins are replaced by empty modules, and
<script src>tags are not loaded. Pins vendored withbin/importmap pin --downloadare bundled and work. Remote scripts could be downloaded once and cached, but many of them (payment widgets, maps, charts, analytics) need cross-origin iframes, canvas, layout or their vendor’s servers, so they would still need stubs. -
Custom ActionCable channels with their own JavaScript consumers. Turbo Stream broadcasts are delivered (see below). Custom channels could be supported in-process by giving the page a fake WebSocket that runs the real channel in Ruby; reconnects and pings would stay untested.
-
iframes and new windows. Turbo Frames work, but real
<iframe>content and links that open a new window or tab are not followed. Check such a link withassert_linkand itshref, thenvisitit.
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
-
Ruby 3.3, 3.4 or 4.0
-
Rails 8.1 with
importmap-rails,propshaft,turbo-railsandstimulus-rails -
Stimulus controllers under
app/javascript/controllers, named*_controller.js -
Node.js supported by jsdom (currently 20.19+, 22.13+ or 24+)
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:
-
Adds
require "rails_jsdom"totest/test_helper.rb, afterrequire "rails/test_help". -
Creates
test/interactions/for your interaction tests. Any test directory works;bin/rails testpicks them up. -
Adds
jsdomandesbuildto thedevDependenciesinpackage.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’snode_modules. -
Sets
min-release-age=7in.npmrc, unless it is already set, so npm installs only versions published at least 7 days ago. See Supply chain. -
Warns if your npm is older than 11.10, which ignores
min-release-age. -
Runs
npm install. Pass--skip-npmto run it yourself. -
Adds
/node_modulesto.gitignore. -
Warns if
config/cable.ymldoes not use thetestadapter 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:
-
A major-version range.
npm updatenever moves to a new major version without you changingpackage.json. -
A release age. With
min-release-age=7in.npmrc, npm skips any version published in the last 7 days when it resolves a range, and fails withETARGETif no older version fits. It needs npm 11.10 or later; older npm reads the setting and ignores it. -
Your lockfile. Commit
package-lock.jsonand install withnpm ciin CI.npm ciinstalls exactly the locked versions, whatever your npm version, so new releases only arrive when someone runsnpm installornpm updateand commits the result.
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:
-
Turbo forms submit through Turbo and Rails answers in-process; Turbo Stream responses are applied before the action returns.
-
Dialogs:
<dialog>showModal/closeandmethod="dialog"forms work. -
Requests to other hosts fail like a network error (
TypeError), so a page cannot reach the internet.
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
-
Node output and page console messages go to
Rails.loggerat info level, prefixed with[rails_jsdom], so they appear wherever your test environment logs (by defaultlog/test.log). -
Cannot find module 'jsdom': runnpm install --save-dev jsdom esbuildin the app (andnpm ciin CI). -
“The
RailsJsdomNode process exited”: see the[rails_jsdom]lines in your Rails log; the next command starts a new process. -
An exception in your Rails code while answering a page request fails the test with that exception; the Node process is restarted for the next command.
-
An element is not found:
assert_domyour expectation first, check the locator rules, or scope withwithin.
Limitations
See what you lose. In addition:
-
jsdom lacks some browser APIs. The runtime stubs the common ones so controllers keep working:
matchMedia(never matches),IntersectionObserverandResizeObserver(never report),scrollToandscrollIntoView(do nothing),navigator.clipboard(writes are dropped, reads are empty) and<dialog>show,showModalandclose. A page that needs another missing API needs a small stub in your controller or test. -
Stimulus outlets are not tested yet.
Development
bundle install npm install bundle exec rake test bundle exec rubocop
bundle exec rake test runs, in order:
-
ESLint over all JavaScript (
bundle exec rake lint_js). -
The Node tests for the runtime and the ESLint rule (
bundle exec rake test_runner). -
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
-
The Ruby side (
lib/rails_jsdom) runs in the test process. The Node side (lib/rails_jsdom/runtime.js) runs the pages in jsdom. -
They talk over a line protocol, documented in Runtime protocol (source). Update that page whenever a command, field or reply changes.
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:
-
Put the markup it acts on in a dummy page (
test/dummy/app/views), with a route and controller action if it needs one. Prefer what a real app has: Rails form helpers, Turbo forms and links, Stimulus controllers. -
Add one variant to the page for each behaviour that differs: each option and filter, each kind of element it matches, Turbo and non-Turbo forms, and the cases that must fail, such as no match, several matches, a disabled field or an unexpected dialog.
-
Write one interaction test per variant against the real page, and check the outcome Rails or the page sees, such as submitted params, the current path or the DOM.
-
Keep the unit tests in
test/rails_jsdomfor code that does not need a page.
The Node runtime has its own unit tests in test/runner/*.test.js, written with Node’s built-in test runner:
-
They drive the runtime only through its public interface, the line protocol.
test/runner/support.jsplays the Ruby side and answers the runtime’s requests to Rails. -
They bundle a small fixture app in
test/runner/fixtures, with stand-ins for Stimulus and Turbo. -
Write them in the same style: no reaching into private functions.
Standards
These are enforced by rake test and RuboCop, and CI fails if any of them fails.
-
Coverage:
-
The Ruby code is held at 100% of lines and branches (SimpleCov).
-
runtime.jsand the ESLint rule are held at 100% of lines, branches and functions (Node’s test coverage). -
Code that cannot be tested is deleted.
-
Comments:
-
Only API documentation is allowed. In Ruby that means RDoc: a comment block directly above a class, module, method, constant, attribute, alias or delegation, an RDoc directive such as
:nodoc:, or a##block for a generated method. In JavaScript it means a/** */doc comment directly above a declaration. -
Every other comment fails the build. Put the meaning into a name, a method or a test.
-
Directives that switch a cop off (
# rubocop:disable,// eslint-disable) are not allowed either. -
API documentation:
-
Every public class, module, method and constant has RDoc.
bundle exec rdoc -C libreports 100%. -
The API reference is built with
bundle exec rake rdocand published to GitHub Pages. -
Readable code (Ruby):
-
Implicit returns, with no
returnand no explicitreturn nil. -
unlessonly as a one-line modifier with a predicate method. -
No boolean flags set more than once. No ternaries with a
nil,trueorfalsebranch. Nonext unless. No.nil?as a condition. -
Bang methods start with a verb, and predicate methods return booleans.
-
Blocks are named (
&block), not forwarded anonymously. -
These cops live in
rubocop/cop/readability, with their tests intest/rubocop. -
Size and complexity:
-
Ruby: methods up to 10 lines, AbcSize 13, cyclomatic and perceived complexity 5, and one level of block nesting.
-
JavaScript: complexity 8, 25 lines and 16 statements per function, nesting depth 2, 4 parameters, and no nested ternaries.
-
When code goes over a limit, remove the complexity: give a concept its own method or class, or split a function by the questions it answers. Never shorten lines with compressed or unusual syntax. A clear method slightly over a limit is better than a contorted one under it.
-
Style:
-
Ruby follows rubocop-rails-omakase and rubocop-minitest, with RuboCop’s Lint and Gemspec departments.
-
Write
casewith eachwhenbody on its own line. -
JavaScript: double quotes, no semicolons, two-space indentation,
constoverlet, and===.
Releases are published to RubyGems from GitHub Actions with trusted publishing when a v* tag is pushed.
License
MIT — see LICENSE.