Fluffy replaces PhoenixTest helpers with composable locators, explicit form submission, and a shared Phoenix/Playwright API. This guide covers the setup and behavior differences that matter when converting tests.
Test-module setup
Replace PhoenixTest-specific setup with the shared
FluffyCase recipe. It imports the API, creates a
session, and lets module, describe, or test tags choose the backend. Keep your
ordinary ConnCase for fixtures, routes, and sandbox setup.
See Installation and runtime for endpoint, Playwright, and Ecto sandbox configuration.
Common rewrites
Start with these common translations; matching and form differences are explained below:
| PhoenixTest | Fluffy |
|---|---|
visit("/potions") | visit("/potions") after start_session/2 |
fill_in("Potion name", with: name) | fill(by_label("Potion name", exact: true), name) |
select("Boomslang skin", from: "Ingredient") | select_option(by_label("Ingredient", exact: true), %{label: "Boomslang skin"}) |
check("Ready to brew") | check(by_label("Ready to brew", exact: true)) |
uncheck("Ready to brew") | uncheck(by_label("Ready to brew", exact: true)) |
click_button("Brew potion") | click(by_role(:button, name: "Brew potion")) |
click_link("Potions") | click(by_role(:link, name: "Potions")) |
assert_has("#notice") | expect(to_be_visible(by_css("#notice"))) |
assert_path("/potions") | expect(Page.to_have_url(path: "/potions")) |
assert_path("/potions", query_params: params) | expect(Page.to_have_url(path: "/potions", query: params)) |
assert_has("title", text: "Potions", exact: true) | expect(Page.to_have_title("Potions")) |
reload_page() | reload() |
upload("Recipe", path) | set_input_files(by_label("Recipe", exact: true), path) |
submit() | submit(by_css("#potion-form")) |
Use %{value: value}, %{label: label}, or %{index: zero_based_index} when
the selection criterion must be explicit. One select_option call replaces
the selected set; pass a list for a multiple select.
Locator differences
- Use typed locators such as
by_roleandby_label, then compose them. There is no mutablewithinscope. - Single-target actions follow Playwright strictness. Multiple matches are not
silently selected; use
nth/firstor narrow the locator. - Matching uses Playwright normalization, accessible-name precedence, and
zero-based
nthindexing. aria-labelledbyoutranksaria-label, which outranks a native label for role names. Playwright's label locator matches eacharia-labelledbyreference separately.- Raw CSS must be valid browser CSS. Fluffy does not reinterpret an ID such
as
#id?; escape it or prefer a semantic/test-id locator.
Translate within into immutable locator composition. This avoids hidden
mutable scope and lets the same locator be reused:
potion = by_css("#polyjuice-potion")
session
|> fill(by_label(potion, "Brewer"), "Hermione Granger")
|> click(by_role(potion, :button, name: "Brew"))PhoenixTest's positive at: positions are one-based; Fluffy follows
Playwright's zero-based indexing:
# PhoenixTest: assert_has(".creature", at: 1)
expect(session, nth(by_css(".creature"), 0) |> to_be_visible())Explicit ARIA roles override the element's default role:
<a role="menuitem">Edit</a> is located as a :menuitem. In a browser test,
first perform the interaction that makes a JavaScript-owned menu
visible. In a Phoenix test, a structurally actionable href, phx-click, or
submit control can be targeted directly when opening the menu is purely
client-side presentation.
Exact matching
PhoenixTest's exact: default depends on the helper; Fluffy follows
Playwright locator defaults:
| Source operation | PhoenixTest default | Fluffy translation |
|---|---|---|
fill_in, select, check, uncheck, choose, and upload label lookup | exact | put exact: true on by_label |
select option text (exact_option) | exact | %{label: text} already selects an exact option label |
assert_has and refute_has text | substring | omit exact: or use exact: false on the semantic locator |
click_button and click_link text | substring | omit exact: or use exact: false on by_role |
For assertions, put exactness on the locator rather than the expectation:
# assert_has("p", text: "Potion brewed", exact: true)
expect(by_text(by_css("p"), "Potion brewed", exact: true) |> to_be_visible())
# assert_has("p", text: "Potion brewed")
expect(by_text(by_css("p"), "Potion brewed", []) |> to_be_visible())Prefer labels and roles over generated IDs or CSS tied to implementation details. Compose locators to identify the intended row, section, or control; keep CSS when the test specifically checks structure or no suitable semantic locator exists.
Prefer direct state assertions too: to_be_enabled(by_label("Potion name"))
requires the field to exist and be enabled, whereas checking that no disabled
input matches can pass when the field is missing. Use to_have_value for
field values and to_be_checked for checkbox state.
PhoenixTest's exact: on a value: or selected: assertion applies to its
optional field label, not to the value or selected option itself. Preserve
that distinction by applying exact: only to the locator used to find the
field.
Assertion intent
Choose whether the old assertion meant DOM absence or user-visible state:
# The node must not exist.
session |> expect(by_css("#flash") |> to_have_count(0))
# The node may exist but must not be visible.
session |> expect(not_(by_css("#flash") |> to_be_visible()))not_(to_be_visible(...)) checks visibility; to_have_count(..., 0) checks
absence.
Page title is not an element-title locator. Migrate PhoenixTest's special
"title" assertion through the page API:
session
|> expect(Page.to_have_title("Potions classroom"))
|> expect(Page.to_have_title(~r/^Potions/))A string is an exact Playwright-style title expectation. PhoenixTest's
default title assertion used substring matching, so translate an intentional
substring check to a regular expression rather than silently strengthening
it; use Regex.escape/1 when constructing that expression from data.
Form differences
Fluffy does not keep an “active form” map. fill, check, uncheck, and
select_option mutate ordered client properties. Every LiveView change and
final submission is rebuilt from the latest DOM in tree order.
Like PhoenixTest, Fluffy's LiveView driver sends an applicable phx-change
immediately after each mutation. phx-debounce and phx-throttle do not alter
that fast-test synchronization. Keep tests of the eventual application state
on Phoenix; migrate tests of actual debounce/throttle timing to Playwright.
Form behavior to account for:
- controls removed or disabled after editing are omitted; newly inserted or renamed controls use their final ownership/name;
- duplicate names remain ordered entries instead of being prematurely deep merged;
- each multiple-select action replaces the selected set, matching
Playwright's
selectOption; - hidden inputs and checkbox entries remain independent successful controls;
- only the activated submitter contributes its value and overrides;
- readonly controls submit, but a user cannot fill them;
- option matching is exact and option fallback values use normalized text;
- invalid input types use the browser's text state;
- specialized scalar input values are retained as supplied strings in Static and LiveView; browser sanitization, browser defaults, and type validation remain Playwright behavior;
- Static and LiveView bypass native constraint validation. Use Playwright for
validation events, browser-specific serialization, and JavaScript-mutated
FormData; see Forms and files.
Explicit forms and submitters
PhoenixTest's active-form state does not carry over. Target the form when the test means generic submission:
session
|> fill(by_label("Potion name"), "Polyjuice Potion")
|> submit(by_css("#potion-form"))Click the intended submitter when its name=value, formaction, formmethod,
or other submitter override matters:
session
|> click(by_role(:button, name: "Save recipe"))press(locator, "Enter") follows implicit submission rules: it may select a
default submitter or decline to submit. Use it when the key interaction itself
is under test.
Files and uploads
The same public action covers ordinary multipart forms and managed LiveView uploads:
session
|> set_input_files(by_label("Evidence"), ["diary-front.pdf", "diary-back.pdf"])
|> click(by_role(:button, name: "Upload"))The path list preserves selection order. Pass [] to clear the input.
For generated bytes, use %Fluffy.FilePayload{}. For a JavaScript-opened file
chooser, use Playwright's Event.file_chooser. See
Files and uploads and
File choosers for examples.
Navigation, retries, and JavaScript
expect(Page.to_have_url(...)) compares an exact canonical absolute URL, a
regular expression over that complete URL, or selected structured components.
Root-relative exact strings are resolved against base_url. LiveView expectations
retry fresh renders under one deadline and wake on redirects/process exits.
LiveView actions also resolve afresh while a target is
absent, disabled, structurally hidden for a click, readonly for a fill, or
missing the requested select option. Multiple matches and semantically wrong
control types still fail immediately, and Static actions remain immediate.
PhoenixTest's assert_path(path) compares only URI.path; it ignores an
existing query unless the source assertion supplies query_params:. A
Fluffy string URL expectation is deliberately stricter and compares the
complete canonical URL. Preserve the source assertion's intent with the
structured path form when a redirect or application link retains context in a
query:
# PhoenixTest: the current URL may be /chambers/secrets?clue=diary
assert_path(session, "/chambers/secrets")
# Fluffy: keep the path-only assertion
expect(session, Page.to_have_url(path: "/chambers/secrets"))Use an exact string when the absence of a query or its serialized order is part
of the assertion. When the original specifies query_params:, use exact query
mode (the default) to preserve PhoenixTest's complete decoded-map comparison:
expect(
session,
Page.to_have_url(
path: "/chambers/secrets",
query: %{"state" => "open", "clues[]" => ["diary", "basilisk"]}
)
)Use query_mode: :subset only when unrelated names are intentionally allowed.
Both modes ignore order between distinct names and preserve the order and
duplicates of repeated values for each name. Query names and values are
strings; bracketed names such as clues[] remain literal URLSearchParams names
rather than being converted into nested Plug data. Bare parameters and empty
values both decode to ""; + and %20 both decode to a space.
Phoenix supports declarative server/HTML actions, including stock
Phoenix.HTML data-method/data-to behavior and a form button's
JS.dispatch("change"). It does not execute arbitrary JavaScript.
data-confirm is ignored; use Playwright to test the prompt or cancellation.
JavaScript-populated phx-update="ignore" controls may need a native LiveView
event or a browser interaction. See Capability matrix for
the supported boundaries.
Playwright events, pages, and independent users
Replace raw event recorders with Fluffy's listener-before-action API. The listener is installed before the callback runs, and the captured result stays in the pipe:
alias Fluffy.Download
session
|> wait_for(Event.download(:report), fn session ->
click(session, by_role(:button, name: "Download potion ledger"))
end)
|> expect(Download.to_have_suggested_filename(:report, "potions.csv"))For popups, use Event.popup with switch_page and close_page instead of
changing native page IDs. Start a second session for an independent user;
pages within one session share cookies and storage. See
Advanced events and pages for popup, dialog, navigation,
and network examples.
Advanced migration cases
Most migrations stay on Fluffy's shared API. The cases below deliberately cross into driver-native behavior or application-specific test setup.
Native unwrap/2 differences
Fluffy keeps PhoenixTest's top-level unwrap/2 name but deliberately changes
LiveView result handling:
- Static callbacks receive and must return the updated
Plug.Conn. - LiveView callbacks receive the current
Phoenix.LiveViewTest.View, but Fluffy ignores the successful return value and reconciles the retained View. - Playwright callbacks receive a typed
Fluffy.Playwright.Handle, not a mutable session map; their successful return value is also ignored.
Remove PhoenixTest continuation tuples such as {:ok, view, metadata}. Write
the native action naturally and continue the pipeline:
session
|> unwrap(fn view ->
Phoenix.LiveViewTest.render_change(view, "validate", %{"name" => "Basilisk"})
end)
|> expect(by_text("Basilisk") |> to_be_visible())Do not call assert_patch or assert_redirect inside the callback: Fluffy
consumes those pending notifications to update session URL/history and perform
driver reclassification. Assert the resulting URL or page through Fluffy
after unwrap/2. Native exceptions, throws, and exits are not translated, so
an existing catch_exit/1 around an expected LiveView failure can remain.
Use the event and page APIs for browser lifecycle operations, and
Fluffy.Playwright.evaluate/2 for JavaScript values. Reserve unwrap/2 for
native operations without a shared API. See
Native escape hatch and
Browser-only evaluation.
LiveView document boundary
Like PhoenixTest, Fluffy's LiveView driver sees the current View's DOM, not
the surrounding dead layout. Use Phoenix.ConnTest for initial outer-layout
HTML assertions or Playwright for interactions with the full document.
LiveView unwrap/2 receives only the View; it cannot access the outer layout.
Prepared test connections
Pass conn: when the first Phoenix request needs a prepared connection.
Application plugs may replace test-only assigns during normal request
processing.
Fluffy does not provide a Playwright connect-param override. Browser tests
exercise the params supplied by the application's real LiveSocket.
For one initial Phoenix mount, prepare the session's one-shot connection:
conn =
Phoenix.ConnTest.build_conn()
|> Phoenix.LiveViewTest.put_connect_params(%{"timezone" => "Europe/Berlin"})
start_session(:phoenix, conn: conn)
|> visit("/chambers/secrets")The prepared connection applies only to the first request; connect params do
not carry across later navigation. unwrap/2 runs after mounting, so it is too
late to supply initial connect params.