A friendly three-headed Fluffy asleep beside an Elixir wizard playing a flute, with the heads labeled Live, Static, and Playwright

Phoenix feature testing — 3 drivers, 1 API: Static, LiveView, Playwright.

Hex.pm HexDocs CI License

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:

PhoenixTestFluffy
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: 1nth(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.

FeaturePhoenixPlaywright
Semantic locators, actions, and assertionssupported structural modelnative browser behavior
Controller navigation, forms, redirects, and cookiessupported across Static and LiveView documentssupported
LiveView events, patches, navigation, and retrysupportedsupported through the browser
data-method / data-to actionsstock Phoenix.HTML structural modelnative Phoenix.HTML JavaScript
Dialogs and data-confirmaction continues; no dialog is inventedcapture, accept, or dismiss
Downloadsdeclarative downloadssupported
File selection and uploadstyped paths/payloads, multipart forms, and the supported managed LiveView lifecyclenative typed selection and scripted chooser capture
Multiple tabs/pages-supported, including script-created pages
Multiple isolated user sessionssupportedone 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

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.