PhoenixTest.Playwright (PhoenixTestPlaywright v0.10.0-rc.0)

View Source

Run 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
end

Please get in touch with feedback of any shape and size.

Enjoy! Freddy.

P.S. Looking for a standalone Playwright client? See PlaywrightEx.

Getting started

  1. Add dependency

     # mix.exs
     {:phoenix_test_playwright, "~> 0.9", only: :test, runtime: false}
  2. Install playwright and browser

     npm --prefix assets i -D playwright
     npm --prefix assets exec -- playwright install chromium --with-deps
  3. Config

     # config/test.exs
     config :phoenix_test, otp_app: :your_app
     config :your_app, YourAppWeb.Endpoint, server: true
  4. Runtime config

     # test/test_helpers.exs
     {:ok, _} = PhoenixTest.Playwright.Supervisor.start_link()
     Application.put_env(:phoenix_test, :base_url, YourAppWeb.Endpoint.url()
  5. 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" 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"

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]]] or 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 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
  # ...
end

If 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

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.

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

css_selector()

@type css_selector() :: String.t()

playwright_selector()

@type playwright_selector() :: String.t()

selector()

@type selector() :: playwright_selector() | css_selector()

t()

@opaque t()

Functions

add_cookies(conn, cookies)

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

keytypedescription
:namebinary()
:valuebinary()
:urlbinary()(optional) either url or domain / path are required
:domainbinary()(optional) either url or domain / path are required
:pathbinary()(optional) either url or domain / path are required
:max_agefloat()(optional) The cookie max age, in seconds.
:http_onlyboolean()(optional)
:secureboolean()(optional)
:encryptboolean()(optional)
:signboolean()(optional)
:same_sitebinary()(optional) one of "Strict", "Lax", "None"

Two of the cookie fields mean nothing to Playwright. These are:

  1. :encrypt
  2. :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_session_cookie(conn, cookie, session_options)

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()
)

clear_cookies(conn, opts \\ [])

Removes all cookies from the context

click(conn, selector)

@spec click(t(), selector()) :: t()

See click/4.

click(conn, selector, text, opts \\ [])

@spec click(t(), selector(), String.t(), [{:exact, boolean()}]) :: t()

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 is false.

Examples

|> click(Selector.menuitem("Edit"))
|> click("summary", "(expand)", exact: false)

click_button(conn, selector \\ nil, text, opts \\ [])

Like PhoenixTest.click_button/3, but allows exact text match.

Options

  • :exact (boolean/0) - Exact or substring text match. The default value is false.

click_link(conn, selector \\ nil, text, opts \\ [])

Like PhoenixTest.click_link/3, but allows exact text match.

Options

  • :exact (boolean/0) - Exact or substring text match. The default value is false.

drag(conn, source_selector, list)

@spec drag(t(), selector(), [{:to, selector()}]) :: t()

Drag and drop a source element to a target element.

Options

Examples

|> drag("#source", to: "#target")
|> drag(Selector.text("Draggable"), to: Selector.text("Target"))

press(conn, selector, key, opts \\ [])

@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 is 0.

Examples

|> press("#id", "Control+Shift+T")
|> press(Selector.button("Submit"), "Enter")

screenshot(conn, file_path, opts \\ [])

@spec screenshot(t(), String.t(), full_page: boolean(), omit_background: boolean()) ::
  t()

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 is true.

  • :omit_background (boolean/0) - Only applicable to .png images. The default value is false.

Examples

|> screenshot("my-screenshot.png")
|> screenshot("my-test/my-screenshot.jpg")

type(conn, selector, text, opts \\ [])

@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 is 0.

Examples

|> type("#id", "some text")
|> type(Selector.role("heading", "Untitled"), "New title")

unwrap(conn, fun)

@spec unwrap(t(), (%{context_id: any(), page_id: any(), frame_id: any()} -> any())) ::
  t()

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')"))

with_dialog(session, callback, fun)

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