Execute PhoenixTest cases in an actual browser via Playwright.
defmodule Features.RegisterTest do
use PhoenixTest.Playwright.Case,
async: true, # async with Ecto sandbox
parameterize: [ # run in multiple browsers in parallel
%{browser_pool: :chromium},
%{browser_pool: :firefox}
]
@tag trace: :open # replay in interactive viewer
test "register", %{conn: conn} do
conn
|> visit(~p"/")
|> click_link("Register")
|> fill_in("Email", with: "f@ftes.de")
|> click_button("Create an account")
|> assert_has(".error", text: "required")
|> screenshot("error.png", full_page: true)
end
endPlease get in touch with feedback of any shape and size.
Enjoy! Freddy.
P.S. Looking for a standalone Playwright client? See PlaywrightEx.
-
Add dependency
# mix.exs {:phoenix_test_playwright, "~> 0.12", only: :test, runtime: false}
-
Install playwright and browser
npm --prefix assets i -D playwright npx --prefix assets playwright install chromium --with-deps
-
Config
# config/test.exs config :phoenix_test, otp_app: :your_app config :your_app, YourAppWeb.Endpoint, server: true
-
Runtime config
# test/test_helper.exs {:ok, _} = PhoenixTest.Playwright.Supervisor.start_link() Application.put_env(:phoenix_test, :base_url, YourAppWeb.Endpoint.url())
-
Use in test
defmodule MyTest do use PhoenixTest.Playwright.Case # `conn` isn't a `Plug.Conn` but a Playwright session. # We use the name `conn` anyway so you can easily switch `PhoenixTest` drivers. test "in browser", %{conn: conn} do conn |> visit(~p"/") |> evaluate("console.log('Hey')") end end
-
(Optional) Enable concurrent browser tests with
async: true: see Ecto Sandbox -
(Optional) LLM usage rules for AI coding agents (via usage_rules)
# mix.exs def project do [ ... usage_rules: usage_rules() ] end defp usage_rules do [ # Option A: inline into a rules file file: "AGENTS.md", # or "CLAUDE.md" usage_rules: [~r/^phoenix/], # Option B: generate as skill (can be used instead of or in addition to file) skills: [ location: ".agents/skills", # or ".claude/skills" deps: [:phoenix_test_playwright] ] ] end
Then run
mix usage_rules.sync.
Reference project
github.com/ftes/phoenix_test_playwright_example
The last commit adds a feature test for the
phx gen.authregistration page and runs it in CI (Github Actions).
# config/test.exs
config :phoenix_test,
otp_app: :your_app,
playwright: [
browser_pool: :chromium_pool,
browser_pools: [
[id: :chromium_pool, browser: :chromium],
[id: :firefox_pool, browser: :firefox]
],
js_logger: false,
browser_launch_timeout: 10_000
]See PhoenixTest.Playwright.Config for more details.
You can override some options in your test:
defmodule DebuggingFeatureTest do
use PhoenixTest.Playwright.Case,
async: true,
# Launch new browser for this test suite with custom options below
browser_pool: false,
# Show browser and pause 1 second between every interaction
headless: false,
slow_mo: :timer.seconds(1)
endConnect to a remote Playwright server via WebSocket instead of spawning a local Node.js driver. Useful for Alpine Linux containers (glibc issues) or containerized CI.
# mix.exs
{:websockex, "~> 0.4", only: :test}
# config/test.exs
config :phoenix_test, playwright: [ws_endpoint: "ws://localhost:3000", browser_pool: false]
# or, to enable via environment variable
config :phoenix_test, playwright: [ws_endpoint: System.get_env("PLAYWRIGHT_WS_ENDPOINT"), browser_pool: false]# Start Playwright server
docker run -p 3000:3000 --rm --init -it --workdir /home/pwuser --user pwuser mcr.microsoft.com/playwright:v1.58.0-noble /bin/sh -c "npx -y playwright@1.58.0 run-server --port 3000 --host 0.0.0.0"The browser type is automatically appended as a query parameter (e.g., ?browser=chromium).
Playwright traces record a full browser history, including 'user' interaction, browser console, network transfers etc. Traces can be explored in an interactive viewer for debugging purposes.
@tag trace: :open
test "record a trace and open it automatically in the viewer" do
# config/test.exs
config :phoenix_test, playwright: [trace: System.get_env("PW_TRACE", "false") in ~w(t true)]# .github/workflows/elixir.yml
run: "mix test || if [[ $? = 2 ]]; then PW_TRACE=true mix test --failed; else false; fi"Playwright traces support grouping labelled test steps and assigning them source code locations. This makes it easier to see what a test is doing and where. These groups are visible in the Playwright trace viewer.
test "user registration", %{conn: conn} do
conn
|> visit(~p"/")
|> step("Submit registration form", fn conn ->
conn
|> fill_in("Email", with: "user@example.com")
|> fill_in("Password", with: "secret")
|> click_button("Sign up")
end)
|> assert_has(".flash", text: "Welcome!")
end|> visit(~p"/")
|> screenshot("home.png") # captures entire page by default, not just viewport# config/test.exs
config :phoenix_test, playwright: [screenshot: System.get_env("PW_SCREENSHOT", "false") in ~w(t true)]# .github/workflows/elixir.yml
run: "mix test || if [[ $? = 2 ]]; then PW_SCREENSHOT=true mix test --failed; else false; fi"For username/password login, just visit the login page and fill in the credentials:
conn
|> visit(~p"/users/log_in")
|> fill_in("Email", with: "user@example.com")
|> fill_in("Password", with: "password123")
|> click_button("Sign in")For magic link / passwordless login, see the Emails section below.
If you want to verify the HTML of sent emails in your feature tests,
consider using Plug.Swoosh.MailboxPreview.
The iframe used to render the email HTML body makes this slightly tricky:
|> visit(~p"/dev/mailbox")
|> click_link("Confirmation instructions")
|> within("iframe >> internal:control=enter-frame", fn conn ->
conn
|> click_link("Confirm account")
|> click_button("Confirm my account")
|> assert_has("#flash-info", text: "User confirmed")
end)- Limit concurrency:
config :phoenix_test, playwright: [browser_pools: [[size: 1]]]ormix test --max-cases 1for GitHub CI shared runners - Increase timeout:
config :phoenix_test, playwright: [timeout: :timer.seconds(4)] - More compute power: e.g.
x64 8-coreGitHub runner
|> visit(~p"/")
|> assert_has("body .phx-connected")
# now continue, playwright has waited for LiveView to connect<div id="my-component" data-connected={connected?(@socket)}>|> visit(~p"/")
|> assert_has("#my-component[data-connected]")
# now continue, playwright has waited for LiveComponent to connectIf you've installed a browser but can't run tests
(Executable doesn't exist at .../ms-playwright/chromium_headless_shell-1208/),
you probably used the wrong playwright JS version to install the browser.
Each playwright JS version pins a specific browser version.
Tests are run using ./assets/node_modules/playwright
(see assets_dir in PhoenixTest.Playwright.Config).
Make sure to use that same playwright JS version to install the browser,
e.g. via npx --prefix assets playwright install.
Make sure you have followed the advanced set up instructions for Phoenix.Ecto.SQL.Sandbox
- with LiveViews
- with Channels
- with Ash authentication: use
on_mount_prepend
PhoenixTest.Playwright.Case takes care of the rest. It starts the
sandbox under a separate process than your test and uses
ExUnit.Callbacks.on_exit/1 to ensure the sandbox is shut down afterward. It
also sends a User-Agent header with the
Phoenix.Ecto.SQL.Sandbox metadata for your Ecto repos. This allows
the sandbox to be shared with the LiveView and other processes which need to
use the database inside the same transaction as the test. It also allows for
concurrent browser tests.
defmodule MyTest do
use PhoenixTest.Playwright.Case, async: true
endUnlike Phoenix.LiveViewTest, which controls the lifecycle of LiveView
processes being tested, Playwright tests may end while such processes are
still using the sandbox.
In that case, you may encounter ownership errors like:
** (DBConnection.OwnershipError) cannot find owner for ...
To prevent this, the ecto_sandbox_stop_owner_delay option allows you to delay the
sandbox owner's shutdown, giving LiveViews and other processes time to close
their database connections. The delay happens during
ExUnit.Callbacks.on_exit/1, which blocks the running of the next test, so
it affects test runtime as if it were a Process.sleep/1 at the end of your
test.
So you probably want to use as small a delay as you can, and only for the
tests that need it, using @tag (or @describetag or @moduletag) like:
@tag ecto_sandbox_stop_owner_delay: 100 # 100ms
test "does something" do
# ...
endIf you want to set a global default, you can:
# config/test.exs
config :phoenix_test, playwright: [
ecto_sandbox_stop_owner_delay: 50 # 50ms
]This library adds functions beyond the standard PhoenixTest API (e.g. screenshot/3, evaluate/2, click_link/4),
but it does not wrap the entire Playwright API.
You can add any missing functionality yourself using unwrap/2 with
PlaywrightEx modules (Frame, Selector, Page, BrowserContext),
and the Playwright source.
If you think others might benefit, please open a PR.
Here is some inspiration:
def choose_styled_radio_with_hidden_input_button(conn, label, opts \\ []) do
opts = Keyword.validate!(opts, exact: true)
PhoenixTest.Playwright.click(conn, PlaywrightEx.Selector.text(label, opts))
end
def assert_a11y(conn) do
PlaywrightEx.Frame.evaluate(conn.frame_id, expression: A11yAudit.JS.axe_core(), timeout: timeout())
{:ok, json} = PlaywrightEx.Frame.evaluate(conn.frame_id, expression: "axe.run()", timeout: timeout())
results = A11yAudit.Results.from_json(json)
A11yAudit.Assertions.assert_no_violations(results)
conn
end
def within_iframe(conn, selector \\ "iframe", fun) when is_function(fun, 1) do
within(conn, "#{selector} >> internal:control=enter-frame", fun)
end
# |> assert_download("Wonderwall.pdf", &click_button(&1, "Download PDF"))
def assert_download(conn, filename, fun) do
test_pid = self()
conn
|> unwrap(fn %{page_id: page_id} ->
spawn_link(fn ->
PlaywrightEx.subscribe(page_id)
receive do
{:playwright_msg, %{method: :download, params: params}} ->
send(test_pid, {:download, params.suggested_filename})
end
end)
end)
|> fun.()
|> unwrap(fn _ ->
assert_receive {:download, ^filename}, 500
end)
endTo run the tests locally, you'll need to:
- Check out the repo
- Run
mix setup. This will take care of setting up your dependencies, installing the JavaScript dependencies (including Playwright), and compiling the assets. - Run
mix testor, for a more thorough check that matches what we test in CI, runmix check - Run
mix test.websocketto run all tests against a 'remote' playwright server via websocket. Docker needs to be installed. A container is started viatestcontainers.
- Follows PhoenixTest API. Only add new public functions when strictly necessary for browser-specific interaction (e.g., screenshots, JS evaluation).
- Do not edit upstream tests. Files under
test/phoenix_test/upstream/are mirrored from phoenix_test and must not be modified. Playwright-specific tests go intest/phoenix_test/playwright_test.exsor other files outsideupstream/.
Playwright's implementation is split between a client (Node.js API) and a server (browser protocol layer). The Playwright docs describe the public API but don't reflect this split. When reading Playwright source code, it can help to look at the TypeScript sources directly: client and server (locally under priv/static/assets/node_modules/playwright-core/lib/).