PhoenixTest.Playwright (PhoenixTestPlaywright v0.10.0-rc.0)
View SourceRun feature tests in an actual browser, using PhoenixTest and 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.
Getting started
Add dependency
# mix.exs {:phoenix_test_playwright, "~> 0.9", only: :test, runtime: false}Install playwright and browser
npm --prefix assets i -D playwright npm --prefix assets exec -- playwright install chromium --with-depsConfig
# config/test.exs config :phoenix_test, otp_app: :your_app config :your_app, YourAppWeb.Endpoint, server: trueRuntime config
# test/test_helpers.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, async: true # `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"/") |> unwrap(&Frame.evaluate(&1.frame_id, "console.log('Hey')"))
Reference project
github.com/ftes/phoenix_test_playwright_example
The last commit adds a feature test for the phx gen.auth registration page
and runs it in CI (Github Actions).
Configuration
# config/test.ex
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: :nil,
# Show browser and pause 1 second between every interaction
headless: false,
slow_mo: :timer.seconds(1)Traces
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.
Manually
@tag trace: :open
test "record a trace and open it automatically in the viewer" doAutomatically for failed tests in CI
# 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"Screenshots
Manually
|> visit(~p"/")
|> screenshot("home.png") # captures entire page by default, not just viewportAutomatically for failed tests in CI
# 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"Emails
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")For a full example see ftes/phoenix_test_playwright_example/tree/phoenix-1.8.
Common problems
Test failure in CI (timeout)
- Limit concurrency:
config :phoenix_test, playwright: [browser_pools: [[size: 1]]]ormix test --max-cases 1for GitHub CI shared runners - Increase timemout:
config :phoenix_test, playwright: [timeout: :timer.seconds(4)] - More compute power: e.g.
x64 8-coreGitHub runner
LiveView not connected
|> visit(~p"/")
|> assert_has("body .phx-connected")
# now continue, playwright has waited for LiveView to connectLiveComponent not connected
<div id="my-component" data-connected={connected?(@socket)}`>|> visit(~p"/")
|> assert_has("#my-component[data-connected]")
# now continue, playwright has waited for LiveComponent to connectEcto SQL.Sandbox
defmodule MyTest do
use PhoenixTest.Playwright.Case, async: truePhoenixTest.Playwright.Case automatically takes care of this. 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.html.metadata_for/3 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.
Ownership errors with LiveViews
Unlike 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
]For more details on LiveView and Ecto integration, see the advanced set up instructions:
Missing Playwright features
This module includes functions that are not part of the PhoenixTest protocol, e.g. screenshot/3 and click_link/4.
But it does not wrap the entire Playwright API, which is quite large.
You should be able to add any missing functionality yourself
using PhoenixTest.unwrap/2, Frame, Selector,
and the Playwright code.
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
defp 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
defp within_iframe(conn, selector \\ "iframe", fun) when is_function(fun, 1) do
within(conn, "#{selector} >> internal:control=enter-frame", fun)
end
Summary
Functions
Add cookies to the browser context, using Plug.Conn.put_resp_cookie/3
Add a Plug.Session cookie to the browser context.
Removes all cookies from the context
Click an element that is not a link or button.
Otherwise, use click_link/4 and click_button/4.
Like PhoenixTest.click_button/3, but allows exact text match.
Like PhoenixTest.click_link/3, but allows exact text match.
Drag and drop a source element to a target element.
Focuses the matching element and presses a combination of the keyboard keys.
Takes a screenshot of the current page and saves it to the given file path.
Focuses the matching element and simulates user typing.
Handle browser dialogs (alert(), confirm(), prompt()) while executing the inner function.
Types
@type css_selector() :: String.t()
@type playwright_selector() :: String.t()
@type selector() :: playwright_selector() | css_selector()
@opaque t()
Functions
Add cookies to the browser context, using Plug.Conn.put_resp_cookie/3
Note that for signed cookies the signing salt is not configurable.
As such, this function is not appropriate for signed Plug.Session cookies.
For signed session cookies, use add_session_cookie/3
A cookie's value must be a binary unless the cookie is signed/encrypted
Cookie fields
| key | type | description |
|---|---|---|
:name | binary() | |
:value | binary() | |
:url | binary() | (optional) either url or domain / path are required |
:domain | binary() | (optional) either url or domain / path are required |
:path | binary() | (optional) either url or domain / path are required |
:max_age | float() | (optional) The cookie max age, in seconds. |
:http_only | boolean() | (optional) |
:secure | boolean() | (optional) |
:encrypt | boolean() | (optional) |
:sign | boolean() | (optional) |
:same_site | binary() | (optional) one of "Strict", "Lax", "None" |
Two of the cookie fields mean nothing to Playwright. These are:
:encrypt:sign
The :max_age cookie field means the same thing as documented in Plug.Conn.put_resp_cookie/4.
The :max_age value is used to infer the correct expires value that Playwright requires.
See https://playwright.dev/docs/api/class-browsercontext#browser-context-add-cookies
Add a Plug.Session cookie to the browser context.
This is useful for emulating a logged-in user.
Note that that the cookie :value must be a map, since we are using
Plug.Conn.put_session/3 to write each of value's key-value pairs
to the cookie.
The session_options are exactly the same as the opts used when
writing plug Plug.Session in your router/endpoint module.
Examples
|> add_session_cookie(
[value: %{user_token: Accounts.generate_user_session_token(user)}],
MyAppWeb.Endpoint.session_options()
)
Removes all cookies from the context
See click/4.
Click an element that is not a link or button.
Otherwise, use click_link/4 and click_button/4.
Options
:exact(boolean/0) - Exact or substring text match. The default value isfalse.
Examples
|> click(Selector.menuitem("Edit"))
|> click("summary", "(expand)", exact: false)
Like PhoenixTest.click_button/3, but allows exact text match.
Options
:exact(boolean/0) - Exact or substring text match. The default value isfalse.
Like PhoenixTest.click_link/3, but allows exact text match.
Options
:exact(boolean/0) - Exact or substring text match. The default value isfalse.
Drag and drop a source element to a target element.
Options
:to(selector/0) - Required. The target selector.
Examples
|> drag("#source", to: "#target")
|> drag(Selector.text("Draggable"), to: Selector.text("Target"))
@spec press(t(), selector(), String.t(), [{:delay, non_neg_integer()}]) :: t()
Focuses the matching element and presses a combination of the keyboard keys.
Use type/4 if you don't need to press special keys.
Examples of supported keys:
F1 - F12, Digit0- Digit9, KeyA- KeyZ, Backquote, Minus, Equal, Backslash, Backspace, Tab, Delete, Escape, ArrowDown, End, Enter, Home, Insert, PageDown, PageUp, ArrowRight, ArrowUp
Modifiers are also supported:
Shift, Control, Alt, Meta, ShiftLeft, ControlOrMeta
Combinations are also supported:
Control+o, Control++, Control+Shift+T
Options
:delay(non_neg_integer/0) - Time to wait between keydown and keyup in milliseconds. The default value is0.
Examples
|> press("#id", "Control+Shift+T")
|> press(Selector.button("Submit"), "Enter")
Takes a screenshot of the current page and saves it to the given file path.
The file type will be inferred from the file extension on the path you provide.
The file is saved in :screenshot_dir, see PhoenixTest.Playwright.Config.
Options
:full_page(boolean/0) - The default value istrue.:omit_background(boolean/0) - Only applicable to .png images. The default value isfalse.
Examples
|> screenshot("my-screenshot.png")
|> screenshot("my-test/my-screenshot.jpg")
@spec type(t(), selector(), String.t(), [{:delay, non_neg_integer()}]) :: t()
Focuses the matching element and simulates user typing.
In most cases, you should use PhoenixTest.fill_in/4 instead.
Options
:delay(non_neg_integer/0) - Time to wait between key presses in milliseconds. The default value is0.
Examples
|> type("#id", "some text")
|> type(Selector.role("heading", "Untitled"), "New title")
See PhoenixTest.unwrap/2.
Invokes fun with various Playwright IDs.
These can be used to interact with the Playwright
BrowserContext,
Page and
Frame.
Examples
|> unwrap(&Frame.evaluate(&1.frame_id, "console.log('Hey')"))
Handle browser dialogs (alert(), confirm(), prompt()) while executing the inner function.
Note: Add @tag accept_dialogs: false before tests that call this function.
Otherwise, all dialogs are accepted by default.
Callback return values
The callback may return one of these values:
:accept-> accepts confirmation dialog{:accept, prompt_text}-> accepts prompt dialog with text:dismiss-> dismisses dialog- Any other value will ignore the dialog
Examples
@tag accept_dialogs: false
test "conditionally handle dialog", %{conn: conn} do
conn
|> visit("/")
|> with_dialog(
fn
%{message: "Are you sure?"} -> :accept
%{message: "Enter the magic number"} -> {:accept, "42"}
%{message: "Self destruct?"} -> :dismiss
end,
fn conn ->
conn
|> click_button("Delete")
|> assert_has(".flash", text: "Deleted")
end
end)
end