Current section

Files

Jump to
fluffy docs migration-from-phoenix-test.md
Raw

docs/migration-from-phoenix-test.md

# Migrating from PhoenixTest

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.

## Phoenix and browser tests in the same module

Keep related tests together, sharing fixtures, helpers, and the same actions
and assertions. Run ordinary Phoenix tests in process and choose a real
browser only for the tests that need JavaScript or browser behavior.

With the shared [`FluffyCase` recipe](usage.md#shared-test-case), Phoenix is
the default; one tag selects Playwright for an individual test:

```elixir
defmodule MyAppWeb.PotionTest do
  use MyAppWeb.FluffyCase, async: true

  test "lists the available potions", %{session: session} do
    session
    |> visit("/potions")
    |> assert(visible(by_text("Polyjuice Potion")))
  end

  @tag backend: :playwright
  test "brews a potion through a JavaScript hook", %{session: session} do
    session
    |> visit("/potions")
    |> click(by_role(:button, name: "Brew potion"))
    |> assert(visible(by_text("Potion brewed")))
  end
end
```

The first test uses ConnTest or LiveViewTest; the second uses Playwright.
The backend tag is interpreted by your shared `FluffyCase` setup. You can
also select a backend for a describe block or an entire module.

### Test-module setup

Replace PhoenixTest-specific setup with the shared
[`FluffyCase` recipe](usage.md#shared-test-case). 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](installation.md) for endpoint, Playwright, and
Ecto sandbox configuration.

## Actionability checks and waiting, even without a browser

Fluffy's Phoenix backend checks each action's prerequisites: for example,
`click` rejects disabled or structurally hidden targets, and `fill` requires
an enabled, editable control. For clicks, fills, checks, and selections,
LiveView retries temporarily missing targets and applicable state changes:
disabled controls, hidden click targets, readonly fill targets, and missing
select options. Static checks immediately.

PhoenixTest already rejects disabled buttons in its LiveView driver, but does
not provide these structural click checks and LiveView action retries.
Separate assertions that only establish an action's prerequisites are usually
redundant. Perform the action and assert its outcome instead:

```elixir
session
|> fill(by_label("Potion name"), "Polyjuice Potion")
|> click(by_role(:button, name: "Save potion"))
|> assert(page_url(path: "/potions"))
```

Keep explicit state assertions when visibility, enablement, or editability is
itself the behavior under test. The checks differ by action: Phoenix `fill`
does not check visibility, and file uploads allow hidden or disabled inputs.
Phoenix does not compute CSS layout or detect overlapping elements. See
[Actionability checks and waiting](usage.md#actionability-checks-and-waiting)
for the per-action rules and browser differences.

## Common rewrites

Start with these common translations; matching and form differences are
explained below. Each call is a pipeline step with the session supplied by
`|>`:

| 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"))` |
| `refute_has("#notice")` | `assert(count(by_css("#notice"), 0))` |
| `assert_path("/potions")` | `assert(page_url(path: "/potions"))` |
| `assert_path("/potions", query_params: params)` | `assert(page_url(path: "/potions", query: params))` |
| `assert_has("title", text: "Potions", exact: true)` | `assert(page_title("Potions"))` |
| `reload_page()` | `reload()` |
| `upload("Recipe", path)` | `set_input_files(by_label("Recipe", exact: true), path)` |
| `submit()` | `submit(by_css("#potion-form"))` |

For a positive `assert_has`, choose visibility or an explicit count according
to [Assertion intent](#assertion-intent). A visibility assertion also requires
the matched element to be visible; it is not a direct DOM-presence equivalent.

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:

```elixir
potion = by_css("#polyjuice-potion")

session
|> fill(by_label(potion, "Brewer", exact: true), "Hermione Granger")
|> click(by_role(potion, :button, name: "Brew"))
```

PhoenixTest's positive `at:` positions are one-based; Fluffy follows
Playwright's zero-based indexing:

```elixir
# PhoenixTest: assert_has(".creature", at: 1)
assert(session, visible(nth(by_css(".creature"), 0)))
```

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:

```elixir
# assert_has("p", text: "Potion brewed", exact: true)
session |> assert(visible(by_text("Potion brewed", exact: true)))

# assert_has("p", text: "Potion brewed")
session |> assert(visible(by_text("Potion brewed")))
```

These examples express visible text rather than preserve the `p` selector.
A text locator finds the smallest matching element; scoping `by_text` under
`by_css("p")` searches within that paragraph, rather than filtering the
paragraph itself. For a substring check that must retain the paragraph
selector, use `filter(by_css("p"), has_text: "Potion brewed")`.

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: `assert(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 `value` for
field values and `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:

```elixir
# The node must not exist.
session |> assert(count(by_css("#flash"), 0))

# The node may exist but must not be visible.
session |> refute(visible(by_css("#flash")))
```

`refute(visible(locator))` checks visibility; `assert(count(locator, 0))` checks
absence. Even `:phoenix` respects structural hiddenness, unlike PhoenixTest's
DOM-presence assertions. See
[Visibility and DOM presence](usage.md#visibility-and-dom-presence) for the
structural checks and their differences from browser rendering.

### Page titles

Page title is not an element-title locator. Migrate PhoenixTest's special
`"title"` assertion through the page API:

```elixir
session
|> assert(page_title("Potions classroom"))
|> assert(page_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](capabilities.md#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:

```elixir
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:

```elixir
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:

```elixir
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](usage.md#files-and-uploads) and
[File choosers](advanced-events.md#file-choosers) for examples.

## Navigation, retries, and JavaScript

### URL matching

`assert(page_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`.

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:

```elixir
# PhoenixTest: the current URL may be /chambers/secrets?clue=diary
assert_path(session, "/chambers/secrets")

# Fluffy: keep the path-only assertion
assert(session, page_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:

```elixir
assert(
  session,
  page_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.

### Retries

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.

### JavaScript

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](capabilities.md) 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:

```elixir
alias Fluffy.Event

session
|> wait_for(Event.download(:report), fn session ->
  click(session, by_role(:button, name: "Download potion ledger"))
end)
|> assert(download_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](advanced-events.md) 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:

```elixir
session
|> unwrap(fn view ->
  Phoenix.LiveViewTest.render_change(view, "validate", %{"name" => "Basilisk"})
end)
|> assert(visible(by_text("Basilisk")))
```

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](usage.md#native-escape-hatch) and
[Browser-only evaluation](usage.md#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:

```elixir
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.