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

  1. Add dependency

    # mix.exs
    {:phoenix_test_playwright, "~> 0.4", 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
    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: :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.

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

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

click(session, selector)

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

See click/4.

click(session, 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", exact: true))
|> click("summary", "(expand)", exact: false)

click_button(session, 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(session, 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.

press(session, 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", exact: true), "Enter")

screenshot(session, 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(session, 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", exact: true), "New title")

unwrap(session, 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')"))