View Source PhoenixTest.Playwright (PhoenixTestPlaywright v0.4.0)

Run feature tests in an actual browser, using PhoenixTest and Playwright.

defmodule Features.RegisterTest do
  use PhoenixTest.Case, async: true,
    # run in multiple browsers in parallel
    parameterize: [%{browser: :chromium}, %{browser: :firefox}]

  @moduletag :playwright
  @moduletag headless: false             # show browser window
  @moduletag 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.Case, async: true
     @moduletag :playwright
    
     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 via @moduletag/@describetag/@tag:

defmodule DebuggingFeatureTest do
  use PhoenixTest.Case, async: true

  # Run test in a browser with a 1 second delay between every interaction
  @moduletag headless: false
  @moduletag slow_mo: 1_000

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

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.Case, async: true

PhoenixTest.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 driver doesn't wrap the entire Playwright API. However, you should be able to wrap 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

assert_has(session, selector)

assert_has(session, selector, opts)

build(context_id, page_id, frame_id)

check(session, css_selector \\ nil, label, opts)

choose(session, css_selector \\ nil, label, opts)

click(session, selector)

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

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

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

current_path(session)

fill_in(session, css_selector \\ nil, label, opts)

open_browser(session, open_fun \\ &OpenBrowser.open_with_system_cmd/1)

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

Focuses the matching element and presses a combination of the keyboard 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 (integer): Time to wait between keydown and keyup in milliseconds. Defaults to 0.

Examples

> PhoenixTest.Playwright.press(session, "#id", "Enter")

refute_has(session, selector)

refute_has(session, selector, opts)

render_html(session)

render_page_title(session)

retry(fun, backoff_ms \\ [100, 250, 500, timeout()])

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

Takes a screenshot of the current page and saves it to the given file path.

The screenshot type will be inferred from the file extension on the path you provide. If the path is relative (e.g., "my_screenshot.png" or "my_test/my_screenshot.jpg"), it will be saved in the directory specified by the :screenshot_dir config option, which defaults to "screenshots".

Options

  • :full_page (boolean): Whether to take a full page screenshot. If false, only the current viewport will be captured. Defaults to true.
  • :omit_background (boolean): Whether to omit the background, allowing screenshots to be captured with transparency. Only applicable to PNG images. Defaults to false.

Examples

# By default, writes to screenshots/my-screenshot.png within your project root
> PhoenixTest.Playwright.screenshot(session, "my-screenshot.png")

# Writes to screenshots/my-test/my-screenshot.jpg by default
> PhoenixTest.Playwright.screenshot(session, "my-test/my-screenshot.jpg")

select(session, css_selector \\ nil, option_labels, opts)

submit(session)

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

Focuses the matching element and simulates user typing.

In most cases, you should use fill_in/4 instead.

Options

  • :delay (integer): Time to wait between key presses in milliseconds. Defaults to 0.

Examples

> PhoenixTest.Playwright.type(session, "#id", "some text")

uncheck(session, css_selector \\ nil, label, opts)

unwrap(session, fun)

upload(session, css_selector \\ nil, label, paths, opts)

visit(session, path)

within(session, selector, fun)