Start each test with the Phoenix (:phoenix) or Playwright (:playwright)
backend. The Phoenix backend automatically selects the Static
(Phoenix.ConnTest) or LiveView (Phoenix.LiveViewTest) driver for each page
and can transition between them as the user navigates. Use Playwright when the
behavior depends on JavaScript or other browser-owned capabilities.
Your first test
Import the actions and locators; use Fluffy.Assert imports the assertion vocabulary:
defmodule MyAppWeb.CreatureTest do
use ExUnit.Case, async: true
use Fluffy.Assert
import Fluffy
import Fluffy.Locator
setup context do
Fluffy.Test.setup(context)
end
test "puts Fluffy to sleep" do
start_session(:phoenix)
|> visit("/creatures/fluffy")
|> fill(by_label("Keeper"), "Rubeus Hagrid")
|> click(by_role(:button, name: "Play flute"))
|> assert(visible(by_text("Asleep")))
|> assert(page_url("/creatures/fluffy"))
end
endFor tests using browser-only helpers, also import Fluffy.Playwright. This
lets you call new_page, current_page, pages, switch_page, close_page,
go_back, go_forward, hover, drag_to, and
press_sequentially without module qualifiers:
import Fluffy
import Fluffy.Locator
import Fluffy.Playwright
start_session(:playwright)
|> visit("/creatures/fluffy")
|> press_sequentially(by_label("Keeper"), "Hagrid", delay: 20)Actions and assertions return a Fluffy.Session handle for pipelines. Evolving
state belongs to the session runtime: existing handles see action results even
when the returned handle is discarded. Use each session sequentially from its
owning test process; concurrent use of the same session is unsupported.
For page selection and handle lifetime, see Pages, popups, and frames.
The examples describe an illustrative Hogwarts application; routes, controls,
and event handlers must exist in the application under test. Later snippets
assume the same imports and an initialized session. Upload examples also
require the named fixture files.
Complete Installation and runtime before running these examples. That guide owns endpoint, browser, and lifecycle/sandbox configuration. For PhoenixTest-style helpers, follow Coming from PhoenixTest.
Choosing a backend
Use :phoenix for the common case: rendered controller pages, forms,
LiveViews, links, redirects, cookies, and LiveView events. Its first visit
selects the Static or LiveView driver. Later document navigations select the
driver for the new page again without changing the public session.
For LiveView event scheduling and keyboard limitations, see LiveView timing and keyboard events.
Use :playwright when the behavior depends on JavaScript, browser layout,
native-validation events or blocking, dialogs, request/response events, or
another capability shown as unavailable on Phoenix. Choose the backend when
starting the session:
test "updates the preview rendered by a JavaScript hook" do
start_session(:playwright)
|> visit("/creatures/new")
|> fill(by_label("Name"), "Basilisk")
|> assert(visible(by_text("Preview: Basilisk")))
endSee Pages, popups, and frames for page selection and user isolation.
See the capability matrix for backend differences.
Shared test case
Put shared imports and session setup in an application-owned FluffyCase.
Keep your ordinary ConnCase for fixtures, routes, and sandbox setup, then
start the Fluffy session after it:
# test/support/fluffy_case.ex
defmodule MyAppWeb.FluffyCase do
use ExUnit.CaseTemplate
using do
quote do
use MyAppWeb.ConnCase
use Fluffy.Assert
import Fluffy
import Fluffy.Locator
alias Fluffy.Event
setup context do
:ok = Fluffy.Test.setup(context)
%{session: start_session(Map.get(context, :backend, :phoenix))}
end
end
end
endTests receive session in their context and default to Phoenix. Use
@moduletag backend: :playwright for an entire module, @describetag for a
describe block, or @tag for one test—without a separate browser test module:
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
endLocators
Prefer locators that describe the interface as a user experiences it:
- Use a label for a form control.
- Use a role and accessible name for an interactive element.
- Use text for rendered content, with a visibility assertion when needed.
- Use a test ID when user-facing names are ambiguous.
- Use CSS for structure that has no user-facing identity.
session
|> fill(by_label("Name"), "Basilisk")
|> click(by_role(:button, name: "Register"))
|> assert(visible(by_text("Creature registered")))Locators are values, so they can be scoped and reused:
potions = by_css("#potions")
potion = by_role(potions, :row, name: "Polyjuice Potion")
session
|> click(by_role(potion, :button, name: "Inspect"))Available constructors are by_role, by_text, by_label,
by_placeholder, by_alt_text, by_title, by_test_id, and by_css.
Text, role-name, and label matching default to normalized substring matching;
use exact: true for a complete match. Role names follow accessible-name
precedence: aria-labelledby, then aria-label, then native labels. Explicit
ARIA roles override implicit roles. CSS must be valid browser CSS; escape
special characters in IDs or use a semantic/test-id locator.
Refine a locator with filter, first, last, or zero-based nth.
Use and_ to require both locators to match the same element:
email = by_css("#email") |> and_(by_label("Email", exact: true))The right locator resolves from the original query root, not inside the left
matches. Inside a has or has_not filter, it resolves from that filter's
candidate. Intersections preserve the left locator's order and can be chained.
For frames, the right locator can be relative to the left locator's frame or
use the same frame prefix. Playwright validates frame compatibility when the
locator resolves; intersections across different frames are unsupported.
CSS :checked matches current checkbox, radio, and option state, including
changes made by check, uncheck, and select_option. For example,
by_label("Country") |> by_css("option:checked") locates the current selection.
Attribute selectors such as [checked] and [selected] still match the
rendered HTML attributes, which can differ from the user's input.
The Phoenix backend tracks current input properties but does not model focus.
On changed LiveView renders, local text adopts the server-rendered value except
for phx-trigger-action handoff. Unchanged renders retain local values; checkbox,
select, and file state follow the rules described in
Focus and client state.
Use Fluffy.Playwright.focus/3, Fluffy.Playwright.blur/3, and focus assertions
with browser sessions. With import Fluffy.Playwright, call focus and blur
directly. Use Playwright when a test depends on focus-sensitive LiveView patches
or blur/debounce timing.
Single-target actions and native visibility assertions are strict: if a locator
matches more than one element, Fluffy reports the ambiguity instead of choosing
for you. Facade assert_has accepts any visible match. Narrow the
locator, or use assert(count(locator, n)) when multiple matches are
the intended assertion.
Frames (Playwright)
Use a frame locator to query elements inside an iframe. Frame locators are lazy; Playwright resolves the frame and target when an action or assertion runs.
checkout = frame_locator("#checkout")
email = by_label(checkout, "Email")
session
|> fill(email, "buyer@example.com")
|> assert(value(email, "buyer@example.com"))
|> click(by_role(checkout, :button, name: "Pay"))Each frame boundary is strict: if multiple iframes match, an action or assertion inside
that frame fails with a strictness error. Narrow the iframe locator, or select a
particular match with Fluffy.Locator.first/1 or Fluffy.Locator.nth/2 before
converting it with Fluffy.Locator.content_frame/1:
checkout = by_title("Checkout") |> first() |> content_frame()
payment = frame_locator(checkout, "#payment")
pay_button = by_role(payment, :button, name: "Pay")
iframe_element = Fluffy.FrameLocator.owner(checkout)The by_* builders return ordinary element locators that support existing actions,
filters, and assertions. Fluffy.FrameLocator.owner/1 returns the iframe element in its
containing document. As in Playwright JS, has and has_not filter operands must
remain in the same frame and cannot themselves traverse frames.
A frame locator never changes the active page. Page URL/title assertions and
navigation helpers continue to target the page's main frame. Child navigation
leaves that page state unchanged; navigation targeting _top updates it normally.
The Phoenix backend does not flatten frames into one document. Static operates
on the response HTML; LiveView operates on the current rendered LiveView. An
<iframe> in that markup is an element whose attributes can be queried, but
neither driver loads its src nor turns its srcdoc into a child document.
Ordinary locators therefore cannot find elements inside it. Frame traversal is
not supported by Static and LiveView and raises Fluffy.CapabilityError when
an action or assertion executes the query; frame boundaries are never silently
ignored.
To test the embedded route on its own with Phoenix, visit it directly. Use Playwright to test it as an embedded document, including interactions between the parent page and iframe. See Playwright's FrameLocator API for the underlying browser behavior.
Actions and expectations
Actions and expectations use the same API on both backends:
session
|> visit("/creatures/fluffy")
|> fill(by_label("Keeper"), "Rubeus Hagrid")
|> check(by_label("Flute ready"))
|> select_option(by_label("Status"), %{label: "Asleep"})
|> click(by_role(:button, name: "Save creature"))
|> assert(enabled(by_role(:button, name: "Save creature")))
|> assert(disabled(by_label("Species")))
|> assert(value(by_label("Keeper"), "Rubeus Hagrid"))
|> assert(editable(by_label("Keeper")))
|> assert(checked(by_label("Flute ready")))
|> assert(visible(by_text("Creature saved")))fill accepts any value implementing String.Chars, such as numbers, dates,
and custom structs. select_option also converts option values and labels,
including those in %{value: value} and %{label: label} requests. Conversion
uses to_string/1 without HTML escaping; %{index: index} remains numeric.
Lists in select_option represent multiple options. To select a single
charlist value, use %{value: charlist} (or %{label: charlist} for its label).
Use checked(locator, checked: false) for an unchecked control. The
browser-owned indeterminate state is Playwright-only:
session
|> assert(checked(by_label("All ingredients"), indeterminate: true))Static and LiveView raise Fluffy.CapabilityError for indeterminate state.
Actionability checks and waiting
LiveView expectations observe fresh renders and retry until they pass or reach
their deadline. Supported LiveView actions also retry transient missing or
actionability failures, as listed below. Waiting is enabled by default; pass
timeout: to an action or expectation to change its deadline. Static checks
are immediate. Playwright uses the browser's native waiting behavior.
When a Playwright visit finds LiveView roots, it waits for every root, including nested views, to connect and finish joining. Application-specific loading may still need an assertion.
Playwright already checks each action's prerequisites and waits automatically for the target to be ready. As elsewhere in Fluffy, Playwright is the model we follow, with the Phoenix-specific limits described below. Usually you can act directly and assert the result, without first asserting that the target is ready:
session
|> fill(by_label("Potion name"), "Polyjuice Potion")
|> click(by_role(:button, name: "Save potion"))
|> assert(page_url(path: "/potions"))The Phoenix backend checks HTML structure and control state. Each action requires exactly one matching target, with these additional checks:
| Action | Phoenix checks | LiveView also retries while… |
|---|---|---|
click | enabled; no hidden attribute on the target | the target is disabled or hidden |
fill | enabled, supported editable control, not readonly | the target is disabled or readonly |
check / uncheck | checkbox/radio type; enabled when changing state | the target is disabled and needs changing |
select_option | enabled select; requested options exist and are enabled | the select is disabled or a requested option is missing |
set_input_files | file input; multiple files require multiple | checks immediately, including target lookup |
For click, fill, check / uncheck, and select_option, LiveView also
retries missing targets until the action deadline. Ambiguous locators and invalid control types fail immediately. Static checks immediately
without waiting for later changes. A checkbox already in the requested state
needs no mutation; unchecking a checked radio is invalid.
These are action-specific rules, not a blanket visibility requirement. Phoenix
fill does not check visibility, and set_input_files intentionally accepts
hidden or disabled file inputs. Locator matching may itself exclude hidden
elements; see Visibility and DOM presence.
Phoenix does not compute CSS layout or detect overlapping elements.
Playwright uses the browser's action-specific checks: for example, fill
waits for rendered visibility, enablement, and editability, while click
also waits for stability and the target to receive pointer events.
Keep separate assertions when the state itself is the behavior under test, such as a
button becoming enabled after a required field is filled. assert(enabled(locator))
does not imply visibility, and neither does refute(disabled(locator)); the latter is
the same negation as assert(not_(disabled(locator))) with Fluffy.Expect.not_/1
imported from Fluffy.Expect.
Form submission and keyboard actions
Use submit(form_locator) when the intent is native form submission without a
specific submitter. Click the intended submit button when its name=value or
override attributes matter. press(locator, "Enter") performs implicit form submission only with Playwright.
Submissions use the latest DOM ownership and control state in document order.
Removed or disabled controls are omitted; renamed and newly inserted controls
use their current names and owners. Duplicate names preserve their order,
hidden inputs remain independent entries, and readonly controls submit even
though they cannot be filled. Multiple selections replace the selected set.
See Forms and files for validation and
serialization boundaries.
Native reset-button behavior requires Playwright. Static and LiveView clicks do
not restore form defaults or clear file selections. LiveView reset buttons with
phx-click still dispatch their server event.
Fluffy.press/4 uses native key behavior with Playwright. With LiveView, it
forwards the supplied key unchanged to LiveViewTest keydown/keyup handlers on
the selected element. It does not simulate focus, editing, checkbox activation,
Enter submission, or phx-key filtering. Supply the desired event key yourself;
modifier strings are not parsed. Static does not support press.
# LiveView: invoke bound handlers with key = "Backspace"; do not delete text.
session |> press(by_label("Search"), "Backspace")
# Playwright: select the text, then delete it through native keyboard behavior.
session
|> press(by_label("Search"), "ControlOrMeta+A")
|> press(by_label("Search"), "Backspace")For character-by-character browser typing, use Fluffy.Playwright.press_sequentially/4.
See LiveView timing and keyboard events.
Assertions
See Assertion styles for native assertion imports, constructor names, options, and negation semantics.
Visibility and DOM presence
Even the :phoenix backend checks structural visibility, rather than just
DOM presence. Its Static and LiveView drivers treat a matched element with a
hidden attribute or aria-hidden="true" as invisible.
Without count: or a field predicate, the PhoenixTest-style facade's
assert_has requires any visible match and refute_has requires no visible
matches. Native visibility assertions require an unambiguous locator.
Counts and field predicates (value:, checked:, selected:) add no visibility
requirement. Use assert_has(selector, count: 1) for DOM presence, including
hidden inputs or script elements, and selected: on a select to check its
selected option.
Phoenix does not compute CSS or browser layout: a CSS class or inline
display: none alone does not make an element structurally invisible. Use
:playwright to test rendered visibility. Its browser visibility rules also
differ from structural checks: aria-hidden="true" alone does not visually
hide an element.
Choose the assertion that expresses the intended behavior:
# Require a visible element.
session |> assert(visible(by_css("#notice")))
# Allow the element to be absent or invisible.
session |> refute(visible(by_css("#notice")))
# Require DOM absence, including hidden elements.
session |> assert(count(by_css("#notice"), 0))To require an element to remain in the DOM but be invisible, assert
count(by_css("#notice"), 1) before refuting its visibility. Use a CSS locator
for these presence checks; role locators can exclude structurally hidden
elements before the assertion runs.
Page assertions
Page assertion constructors use a page_ prefix and target
the active page:
session
|> assert(page_title("Chamber of Secrets"))
|> assert(page_title(~r/^Chamber/))
|> refute(page_title("Chamber sealed"))Title assertions normalize whitespace. LiveView and Playwright retry until
the title matches; Static checks immediately. LiveView also observes later
@page_title updates.
URL assertions use the same page_ prefix. Strings and regular expressions
match the complete canonical URL. Use the structured form when query
serialization order is not part of the contract:
session
|> assert(page_url(path: "/potions"))
|> assert(
page_url(
path: "/potions",
query: %{"state" => "brewing", "ingredient" => ["lacewing", "boomslang"]},
query_mode: :subset
)
)Exact URL strings include the query and fragment; relative strings resolve
against the session base URL. Structured matching can select :path, :query,
and :fragment; omitted components are ignored. A function receives a %URI{}:
session |> assert(page_url(fn uri -> uri.path == "/potions" and uri.fragment == "ready" end))Predicates work with both backends and with negated assertions. Keep them quick and nonblocking.
Structured queries decode like URLSearchParams: distinct parameter-name
order is ignored, and repeated values retain their order and duplicates.
Exact query mode rejects unrelated names; query_mode: :subset allows them
while requiring all values for each requested name. Keys and values are strings;
bracketed names remain literal, rather than becoming nested Plug data. Bare
parameters and empty values both decode to ""; + and %20 decode to a space.
Reloading
Use Fluffy.reload/1 to reload the active document:
session
|> visit("/chambers/secrets")
|> assert(visible(by_role(:heading, name: "Chamber of Secrets")))
|> reload()
|> assert(visible(by_role(:heading, name: "Chamber of Secrets")))
|> assert(page_url("/chambers/secrets"))The Phoenix backend dispatches the current URL again and selects the driver for the
returned document. The Playwright backend uses the page's native reload.
Fluffy.reload/1 promises a fresh document, not preservation of unsaved client-side
state.
Files and uploads
The same public action covers ordinary multipart forms and the supported managed LiveView upload lifecycle:
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. Use a
typed in-memory payload for generated bytes or a remote browser:
payload = %Fluffy.FilePayload{
name: "potion-ledger.csv",
bytes: "potion,vials\nPolyjuice Potion,3\n",
content_type: "text/csv"
}
session
|> set_input_files(by_label("Report"), payload)
|> click(by_role(:button, name: "Upload"))One path or payload, a homogeneous list of either, and [] are accepted.
Fluffy validates and snapshots the complete selection before changing the
page. See Upload limits for the aggregate
limit and per-action override. Error messages and diagnostic artifacts do not
copy payload contents.
Playwright can also capture a script-opened chooser before the triggering click. Pass
the chooser directly to Fluffy.set_input_files/3; see Advanced events and
pages.
Operation failures and browser diagnostics
Element-action failures raise Fluffy.OperationError across drivers. Rescue it
uniformly and inspect backend, driver, operation, locator (or file-chooser
handle), and cause for details. This includes submit and file selection.
For Playwright, cause preserves the original adapter error, including native
error names and call logs. For Phoenix, it retains Fluffy.StrictnessError or
Fluffy.ActionabilityError, including structural reasons and candidates. LiveView
finishes its action retries before wrapping the final failure. Browser failures
are not classified by parsing messages or inspecting a later DOM snapshot.
Existing code rescuing structural errors from public actions should rescue
Fluffy.OperationError and inspect its cause instead. Invalid arguments,
unsupported capabilities, and unexpected programming errors remain distinct.
Failed assertions remain ExUnit.AssertionError. Browser assertion messages
include the expected condition and Playwright's received values, timeout details,
and call log. No candidate markup is reconstructed after browser failure.
Start a context-wide trace explicitly when debugging a Playwright scenario:
alias Fluffy.Playwright
start_session(:playwright)
|> Playwright.trace(open: false)
|> visit("/potions/polyjuice")
|> step("Brew Polyjuice Potion", fn session ->
session
|> fill(by_label("Boomslang skin"), "3")
|> click(by_role(:button, name: "Brew"))
end)Fluffy saves the trace before closing its BrowserContext. A trace covers all pages in
the session; different sessions produce different archives. Explicit tracing opens Trace
Viewer by default for local debugging, while open: false is appropriate in CI.
Fluffy.step/3 remains portable: it adds nested, source-linked trace groups when
tracing is active and simply runs the callback on Phoenix or an untraced Playwright
session.
Save an explicit PNG while retaining the pipeline with:
session
|> Playwright.screenshot("tmp/screenshots/polyjuice.png", full_page: true)
|> assert(visible(by_text("Potion ready")))See Playwright setup for console logging, failure artifacts, and their configuration. See Events and pages for downloads, popups, dialogs, and network events.
Browser-only evaluation
Use Fluffy.Playwright.evaluate/2 when a Playwright test needs a value from
the active page:
alias Fluffy.Playwright
session
|> then(fn session ->
data_url = Playwright.evaluate(session, "document.querySelector('canvas').toDataURL()")
assert data_url =~ "data:image/png"
session
end)
|> click(by_role(:button, name: "Reveal diary message"))Fluffy.Playwright.evaluate/2 returns the JavaScript result, not the session. Use
then/2, as above, to continue a pipeline. Function-style expressions can pass
is_function: true and arg:. Phoenix sessions raise a capability error because
Phoenix does not execute client code.
Native escape hatch
Use Fluffy.unwrap/2 for an uncommon driver-native operation that has no first-class
Fluffy API. It returns the reconciled session, so the pipeline can continue, but the
callback value is intentionally driver-specific:
session
|> unwrap(fn %Phoenix.LiveViewTest.View{} = view ->
Phoenix.LiveViewTest.render_hook(view, "open-chamber", %{"phrase" => "open"})
end)
|> assert(visible(by_text("Chamber opened")))The callback receives the current %Plug.Conn{} on a Static page, a
%Phoenix.LiveViewTest.View{} on a LiveView page, or a
%Fluffy.Playwright.Handle{} on a Playwright page.
A Static callback must return its updated Plug.Conn because connections are
immutable. LiveView and Playwright callback results are ignored: Fluffy retains
and reconciles the original View or Handle. Exceptions, throws, and exits pass
through unchanged. Match PlaywrightEx {:error, reason} results inside the
callback when failure should stop the test.
Do not consume assert_patch or assert_redirect notifications inside a LiveView
callback: Fluffy uses them to reconcile navigation. Assert the resulting page or URL
after Fluffy.unwrap/2.
The Playwright handle exposes only context_id, page_id, frame_id,
connection, and timeout. Use the event and page APIs
for lifecycle operations and registrations that must precede an action.