View Source Pages

CI Hex pm API docs License

Pages is a tool for testing Phoenix web applications purely in fast asynchronous Elixir tests, without the need for a web browser. It can seamlessly move between LiveView-based pages (via Phoenix.LiveViewTest) and controller-based pages (via Phoenix.ConnTest), so web tests are as fast as all other unit tests.

Pages has been used for testing multiple applications over the past 2+ years without much need for API changes.

Quick Overview

Interacting With A Web App

The Pages.new/1 function creates a new Pages.Driver struct from the conn, and most of the rest of the Pages functions expect that driver to be passed in as the first argument and will return that driver:

profile_page =
  conn
  |> Pages.new()
  |> Pages.visit(Web.Paths.auth())
  |> Pages.submit_form("[test-role=auth]", :user, name: "alice", password: "password1234")
  |> Pages.click("[test-role=my-profile-button]")

# `profile_page` now references a `Pages.Driver.LiveView` struct.

(Curious about Web.Paths.auth() above? Read this article: Web.Paths.)

See Pages' API reference for more info, specifically the docs for the Pages module.

Finding Elements On A Page

Instead of having a bespoke API for parsing HTML, Pages allows you to use your favorite HTML parsing library.

We naturally recommend the one we built: HtmlQuery. It and its XML counterpart XmlQuery have the same concise API. The main functions are: all/2, find/2, find!/2, attr/2, and text/1.

You can use a different library or your own code. The drivers (Pages.Driver.Conn and Pages.Driver.LiveView) implement the String.Chars protocol, so you can call to_string/0 on any page to get its rendered result.

Here's an example of using HtmlQuery to get all the email addresses from the profile_page:

alias HtmlQuery, as: Hq

# ...

email_addresses =
  dashboard_page
  |> Hq.all("ul[test-role=email-addresses] li")
  |> Enum.map(&Hq.text/1)

assert email_addresses == ["alice@example.com", "alice@example.net"]

Optional: Taming Complexity With The Page Object Pattern

In a large web application, test complexity becomes an issue. One way to solve web test complexity is by using the Page Object pattern for encapsulating each page's content, actions, and assertions in its own module.

Using the Pages library does not require implementing the page object pattern, and implementing the page object pattern doesn't necessitate using the Pages library. However, we find it to be an extremely effective way to keep tests simple so we'll provide an example of implementing the pattern with Pages.

The typical usage is to create a module for each page of your web app, with functions for each action that a user can take on that page, and then to call those functions in a test. Note that in this example, Web and Test are top-level modules in the app that's being tested.

defmodule Web.HomeLiveTest do
  use Test.ConnCase, async: true

  test "has login button", %{conn: conn} do
    conn
    |> Pages.new()
    |> Test.Pages.HomePage.assert_here()
    |> Test.Pages.HomePage.click_login_link()
    |> Test.Pages.LoginPage.assert_here()
  end
end

Here is the definition of the HomePage module that's used in the test above. This test uses assert_eq/3 from the Moar library, and find/2 & attr/2 from the HtmlQuery library.

defmodule Test.Pages.HomePage do
  import Moar.Assertions
  alias HtmlQuery, as: Hq

  @spec assert_here(Pages.Driver.t()) :: Pages.Driver.t()
  def assert_here(%Pages.Driver.LiveView{} = page) do
    page
    |> Hq.find("[data-page]")
    |> Hq.attr("data-page")
    |> assert_eq("home", returning: page)
  end

  @spec click_login_link(Pages.Driver.t()) :: Pages.Driver.t()
  def click_login_link(page),
    do: page |> Pages.click("Log In", test_role: "login-link")

  @spec visit(Pages.Driver.t()) :: Pages.Driver.t()
  def visit(page),
    do: page |> Pages.visit("/")
end

A page module that you define can work with either a controller-based page or a LiveView-based page, and a test can test workflows that use both controllers and LiveViews.

The Context

If you have information you'd like to keep while stepping through multiple pages, you can assign it to the context from functions like Pages.new and Pages.visit:

page =
  conn
  |> Pages.visit("/live/form", some_key: "some_value")
  |> Pages.update_form("#form", foo: [action: "redirect"])
  |> assert_here("pages/show")

assert page.context == %{some_key: "some_value"}

Installation

def deps do
  [
    {:pages, "~> 1.0.1", only: :test}
  ]
end

Configure your endpoint in config/test.exs:

config :pages, :phoenix_endpoint, Web.Endpoint

Alternatives

The relatively recent phoenix_test library is similar in that it handles controller- and LiveView-based tests. Its API style is quite different that Pages' API.

For tests that run in a real browser, the venerable Wallaby is the only production-ready choice at the moment.

playwright-elixir is an Elixir driver for Playwright. Its readme says that "the features are not yet at parity with other Playwright implementations" but it might be worth checking out.

Under the hood

This library uses functions from Phoenix.ConnTest and from Phoenix.LiveViewTest to simulate a user clicking around and therefore can't test any Javascript functionality. To fix this, a driver for browser-based testing via Wallaby or Playwright Elixir would be needed, but one does not yet exist. The upside is that Pages-based tests are extremely fast.