View Source Pages
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.
This library is tested against the latest 3 major versions of Elixir.
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.