View Source PhoenixTest.Playwright (PhoenixTestPlaywright v0.1.5)

Warning

This driver is experimental. If you don't need browser based tests, see PhoenixTest on regular usage.

Execute PhoenixTest cases in an actual browser via Playwright.

Example

Refer to the accompanying example repo for a full example: https://github.com/ftes/phoenix_test_playwright_example/commits/main

Setup

  1. Add to mix.exs deps: {:phoenix_test_playwright, "~> 0.1", only: :test, runtime: false}
  2. Install Playwright: npm --prefix assets i -D playwright
  3. Install browsers: npm --prefix assets exec playwright install --with-deps
  4. Add to config/test.exs: config :phoenix_test, otp_app: :your_app, playwright: [cli: "assets/node_modules/playwright/cli.js"]
  5. Add to config/test.exs: config :your_app, YourAppWeb.Endpoint, server: true
  6. Add to test/test_helpers.exs: Application.put_env(:phoenix_test, :base_url, YourAppWeb.Endpoint.url())

Usage

defmodule MyFeatureTest do
  use PhoenixTest.Case, async: true
  @moduletag :playwright

  @tag trace: :open
  test "heading", %{conn: conn} do
    conn
    |> visit("/")
    |> assert_has("h1", text: "Heading")
  end
end

As shown above, you can use ExUnit.Case parameterized tests to run tests concurrently in different browsers.

Configuration

In config/test.exs:

config :phoenix_test,
  otp_app: :your_app,
  playwright: [
    cli: "assets/node_modules/playwright/cli.js",
    browser: [browser: :chromium, headless: System.get_env("PLAYWRIGHT_HEADLESS", "t") in ~w(t true)],
    trace: System.get_env("PLAYWRIGHT_TRACE", "false") in ~w(t true),
    trace_dir: "tmp"
  ],
  timeout_ms: 2000

Playwright Traces

You can enable trace recording in different ways:

  • Environment variable, see Configuration
  • ExUnit @tag :trace
  • ExUnit @tag trace: :open to open the trace viewer automatically after completion

Common problems

  • Test failures in CI (timeouts): Try less concurrency, e.g. mix test --max-cases 1 for GitHub CI shared runners
  • LiveView not connected: add assert_has("body .phx-connected") to test after visiting (or otherwise navigating to) a LiveView
  • LiveComponent not connected: add data-connected={connected?(@socket)} to template and assert_has("#my-component[data-connected]") to test

Ecto SQL.Sandbox

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:

defmodule MyTest do
  use PhoenixTest.Case, async: true

Advanced assertions

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, %{isNot: 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

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

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, input_selector, label, opts)

choose(session, input_selector, label, opts)

click(session, selector)

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

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

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

current_path(session)

fill_in(session, input_selector, label, opts)

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

refute_has(session, selector)

refute_has(session, selector, opts)

render_html(session)

render_page_title(session)

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

select(session, input_selector, option_labels, opts)

submit(session)

uncheck(session, input_selector, label, opts)

unwrap(session, fun)

upload(session, input_selector, label, paths, opts)

visit(session, path)

within(session, selector, fun)