A view-centric JSDom bridge for Phoenix LiveView testing.
Mount a live view into a JSDom instance so client-side JavaScript (React hooks, etc.) executes alongside the live server-side process:
{:ok, view, _html} = live(conn, "/react-counter")
view = PhoenixTestJsdom.mount(view)
html =
view
|> PhoenixTestJsdom.click("Increment", selector: "button")
|> PhoenixTestJsdom.render()
assert html =~ "Count: 1"Or use the pass-through tuple form:
{:ok, view, html} = live(conn, "/react-counter") |> PhoenixTestJsdom.mount()Interaction functions
All interaction functions return the view so you can pipe them:
view
|> PhoenixTestJsdom.click("Submit")
|> PhoenixTestJsdom.render()The render_* variants mirror Phoenix.LiveViewTest.render_* names and
return the HTML string directly, for compatibility with existing assertions:
html = PhoenixTestJsdom.render_click(view, "button", "Increment")
assert html =~ "Count: 1"Isolated LiveComponents
Render a standalone LiveComponent into JSDom without a live URL:
view = PhoenixTestJsdom.mount(MyComponent, %{id: "c", value: 0},
endpoint: MyAppWeb.Endpoint)
assert PhoenixTestJsdom.render(view) =~ "value=\"0\""Configuration
All settings are read from config :phoenix_test_jsdom, ... in config/test.exs.
| Option | Purpose |
|---|---|
node_path | Absolute path to the Node binary. If unset, the worker tries mise which node, then node on PATH. |
setup_files | List of CommonJS module paths (or a single string path) executed in each new JSDom window—polyfills, global mocks, test setup (similar to Vitest setupFiles). |
cwd | Working directory for the Node process. Use this when scripts or require() must resolve packages from your app (for example your own node_modules). |
# config/test.exs
config :phoenix_test_jsdom,
node_path: "/opt/homebrew/bin/node",
setup_files: [Path.expand("test/support/jsdom_setup.cjs", __DIR__)],
cwd: Path.expand("../assets", __DIR__)Setup modes (global vs per-file)
Global (recommended) — start once before ExUnit.start/0 so every test module shares one Node worker and request queue, while each mounted view or PhoenixTestJsdom.Session still gets its own JSDom instance id.
# test/test_helper.exs
{:ok, _} = PhoenixTestJsdom.start()
ExUnit.start()Per-file (or per-module) — start the supervisor under the test supervisor when only some tests need JSDom, or when you need different setup_files / cwd per test module (each setup_all run gets its own tree—heavier than global).
defmodule MyApp.HeavyJsTest do
use ExUnit.Case, async: true
use MyAppWeb.ConnCase
setup_all do
start_supervised(PhoenixTestJsdom)
:ok
end
# ...
endUse global for the usual case (async: true across the suite). Use per-file when isolating optional JS-heavy tests.
Async & isolation model
- One long-lived Node process (Erlang port) runs the bundled server; it multiplexes concurrent RPCs by request id.
- Each
mount/1(LiveView) orSession.new/1(PhoenixTest) allocates a separate JSDom instance id, stored inPhoenixTestJsdom.ViewRegistrykeyed by{test_pid, view_id}. Parallel async tests do not share DOM state. - After server-driven updates (
render_click/2,render_patch/2,render_async/2, etc.), HTML is re-seeded into the same JSDom id so the client bundle matches the LiveView-rendered markup. Pure client state that is not in the HTML snapshot is reset on each reseed—see the README note on LiveViewTest interop.
Waiting for client-rendered DOM — heavy widgets (Monaco, charts) often appear after mount/1 returns. Block on a stable selector before asserting:
{:ok, view, _} = live(conn, "/editor") |> PhoenixTestJsdom.mount()
view
|> PhoenixTestJsdom.wait_for(".monaco-editor", 10_000)
|> PhoenixTestJsdom.render()With PhoenixTestJsdom.Session (PhoenixTest driver), wait_for/3 returns the session for piping. The default timeout is 5000 ms. Lower-level callers can use PhoenixTestJsdom.Jsdom.wait_for_selector/3 with an instance id (see the debug tests).
User interactions & event firing
LiveView + pipable helpers (PhoenixTestJsdom on %Phoenix.LiveViewTest.View{}) dispatch real DOM events inside JSDom: click/3, click_link/3, fill_in/3, select/3, check/3, uncheck/3, choose/3, submit/2, type/3, etc. Options commonly include :selector, :within, and label-based matching as in PhoenixTest.
Low-level DOM events — PhoenixTestJsdom.FireEvent mirrors many DOM event names (click/3, change/3, key_down/3, …) and takes a Phoenix.LiveViewTest.Element from element/2 or element/3. Use this when you need precise Event fields or events not wrapped by the pipable API.
Examples:
Using with LiveViewTest
Obtain a view with live/2 (or live_isolated/3), then call mount/1 on the
%Phoenix.LiveViewTest.View{} or on the {:ok, view, html} tuple so scripts
run and the LiveView client can attach.
import Phoenix.LiveViewTest
{:ok, view, _} = live(conn, "/counter") |> PhoenixTestJsdom.mount()
html =
view
|> PhoenixTestJsdom.click("Increment", selector: "button")
|> PhoenixTestJsdom.render()
assert html =~ "Counter: 1"Prefer PhoenixTestJsdom.render/1 after interactions when you need the
DOM as JSDom sees it (including client-only updates). It falls back to
Phoenix.LiveViewTest.render/1 when the view is not mounted in JSDom, so you
can share helpers between JS and non-JS tests.
When you drive the server with Phoenix.LiveViewTest (render_click/2,
render_patch/2, render_hook/3, etc.), use the PhoenixTestJsdom.render_*
wrappers on the view (same names, same arguments) instead of the plain
LiveViewTest versions whenever the view is JSDom-mounted: they run the
server handler, re-seed JSDom with the returned HTML, and return the HTML
string. That keeps LiveView and the in-memory DOM aligned after server-driven
updates.
{:ok, view, _} = live(conn, "/counter") |> PhoenixTestJsdom.mount()
_html =
PhoenixTestJsdom.render_click(view, selector: "button", text: "Increment")
assert PhoenixTestJsdom.render(view) =~ "Counter: 1"Using with LiveView Components
Use mount/3 to render a single Phoenix.LiveComponent into JSDom without
navigating to a route. Pass the component module, assigns, and endpoint:
(required) so asset URLs resolve the same way as in app tests. The library
builds a synthetic LiveViewTest.View and seeds JSDom from
render_component/2. Pipe PhoenixTestJsdom.render/1 and the interaction
helpers the same as for a full-page LiveView.
view =
PhoenixTestJsdom.mount(MyAppWeb.MyComponent, %{id: "c", value: 0},
endpoint: MyAppWeb.Endpoint
)
html =
view
|> PhoenixTestJsdom.click("Add", selector: "button")
|> PhoenixTestJsdom.render()
assert html =~ "value=\"1\""Using with PhoenixTest
PhoenixTestJsdom.Session implements PhoenixTest.Driver. Build a
session with Session.new(MyAppWeb.Endpoint) (typically in a setup block),
import PhoenixTest, and pipe visit/2, click_button/2, fill_in/3,
assert_has/3, and the rest of the PhoenixTest API as usual—the driver
runs actions and assertions against JSDom instead of static HTML parsing.
PhoenixTestJsdom.wait_for/3, type/3, and exec_js/2 are also
defined for %PhoenixTestJsdom.Session{} so you can wait on selectors, type
like a user, or evaluate snippets in the same window after PhoenixTest steps.
import PhoenixTest
setup do
{:ok, session: PhoenixTestJsdom.Session.new(MyAppWeb.Endpoint)}
end
test "counter via PhoenixTest + JSDom", %{session: session} do
session
|> visit("/react-counter")
|> click_button("Increment")
|> assert_has("#count-display", text: "Count: 1")
endsession
|> visit("/search")
|> PhoenixTestJsdom.wait_for("#results", 5_000)
|> PhoenixTestJsdom.type("hello", selector: "input[name=q]")
|> assert_has("#results", text: "hello")Advanced
Executing Custom JS
exec_js/2 accepts a JavaScript string and runs it in the mounted window
(LiveView view or session). It returns {:ok, string} with the last
expression coerced to string, or {:error, message} on runtime errors.
Use this for small assertions, toggling feature flags in window, or calling
app globals when there is no dedicated Elixir helper.
{:ok, width} =
PhoenixTestJsdom.exec_js(view, "document.querySelector('#chart').clientWidth")
assert String.to_integer(width) > 0{:ok, _} =
PhoenixTestJsdom.exec_js(session, "window.__FLAGS = { skipAnalytics: true }")Triggering custom events
For CustomEvent (or any non-generated RTL name), use
PhoenixTestJsdom.FireEvent.fire/4: pass the view, an element/2 or
element/3 selector, the event type string, and an optional detail map merged
into the event init (for example %{detail: %{id: 1}}). Pipe render/1
afterward to read the DOM. Server-side hooks still go through
render_hook/3 (via the PhoenixTestJsdom.render_hook/3 wrapper when
JSDom-mounted).
import Phoenix.LiveViewTest, only: [element: 2]
alias PhoenixTestJsdom.FireEvent
html =
view
|> FireEvent.fire(element(view, "#sidebar"), "sidebar:toggle", %{detail: %{open: true}})
|> PhoenixTestJsdom.render()
assert html =~ ~s(aria-expanded="true")_html = PhoenixTestJsdom.render_hook(view, :refresh, %{deg: 32})Mounting React components
Load your bundle from the LiveView template
or layout (for example a root hook that calls createRoot) and mount/1
the route or static page as for any other JS. JSDom executes the scripts, the
client connects over the test WebSocket, and click/3 / wait_for/3
exercise the result. Point cwd at your assets tree if the bundle uses
require of packages from node_modules.
{:ok, view, _} = live(conn, "/react-counter") |> PhoenixTestJsdom.mount()
html =
view
|> PhoenixTestJsdom.wait_for("#count-display", 5_000)
|> PhoenixTestJsdom.click("Increment", selector: "button")
|> PhoenixTestJsdom.render()
assert html =~ "Count: 1"Adding shims/stubs with setup files
Set setup_files in config :phoenix_test_jsdom to a list of CommonJS
paths. Each file runs once when a new JSDom window is created—before your page
scripts—so you can assign globalThis.fetch mocks, polyfills, etc.
Paths should be absolute (for example with Path.expand/2). Combine with cwd when modules need to resolve from
your app’s node_modules.
# config/test.exs
config :phoenix_test_jsdom,
setup_files: [Path.expand("test/support/jsdom_setup.cjs", __DIR__)],
cwd: Path.expand("../assets", __DIR__)// test/support/jsdom_setup.cjs
globalThis.fetch = async () => ({
ok: true,
status: 200,
json: async () => ({ items: [] })
});
Summary
Functions
Waits for async server operations and re-mounts HTML in JSDom. Returns view.
Checks a checkbox in JSDom and returns the view.
Chooses a radio button in JSDom and returns the view.
Clicks a button in JSDom and returns the view.
Clicks a link in JSDom and returns the view.
Returns the current path from JSDom.
Evaluates JavaScript in the JSDom window and returns {:ok, string_result} or {:error, msg}.
Fills in an input in JSDom and returns the view.
Mounts a LiveViewTest view (or {:ok, view, html} tuple) into a JSDom
instance and returns the view.
Renders an isolated LiveComponent into a JSDom instance and returns a synthetic view.
Returns the page title from JSDom, or nil.
Patches the view to a new path (server + JSDom) and returns the view.
Returns the current HTML for the view.
Waits for async operations and re-mounts the HTML. Returns HTML.
Triggers a blur event and re-mounts the HTML into JSDom when called with a view. Returns HTML.
Triggers a change event and re-mounts the HTML. Returns HTML.
Triggers a click event via LiveViewTest and re-mounts the resulting HTML into JSDom. Returns the HTML string.
Renders a LiveComponent to HTML, mounts it in JSDom, and returns a synthetic view.
Triggers a focus event and re-mounts the HTML into JSDom when called with a view. Returns HTML.
Triggers a hook push event and re-mounts the HTML into JSDom when called with a view. Returns HTML.
Triggers a keydown event and re-mounts the HTML into JSDom when called with a view. Returns HTML.
Triggers a keyup event and re-mounts the HTML into JSDom when called with a view. Returns HTML.
Patches to a new path and re-mounts the HTML. Returns HTML.
Triggers a submit event and re-mounts the HTML. Returns HTML.
Advances an upload and re-mounts the HTML. Returns HTML.
Selects an option in JSDom and returns the view. Use :from for the label.
Same as start_link/0.
Starts the PhoenixTestJsdom supervision tree. Call from test_helper.exs.
Submits a form in JSDom and returns the view. Options: :selector, :within.
Types text into the focused element (or the element matching selector: if given).
Unchecks a checkbox in JSDom and returns the view.
Destroys the JSDom instance for the view and removes it from the registry.
Advances an upload and returns the view.
Waits for a CSS selector to appear in JSDom and returns the view or session.
Functions
Waits for async server operations and re-mounts HTML in JSDom. Returns view.
Checks a checkbox in JSDom and returns the view.
Chooses a radio button in JSDom and returns the view.
Clicks a button in JSDom and returns the view.
This dispatches the click directly in the JSDom instance, so React-rendered
buttons and other client-side elements are found and clicked. Any LiveView
pushEvent fired by the click is handled by the LV client running inside JSDom.
Options:
:selector— CSS selector restricting where to look (e.g."button"):within— scope selector
The text argument filters by button label.
Clicks a link in JSDom and returns the view.
Options: :selector, :within.
Returns the current path from JSDom.
Evaluates JavaScript in the JSDom window and returns {:ok, string_result} or {:error, msg}.
Fills in an input in JSDom and returns the view.
Options: :selector, :within. Use :with for the value.
PhoenixTestJsdom.fill_in(view, "Email", with: "hello@example.com")
Mounts a LiveViewTest view (or {:ok, view, html} tuple) into a JSDom
instance and returns the view.
The pass-through tuple form is convenient when chaining directly after live/2:
{:ok, view, html} = live(conn, "/react-counter") |> PhoenixTestJsdom.mount()
Renders an isolated LiveComponent into a JSDom instance and returns a synthetic view.
Requires :endpoint in opts to derive the base URL for script resolution.
view = PhoenixTestJsdom.mount(MyComponent, %{id: "c"}, endpoint: MyEndpoint)
Returns the page title from JSDom, or nil.
Patches the view to a new path (server + JSDom) and returns the view.
Returns the current HTML for the view.
When the view has a JSDom instance (mounted via mount/1), returns the
JSDom HTML — reflecting client-side mutations like React hook renders.
Falls back to Phoenix.LiveViewTest.render/1 for views not mounted in JSDom,
so importing render/1 from this module works for both JSDom and plain LV
tests without needing to distinguish them at the call site.
Waits for async operations and re-mounts the HTML. Returns HTML.
Triggers a blur event and re-mounts the HTML into JSDom when called with a view. Returns HTML.
Triggers a change event and re-mounts the HTML. Returns HTML.
Triggers a click event via LiveViewTest and re-mounts the resulting HTML into JSDom. Returns the HTML string.
Renders a LiveComponent to HTML, mounts it in JSDom, and returns a synthetic view.
Triggers a focus event and re-mounts the HTML into JSDom when called with a view. Returns HTML.
Triggers a hook push event and re-mounts the HTML into JSDom when called with a view. Returns HTML.
Triggers a keydown event and re-mounts the HTML into JSDom when called with a view. Returns HTML.
Triggers a keyup event and re-mounts the HTML into JSDom when called with a view. Returns HTML.
Patches to a new path and re-mounts the HTML. Returns HTML.
Triggers a submit event and re-mounts the HTML. Returns HTML.
Advances an upload and re-mounts the HTML. Returns HTML.
Selects an option in JSDom and returns the view. Use :from for the label.
Same as start_link/0.
Starts the PhoenixTestJsdom supervision tree. Call from test_helper.exs.
Configure via config :phoenix_test_jsdom, key: value in config/test.exs.
Submits a form in JSDom and returns the view. Options: :selector, :within.
Types text into the focused element (or the element matching selector: if given).
Dispatches keydown/input/keyup events per character, mimicking user keyboard input.
Use \n to insert a newline / press Enter.
Call click/3 first to ensure an element is focused, or pass selector: directly.
Returns the view or session for piping.
Unchecks a checkbox in JSDom and returns the view.
Destroys the JSDom instance for the view and removes it from the registry.
Advances an upload and returns the view.
Waits for a CSS selector to appear in JSDom and returns the view or session.