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 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, 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:
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
endKeep existing fixture and authentication setup. Fluffy adopts an existing
sandbox checkout; the required endpoint and sandbox configuration lives in
Installation and runtime. If your shared case
already calls Fluffy.Test.setup/1, do not call it again in individual modules.
Using native assertions alongside the facade
The facade's assert_has and refute_has accept selectors and options such as
text: and count:. The similarly named Fluffy.Assert helpers take native
locators and are visibility shortcuts; native count assertions remain explicit
with assert(session, count(locator, n)).
If using Fluffy.Assert alongside the facade, exclude its overlapping names:
use Fluffy.Assert
import Fluffy.Assert, except: [assert_has: 2, assert_has: 3, refute_has: 2, refute_has: 3]
import Fluffy.PhoenixTestNative assertions take the contained native session (session.session), while
facade assertions take the facade session.
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:
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
endThe 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 for guidance and 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.
withinscopes a callback, and field actions remember their owning form forsubmit(). - Without
count:or a field predicate,assert_hasandrefute_hascheck visibility; hidden or absent elements satisfyrefute_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-basedat:positions and familiar text and field predicates. assert_pathremains a path assertion; usequery_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_hasaccepts any visible match;refute_hasrequires 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 and Forms and files for supported behavior.
- JavaScript and timing: choose Playwright for browser-owned behavior. The capability matrix 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}fromunwrapcallbacks. Follow the return-value rules in Native escape hatch, then assert the outcome withassert_hasorassert_pathin the facade pipeline. - Downloads: there is no facade
assert_download/2. Use the native event API 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:
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:
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 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 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
visibleuses the same visibility rules but rejects multiple matches; facadeassert_hasaccepts any visible match. For DOM presence/absence, use counts as described in Visibility and DOM presence. - Native text, role-name, and label locators default to substring matching;
use
exact: trueto retain exact field labels. Nativenthis zero-based, whereas facadeat:is one-based. Use locator composition in place of facadewithin; see 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; usepage_url(path: ...)to retain path-only intent. See 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.