Current section
Files
Jump to
Current section
Files
docs/migration-from-phoenix-test.md
# Coming from PhoenixTest
Start with `import Fluffy.PhoenixTest`. This facade makes Fluffy mostly a
drop-in replacement for supported PhoenixTest helpers: keep `visit`, `fill_in`,
`click_button`, `assert_has`, `assert_path`, `within`, and `submit` in your tests.
You do not need to rewrite them into locators to adopt Fluffy or use Playwright.
## Less manual waiting
With the Phoenix backend, Fluffy retries LiveView assertions and supported action
prerequisites automatically—for example, while waiting for a button to appear or
become enabled. PhoenixTest supports assertion waiting with an explicit positive
`timeout:`; Fluffy also waits before actions and enables waiting by default.
Static pages are checked immediately.
With the browser backend, visiting a LiveView waits for it to connect before
returning. Unlike PhoenixTest.Playwright, you don't need to add
`assert_has("body .phx-connected")` after each visit. Application-specific loading
may still need an assertion.
See [Actionability checks and waiting](usage.md#actionability-checks-and-waiting)
for supported retries, timeout options, and connection readiness.
## Replace the import and add lifecycle setup
Replace `import PhoenixTest` with `import Fluffy.PhoenixTest`, including imports
in your shared case module. Import the facade on its own; mixing it with
`import Fluffy` introduces overlapping function names.
After completing [Installation and runtime](installation.md), keep your application's
`ConnCase` and add `Fluffy.Test.setup/1` after its setup. A prepared connection can
still be piped directly into `Fluffy.PhoenixTest.visit/2`:
```elixir
defmodule MyAppWeb.PotionTest do
use MyAppWeb.ConnCase, async: true
import Fluffy.PhoenixTest
setup context do
Fluffy.Test.setup(context)
end
test "saves a potion", %{conn: conn} do
conn
|> visit("/potions/new")
|> within("#potion-form", fn session ->
session
|> fill_in("Name", with: "Polyjuice")
|> click_button("Save")
end)
|> assert_path("/potions")
|> assert_has("#notice", text: "Saved")
end
end
```
Keep existing fixture and authentication setup. Fluffy adopts an existing
sandbox checkout; the required endpoint and sandbox configuration lives in
[Installation and runtime](installation.md#ecto-sandbox). If your shared case
already calls `Fluffy.Test.setup/1`, do not call it again in individual modules.
## Phoenix and browser tests in the same module
Piping a connection into `Fluffy.PhoenixTest.visit/2` always starts a Phoenix session.
To choose a backend, start a session explicitly. The same facade calls work with both:
```elixir
defmodule MyAppWeb.PotionTest do
use ExUnit.Case, async: true
import Fluffy.PhoenixTest
setup context do
:ok = Fluffy.Test.setup(context)
%{session: start_session(Map.get(context, :backend, :phoenix))}
end
test "lists the available potions", %{session: session} do
session
|> visit("/potions")
|> assert_has("h2", text: "Polyjuice Potion")
end
@tag backend: :playwright
test "brews a potion through a JavaScript hook", %{session: session} do
session
|> visit("/potions")
|> click_button("Brew potion")
|> assert_has("#notice", text: "Potion brewed")
end
end
```
The setup above interprets the tag; Fluffy does not select a backend from tags
by itself. Move this setup and the facade import into your application's case
module when sharing it across tests. Module and describe tags work with the
same setup. See [Choosing a backend](usage.md#choosing-a-backend) for guidance
and [Playwright setup](installation.md#playwright-setup) for browser installation.
## What stays familiar, and what to check
The facade preserves PhoenixTest vocabulary and conveniences:
- Field actions use exact labels by default. `within` scopes a callback, and
field actions remember their owning form for `submit()`.
- Without `count:` or a field predicate, `assert_has` and `refute_has` check
visibility; hidden or absent elements satisfy `refute_has`. Static/Live check
structural visibility, while Playwright checks rendered visibility. Counts and
field predicates add no visibility requirement, matching native assertions.
They retain one-based `at:` positions and familiar text and field predicates.
- `assert_path` remains a path assertion; use `query_params:` when the query
is part of the assertion.
The facade uses Fluffy's execution engine, so it is not an exact compatibility
implementation. Check these differences when migrating:
- **Strict matching:** single-target actions and native visibility assertions reject
ambiguous matches. Facade `assert_has` accepts any visible match; `refute_has`
requires no visible matches.
- **CSS links:** text matches take precedence over labels. Label matching is a
fallback only when no text match exists when the action starts.
- **Form behavior:** submissions use current DOM ownership and control state.
See [Form submission and keyboard actions](usage.md#form-submission-and-keyboard-actions)
and [Forms and files](capabilities.md#forms-and-files) for supported behavior.
- **JavaScript and timing:** choose Playwright for browser-owned behavior. The
[capability matrix](capabilities.md) is the reference for driver boundaries,
including LiveView debounce/throttle timing and the document boundary.
- **Native callbacks:** remove PhoenixTest continuation tuples such as
`{:ok, view, metadata}` from `unwrap` callbacks. Follow the return-value rules
in [Native escape hatch](usage.md#native-escape-hatch), then assert the outcome
with `assert_has` or `assert_path` in the facade pipeline.
- **Downloads:** there is no facade `assert_download/2`. Use the native
[event API](advanced-events.md#downloads) when testing downloads.
The `Fluffy.PhoenixTest` API reference is authoritative for supported overloads,
matching defaults, options, and rejected combinations. Unsupported options raise
`ArgumentError`.
### Prepared connections
`put_endpoint(conn, endpoint)` overrides Fluffy's configured endpoint for that
connection. For initial LiveView connect params, prepare the connection before
visiting:
```elixir
conn
|> Phoenix.LiveViewTest.put_connect_params(%{"timezone" => "Europe/Berlin"})
|> visit("/chambers/secrets")
```
The prepared connection applies to the first request only. Connect params do not carry
across later navigation, and `Fluffy.PhoenixTest.unwrap/2` runs too late to supply
initial mount params. Browser tests use the application's real LiveSocket parameters.
### Using native Fluffy APIs
Facade operations return a `Fluffy.PhoenixTest.Session` wrapper. Native Fluffy
operations accept its contained `session` field:
```elixir
facade = start_session(:playwright) |> visit("/potions")
title = Fluffy.Playwright.evaluate(facade.session, "document.title")
assert title == "Potions"
```
Prefer the native API throughout tests centered on browser scripting, pages,
or events. See [Events and pages](advanced-events.md) for those workflows.
## Optional: adopt the locator API
Use the native API when composable locators make a test clearer. Replace the
facade import with `import Fluffy`, `import Fluffy.Locator`, and
`use Fluffy.Assert`; see [Your first test](usage.md#your-first-test) for complete
setup. This is an optional rewrite, separate from switching to Fluffy.
Each call below is a pipeline step:
| Facade | Native locator API |
| --- | --- |
| `fill_in("Name", with: name)` | `fill(by_label("Name", exact: true), name)` |
| `select("Boomslang skin", from: "Ingredient")` | `select_option(by_label("Ingredient", exact: true), %{label: "Boomslang skin"})` |
| `check("Ready")` | `check(by_label("Ready", exact: true))` |
| `uncheck("Ready")` | `uncheck(by_label("Ready", exact: true))` |
| `click_button("Brew")` | `click(by_role(:button, name: "Brew"))` |
| `click_link("Potions")` | `click(by_role(:link, name: "Potions"))` |
| `refute_has("#notice")` | `refute(visible(by_css("#notice")))` |
| `assert_has("#notice", count: 0)` | `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))` with string keys and values |
| `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"))` |
When rewriting, preserve the original assertion's intent:
- Native `visible` uses the same visibility rules but rejects multiple matches;
facade `assert_has` accepts any visible match. For DOM presence/absence,
use counts as described in [Visibility and DOM presence](usage.md#visibility-and-dom-presence).
- Native text, role-name, and label locators default to substring matching;
use `exact: true` to retain exact field labels. Native `nth` is zero-based, whereas facade `at:` is
one-based. Use locator composition in place of facade `within`; see
[Locators](usage.md#locators).
- Native `page_title("Potions")` is an exact normalized match. Use a regex
for an intentional substring title assertion. Native URL strings match the
complete URL; use `page_url(path: ...)` to retain path-only intent. See
[Page assertions](usage.md#page-assertions).
- The native API does not track an active form. Select a form explicitly for
`submit`, or click the intended submitter. The facade continues to support
active-form tracking.