Migrating from PhoenixTest

Copy Markdown View Source

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:

PhoenixTestFluffy
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_role and by_label, then compose them. There is no mutable within scope.
  • Single-target actions follow Playwright strictness. Multiple matches are not silently selected; use nth/first or narrow the locator.
  • Matching uses Playwright normalization, accessible-name precedence, and zero-based nth indexing.
  • aria-labelledby outranks aria-label, which outranks a native label for role names. Playwright's label locator matches each aria-labelledby reference 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 operationPhoenixTest defaultFluffy translation
fill_in, select, check, uncheck, choose, and upload label lookupexactput exact: true on by_label
select option text (exact_option)exact%{label: text} already selects an exact option label
assert_has and refute_has textsubstringomit exact: or use exact: false on the semantic locator
click_button and click_link textsubstringomit 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.

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.