View Source PhoenixTest.Playwright (PhoenixTestPlaywright v0.5.0)
Run feature tests in an actual browser, using PhoenixTest and Playwright.
defmodule Features.RegisterTest do
use PhoenixTest.Playwright.Case,
async: true,
parameterize: [ # run in multiple browsers in parallel
%{browser: :chromium},
%{browser: :firefox}
],
headless: false, # show browser window
slow_mo: :timer.seconds(1) # add delay between interactions
@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(".text-rose-600", text: "required")
|> screenshot("error.png", full_page: true)
end
end
Please get in touch with feedback of any shape and size.
Enjoy! Freddy.
Getting started
Add dependency
# mix.exs {:phoenix_test_playwright, "~> 0.4", only: :test, runtime: false}
Install playwright and browser
npm --prefix assets i -D playwright npm --prefix assets exec 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_helpers.exs 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: :chromium,
headless: System.get_env("PW_HEADLESS", "true") in ~w(t true),
js_logger: false,
screenshot: System.get_env("PW_SCREENSHOT", "false") in ~w(t true),
trace: System.get_env("PW_TRACE", "false") in ~w(t true),
]
See PhoenixTest.Playwright.Config
for more details.
You can override some options in your test:
defmodule DebuggingFeatureTest do
use PhoenixTest.Playwright.Case,
async: true,
# 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" do
Automatically 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 viewport
Automatically 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"
Common problems
Test failure in CI (timeout)
- Limit concurrency:
mix test --max-cases 1
for GitHub CI shared runners - Increase timemout:
config :phoenix_test, playwright: [timeout: :timer.seconds(4)]
- More compute power: e.g.
x64 8-core
GitHub runner
LiveView not connected
|> visit(~p"/")
|> assert_has("body .phx-connected")
# now continue, playwright has waited for LiveView to connect
LiveComponent 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 connect
Ecto SQL.Sandbox
defmodule MyTest do
use PhoenixTest.Playwright.Case, async: true
PhoenixTest.Playwright.Case
automatically takes care of this.
It passes a user agent referencing your Ecto repos.
This allows for concurrent browser tests.
Make sure to follow the advanced set up instructions if necessary:
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 assert_a11y(session) do
A11yAudit.Assertions.assert_no_violations(fn ->
Frame.evaluate(session.frame_id, A11yAudit.JS.axe_core())
session.frame_id
|> Frame.evaluate("axe.run().then(res => JSON.stringify(res))")
|> JSON.decode!()
|> A11yAudit.Results.from_json()
end)
session
end
def assert_download(session, name, contains: content) do
assert_receive({:playwright, %{method: :download} = download_msg}, 2000)
artifact_guid = download_msg.params.artifact.guid
assert_receive({:playwright, %{method: :__create__, params: %{guid: ^artifact_guid}} = artifact_msg}, 2000)
download_path = artifact_msg.params.initializer.absolutePath
wait_for_file(download_path)
assert download_msg.params.suggestedFilename =~ name
assert File.read!(download_path) =~ content
session
end
def assert_has_value(session, label, value, opts \\ []) do
opts = Keyword.validate!(opts, exact: true)
assert_found(session,
selector: Selector.label(label, opts),
expression: "to.have.value",
expectedText: [%{string: value}]
)
end
def assert_has_selected(session, label, value, opts \\ []) do
opts = Keyword.validate!(opts, exact: true)
assert_found(session,
selector: label |> Selector.label(opts) |> Selector.concat("option[selected]"),
expression: "to.have.text",
expectedText: [%{string: value}]
)
end
def assert_is_chosen(session, label, opts \\ []) do
opts = Keyword.validate!(opts, exact: true)
assert_found(session,
selector: Selector.label(label, opts),
expression: "to.have.attribute",
expressionArg: "checked"
)
end
def assert_is_editable(session, label, opts \\ []) do
opts = Keyword.validate!(opts, exact: true)
assert_found(session,
selector: Selector.label(label, opts),
expression: "to.be.editable"
)
end
def refute_is_editable(session, label, opts \\ []) do
opts = Keyword.validate!(opts, exact: true)
assert_found(
session,
[
selector: Selector.label(label, opts),
expression: "to.be.editable"
],
is_not: true
)
end
def assert_found(session, params, opts \\ []) do
is_not = Keyword.get(opts, :is_not, false)
params = Enum.into(params, %{is_not: is_not})
unwrap(session, fn frame_id ->
{:ok, found} = Frame.expect(frame_id, params)
if is_not, do: refute(found), else: assert(found)
end)
end
defp wait_for_file(path, remaining_ms \\ 2000, wait_for_ms \\ 100)
defp wait_for_file(path, remaining_ms, _) when remaining_ms <= 0, do: flunk("File #{path} does not exist")
defp wait_for_file(path, remaining_ms, wait_for_ms) do
if File.exists?(path) do
:ok
else
Process.sleep(wait_for_ms)
wait_for_file(path, remaining_ms - wait_for_ms, wait_for_ms)
end
end
Summary
Functions
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.
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.
Types
@type css_selector() :: String.t()
@type playwright_selector() :: String.t()
@type selector() :: playwright_selector() | css_selector()
@opaque t()
Functions
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", exact: true))
|> 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
.
@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", exact: true), "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", exact: true), "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')"))