PhoenixTestJsdom (PhoenixTestJsdom v0.1.2)

Copy Markdown View Source

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.

OptionPurpose
node_pathAbsolute path to the Node binary. If unset, the worker tries mise which node, then node on PATH.
setup_filesList of CommonJS module paths (or a single string path) executed in each new JSDom window—polyfills, global mocks, test setup (similar to Vitest setupFiles).
cwdWorking 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

  # ...
end

Use 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) or Session.new/1 (PhoenixTest) allocates a separate JSDom instance id, stored in PhoenixTestJsdom.ViewRegistry keyed 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 eventsPhoenixTestJsdom.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")
end
session
|> 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.

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

async(view, timeout \\ 200)

Waits for async server operations and re-mounts HTML in JSDom. Returns view.

check(view, label, opts \\ [])

Checks a checkbox in JSDom and returns the view.

child_spec(opts)

choose(view, label, opts \\ [])

Chooses a radio button in JSDom and returns the view.

click(view, text, opts \\ [])

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.

click_link(view, text, opts \\ [])

Clicks a link in JSDom and returns the view.

Options: :selector, :within.

current_path(view)

Returns the current path from JSDom.

exec_js(view, code)

Evaluates JavaScript in the JSDom window and returns {:ok, string_result} or {:error, msg}.

fill_in(view, label, opts)

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")

mount(err)

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()

mount(component, assigns \\ %{}, opts \\ [])

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)

page_title(view)

Returns the page title from JSDom, or nil.

patch(view, path)

Patches the view to a new path (server + JSDom) and returns the view.

render(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.

render_async(view, timeout \\ 200)

Waits for async operations and re-mounts the HTML. Returns HTML.

render_blur(view_or_element, event, value \\ %{})

Triggers a blur event and re-mounts the HTML into JSDom when called with a view. Returns HTML.

render_change(view_or_element, value \\ %{})

Triggers a change event and re-mounts the HTML. Returns HTML.

render_click(view_or_element, value_or_opts \\ %{})

Triggers a click event via LiveViewTest and re-mounts the resulting HTML into JSDom. Returns the HTML string.

render_component(component, assigns \\ %{}, opts \\ [])

Renders a LiveComponent to HTML, mounts it in JSDom, and returns a synthetic view.

render_focus(view_or_element, event, value \\ %{})

Triggers a focus event and re-mounts the HTML into JSDom when called with a view. Returns HTML.

render_hook(view_or_element, event, value \\ %{})

Triggers a hook push event and re-mounts the HTML into JSDom when called with a view. Returns HTML.

render_keydown(view_or_element, event, value \\ %{})

Triggers a keydown event and re-mounts the HTML into JSDom when called with a view. Returns HTML.

render_keyup(view_or_element, event, value \\ %{})

Triggers a keyup event and re-mounts the HTML into JSDom when called with a view. Returns HTML.

render_patch(view, path)

Patches to a new path and re-mounts the HTML. Returns HTML.

render_submit(view_or_element, value \\ %{})

Triggers a submit event and re-mounts the HTML. Returns HTML.

render_upload(upload, entry_name, percent \\ 100)

Advances an upload and re-mounts the HTML. Returns HTML.

select(view, option, opts)

Selects an option in JSDom and returns the view. Use :from for the label.

start()

Same as start_link/0.

start_link()

Starts the PhoenixTestJsdom supervision tree. Call from test_helper.exs.

Configure via config :phoenix_test_jsdom, key: value in config/test.exs.

submit(view, opts \\ [])

Submits a form in JSDom and returns the view. Options: :selector, :within.

type(view_or_session, text, opts \\ [])

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.

uncheck(view, label, opts \\ [])

Unchecks a checkbox in JSDom and returns the view.

unmount(view)

Destroys the JSDom instance for the view and removes it from the registry.

upload(upload, entry_name, percent \\ 100)

Advances an upload and returns the view.

wait_for(view_or_session, selector, timeout \\ 5000)

Waits for a CSS selector to appear in JSDom and returns the view or session.