Fluffy
Phoenix feature testing — 3 drivers, 1 API: Static, LiveView, Playwright.
Write blazing-fast Phoenix feature tests with one unified, Playwright-shaped API. Composable locators and strict actions keep driver-specific escape hatches rare. Keep the same readable pipeline across controller-rendered pages, LiveView, and a real browser.
This is what it looks like
alias Fluffy.Page
start_session(:phoenix)
|> visit("/creatures")
|> fill(by_label("Name"), "Basilisk")
|> click(by_role(:button, name: "Register"))
|> expect(visible(by_role(:heading, name: "Basilisk")))
|> expect(Page.to_have_url("/creatures/basilisk"))
Fluffy has two backends: Phoenix (:phoenix) and Playwright (:playwright).
The Phoenix backend stays in-process and selects the Static
(Phoenix.ConnTest) or LiveView (Phoenix.LiveViewTest) driver for each page.
The same session can move between them as navigation changes pages.
The Playwright backend runs the Playwright driver in a real browser, where it can verify JavaScript hooks, layout, and browser behavior:
start_session(:playwright)
|> visit("/creatures/fluffy")
|> click(by_role(:button, name: "Play flute"))
|> expect(visible(by_text("Fluffy is asleep")))
An extensive browser-backed conformance suite verifies that the Phoenix backend matches Playwright within the documented capability boundaries.
Fluffy uses Playwright's locator, strictness, assertion, and retry model as its baseline:
- Locators are semantic and composable.
- Single-target actions are strict.
- LiveView and Playwright operations retry within their documented boundaries; Static operations are immediate.
Who is this for?
PhoenixTest users who want Playwright semantics, not just browser access.
PhoenixTest can already add browser coverage through phoenix_test_playwright.
Fluffy instead shapes its shared API around Playwright: composable locators,
strict actions, and portable expectations mean fewer trips through native
unwrap/2 escape hatches. An extensive browser-backed conformance corpus keeps
the Static and LiveView drivers aligned within the documented capability
boundaries.
Phoenix teams assembling their own feature-test stack. Fluffy replaces the
usual mix of ConnCase, Phoenix.LiveViewTest, and browser helpers with one
test vocabulary across controller pages, LiveView, and Playwright.
Teams that want fast tests without invented browser behavior. The Static and LiveView drivers implement a documented, browser-validated subset of locator matching, actionability, forms, navigation, and events. Use Playwright for the remaining browser-owned behavior instead of running every feature test there.
Getting started
Add the dependency:
# mix.exs
defp deps do
[
{:fluffy, "~> 0.1", only: :test}
]
end
Configure the endpoint:
# config/test.exs
config :fluffy, endpoint: MyAppWeb.Endpoint
Every Fluffy test establishes a lifecycle scope and imports the shared API:
use ExUnit.Case, async: true
import Fluffy
import Fluffy.Expect
import Fluffy.Locator
alias Fluffy.Page
setup context do
Fluffy.Test.setup(context)
end
Start with start_session(:phoenix). It runs entirely in Elixir and starts no
Node process or browser. To use the Playwright backend, install Playwright in
the application and configure its CLI path:
pnpm add --save-dev playwright@1.63.0
pnpm exec playwright install chromium
# config/test.exs
config :my_app, MyAppWeb.Endpoint, server: true
config :fluffy,
playwright: [
enabled: true,
executable: Path.expand("../node_modules/playwright/cli.js", __DIR__),
engine: :chromium,
launch_options: [headless: true]
]
Then change only the backend:
start_session(:playwright)
Applications using Ecto need one additional sandbox bridge so browser and LiveView processes share the test checkout. See Installation and runtime for the complete setup, options, failure artifacts, and alternate browser engines.
Migrating from PhoenixTest
Fluffy is a close relative, not a compatibility wrapper. The broad shape is familiar—visit a page, interact, assert, keep piping—but the vocabulary follows Playwright's locator model:
| PhoenixTest | Fluffy |
|---|---|
conn |> visit("/creatures") |
start_session(:phoenix) |> visit("/creatures") |
click_link("Creatures") |
click(by_role(:link, name: "Creatures")) |
click_button("Register") |
click(by_role(:button, name: "Register")) |
fill_in("Name", with: "Basilisk") |
fill(by_label("Name"), "Basilisk") |
assert_has(".creature", "Basilisk") |
expect(visible(by_text("Basilisk"))) |
within("#potions", fn session -> ... end) |
compose a child locator under by_css("#potions") |
submit() |
submit(form_locator) or click the intended submit button |
reload_page() |
reload() |
at: 1 |
nth(locator, 0) |
The changes buy a few useful things out of the box:
- One API includes the browser. Keep fast Static and LiveView tests, then use the same pipeline for JavaScript, dialogs, layout, and network behavior.
- Playwright-style locators everywhere. Prefer roles, accessible names, labels, and text; compose locators as values; get a strictness error instead of an arbitrary first match.
- Waiting belongs to the library. The LiveView and Playwright drivers react to async renders and automatically await LiveView connection.
See Migrating from PhoenixTest.
Advanced features: two backends, three drivers
Select the Phoenix or Playwright backend when starting a session. The Phoenix backend chooses the Static or LiveView driver for each page; the Playwright backend uses the Playwright driver.
| Feature | Phoenix | Playwright |
|---|---|---|
| Semantic locators, actions, and assertions | supported structural model | native browser behavior |
| Controller navigation, forms, redirects, and cookies | supported across Static and LiveView documents | supported |
| LiveView events, patches, navigation, and retry | supported | supported through the browser |
data-method / data-to actions |
stock Phoenix.HTML structural model | native Phoenix.HTML JavaScript |
Dialogs and data-confirm |
action continues; no dialog is invented | capture, accept, or dismiss |
| Downloads | declarative downloads | supported |
| File selection and uploads | typed paths/payloads, multipart forms, and the supported managed LiveView lifecycle | native typed selection and scripted chooser capture |
| Multiple tabs/pages | - | supported, including script-created pages |
| Multiple isolated user sessions | supported | one isolated BrowserContext per session |
| Request/response events | - | supported |
| Arbitrary application JavaScript | - | supported |
Advanced events use listener-before-action orchestration, so Fluffy starts listening before the click that causes them:
alias Fluffy.Event
session
|> wait_for(Event.download(:report), fn session ->
click(session, by_role(:button, name: "Download potion ledger"))
end)
|> expect(download_suggested_filename(:report, "potions.csv"))
See Advanced events and pages for downloads, dialogs, navigation, tabs, and network events. The full, versioned truth table lives in the Capability matrix. When in doubt, consult the matrix.
Guides
- Installation and runtime — endpoint, browser, and Ecto sandbox setup
- Usage — locators, actions, expectations, backend choice, and diagnostics
- Advanced events and pages — downloads, tabs, navigation, dialogs, and network events
- Capability matrix — the precise backend and driver boundaries
- Migrating from PhoenixTest — practical conversion guidance
Developing Fluffy
The project pins the current local-development toolchain in .tool-versions.
Fluffy's compatibility floor remains Elixir 1.18 and Node.js 20; development
also requires pnpm 11.19.0 and PostgreSQL. From a clean checkout:
mix setup
mix test
Run the ordinary local gate—Styler-backed formatting, warnings-as-errors compilation, strict Credo, and the test suite—with:
mix check
Add test-environment Dialyzer analysis with:
mix quality
License
Fluffy is released under the MIT License.