Instructions for coding agents
This page is for an AI coding agent working in a Rails app that uses, or is moving to, rails-jsdom. It covers setting the gem up, writing interaction tests, and converting an existing system test suite. The full API reference is at jakehow.github.io/rails-jsdom/. This page is also published as Markdown at jakehow.github.io/rails-jsdom/agents.md.
What rails-jsdom is
-
Interaction tests for Rails apps that use importmap, Turbo and Stimulus, without a browser or a test server.
-
Rails renders the real pages. The real Stimulus controllers and Turbo run in jsdom, in one Node process per test worker.
-
Every request the page makes (
fetch, Turbo Drive visits, form submissions) is answered by Rails inside the test process, in the testβs session. -
The helpers use the system test method names:
visit,click_on,fill_in,within,accept_confirm,assert_selector,findand so on.
Set up an app
-
Add
gem "rails-jsdom"to the:testgroup of theGemfileand runbundle install. -
Run
bin/rails generate rails_jsdom:install. It: -
requires the library in
test/test_helper.rb; -
creates
test/interactions/; -
adds
jsdomandesbuildtopackage.jsonand runsnpm install; -
sets
min-release-age=7in.npmrc; -
ignores
node_modules; -
warns if
config/cable.ymldoes not use thetestadapter in the test environment. -
Make CI install the Node packages before the tests, with
npm ciafter setting up Node 22 or later. -
Commit
package.json,package-lock.jsonand.npmrc.
Write an interaction test
require "test_helper" class CartInteractionTest < RailsJsdom::TestCase setup do sign_in_as users(:shopper) visit product_path(products(:mug)) end test "adding an item updates the cart" do fill_in "Quantity", with: 2 click_on "Add to cart" assert_selector "#cart-count", text: "2" assert_text "Added to your cart" end end
-
Put the test in
test/interactions/and subclassRailsJsdom::TestCase. It is anActionDispatch::IntegrationTest, so fixtures,perform_enqueued_jobsand Rails assertions work. -
Sign in the way the appβs integration tests do, usually by posting to the session controller.
-
Every action settles the page before it returns: timers up to one second ahead have run, no request is pending, and Turbo Stream broadcasts have been applied. Never add
sleep, waits or retries. -
A JavaScript error, an unhandled promise rejection or an unexpected
alert,confirmorpromptfails the action. Wrap actions that show a dialog inaccept_alert,accept_confirm,dismiss_confirm,accept_promptordismiss_prompt. -
assert_selectorandfindcount only visible elements unless you passvisible: false.assert_domandassert_selectsee every element. -
Requests to other hosts fail like a network error. Answer them with
stub_fetchafter the page loads. -
Broadcasts made with
broadcast_*_laterarrive when the test performs the jobs, for exampleperform_enqueued_jobs { click_on "Post" }. -
using_session(:name) { ... }runs a block as another user with their own cookies and page.
Convert a system test suite
Work through test/system/ one file at a time.
-
Sort each test. Convert it if it checks behaviour: what the page shows, what is hidden, where it navigates, what Rails receives. Keep it as a system test, or delete it, if it needs what jsdom cannot do:
-
computed CSS, layout or geometry;
-
CSS media-query breakpoints;
-
screenshots;
-
drag and drop;
-
real iframes or new windows;
-
custom ActionCable channels;
-
third-party scripts from a CDN.
-
Move the file from
test/system/totest/interactions/, and change the test classβs superclass fromApplicationSystemTestCasetoRailsJsdom::TestCase. -
Remove browser setup:
driven_by,using_wait_time,Capybara.*settings,sleep,wait_for_*helpers,take_screenshotand window-size code. -
Replace sign-in. System tests often sign in by filling in the login form. That still works. A faster equivalent is the appβs integration-test helper that posts to the session controller.
-
Translate the methods with the table below. Most methods keep their names. The common changes:
-
CSS or layout assertions become behaviour assertions (
assert_selector ".panel[hidden]", not checks ofdisplayor position); -
page.driverand browser-specific calls are removed; -
send_keysis limited to the keys listed in the table notes. -
Run the converted file with
bin/rails test test/interactions/<file>and fix any failure before moving on. Typical failures: -
an unexpected dialog: wrap the action in the matching
accept_*ordismiss_*; -
a request to another host: stub it with
stub_fetch; -
a missing browser API: stub it in the test with
execute_script; -
an element found more than once: scope it with
withinor passmatch: :first. -
When
test/system/is empty, removeApplicationSystemTestCase, thecapybaraandselenium-webdrivergems, and the CI steps that installed a browser.
Interaction tests are faster than system tests, so do not merge tests to save time. Keep one behaviour per test. Group checks of the same result into one test only when they would otherwise repeat the same steps.
System test methods in rails-jsdom
β works in rails-jsdom. β does not. Each link goes to the methodβs reference.
| 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 |
Limits
-
No layout or painting. Computed CSS, geometry, breakpoints, screenshots and drag and drop cannot be tested.
-
Stubbed browser APIs. jsdom lacks some browser APIs, so rails-jsdom stubs these:
matchMedianever matches,IntersectionObserverandResizeObservernever report, scrolling does nothing, andnavigator.clipboarddrops writes. A page that needs another missing API needs a stub in the test. -
Remote importmap pins load as empty modules. Vendor a pin with
bin/importmap pin --downloadto test it. -
Requirements: Ruby 3.3, 3.4 or 4.0, and Rails 8.1 with importmap, Propshaft, Turbo and Stimulus. Stimulus controllers must live under
app/javascript/controllers.