Testing in Elixir: Patterns for Phoenix live-view
This post is part of the Testing Series, an overview of testing strategies for Elixir applications.
Similar to the previous posts on testing, the focus with LiveView tests is on validating meaningful behaviors while avoiding tests that are sensitive to irrelevant changes in the codebase. In Elixir, CaseTemplate
is used to create a domain-specific language (DSL) for testing LiveView components.
Updating the ConnCase
When Phoenix’s code generator creates the ConnCase
module, Ecto
, the Factory
, and two helper modules—ConnSetup
and HTMLSelectors
—are added. While the code in these helper modules could reside directly within ConnCase
, separating them ensures better code organization.
Here’s how the ConnCase
module is structured:
defmodule GameAppWeb.ConnCase do
use ExUnit.CaseTemplate
using do
quote do
@endpoint GameAppWeb.Endpoint
use GameAppWeb, :verified_routes
alias GameApp.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import Plug.Conn
import Phoenix.ConnTest
import GameAppWeb.ConnCase
import GameApp.Factory
import GameAppWeb.ConnSetup
import GameAppWeb.HTMLSelectors
end
end
setup tags do
GameApp.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end
By encapsulating these modules within the quote
block, macros are generated from their behaviors, effectively reducing redundancy in the code and emphasizing their role within the LiveView testing environment.
Helpers
HTMLSelectors
module: This module provides reusable HTML selectors, eliminating the need to redefine them in each test. This approach not only streamlines the testing process but also enhances maintainability by centralizing changes to selector definitions.
ConnSetup
module: Only pipeable functions that transform Phoenix LiveView’scontext
are included in this module. These transformers work together, making it useful to keep them in one place.
defmodule GameAppWeb.ConnSetup do
import GameApp.Factory
def create_player_1(context) do
player = insert(:player)
Map.put(context, :player_1, player)
end
def create_player_2(context) do
player = insert(:player)
Map.put(context, :player_2, player)
end
end
In ConnSetup
, the Factory
populates the database and LiveView’s context
with valid player entities. The player_1
and player_2
functions ensure they can be called without conflict in a test setup.
Test the show
Live Component
defmodule GameAppWeb.PlayerLiveShowTest do
use GameAppWeb.ConnCase
import Phoenix.LiveViewTest
@moduletag :player
@moduletag :database
@moduletag :live_view
def get_url(player) do
~p"/players/#{player.id}"
end
describe "Show" do
setup [:create_player_1]
test "success: displays player", %{conn: conn, player_1: player_1} do
{:ok, _view, html} = live(conn, get_url(player_1))
assert html =~ player_1.name
assert html =~ player_1.email
assert html =~ to_string(player_1.score)
end
test "failure: missing player redirects to list", %{conn: conn} do
missing_id = -1
assert {:error, {:live_redirect, %{to: "/players"}}} =
live(conn, ~p"/players/#{missing_id}")
{:ok, view, _html} = live(conn, ~p"/players")
assert has_flash?(view, :error)
end
end
end
-
Success: Verifies the live view displays the details of a player when provided with a valid player ID, confirming the player’s name, email, and score are correctly rendered in the HTML output.
-
Failure: Checks the behavior when a nonexistent player ID is used. It ensures that the system redirects to the player list page and displays an appropriate error message via a flash notification.
Error messages and headers are not explicitly verified, as future updates to these messages or text are not typically indicative of functional issues.
Test the list
Live Component
defmodule GameAppWeb.PlayerLiveIndexTest do
use GameAppWeb.ConnCase
import Phoenix.LiveViewTest
@moduletag :player
@moduletag :database
@moduletag :live_view
@url "/players"
describe "Index" do
setup [:create_player_1, :create_player_2]
test "success: lists all players", %{conn: conn, player_1: player_1, player_2: player_2} do
{:ok, view, _html} = live(conn, @url)
assert_player_in_table(view, player_1)
assert_player_in_table(view, player_2)
end
end
defp assert_player_in_table(view, player) do
column = "td"
assert has_element?(view, column, player.email)
assert has_element?(view, column, player.name)
assert has_element?(view, column, to_string(player.score))
end
end
During setup, two players are added to the database, and the test verifies both are displayed in the HTML table, focusing on confirming the presence of all intended entries within the view.
Test the Form Component
defmodule GameAppWeb.PlayerLiveFormTest do
use GameAppWeb.ConnCase
import Phoenix.LiveViewTest
@moduletag :player
@moduletag :database
@moduletag :live_view
@valid_attrs %{
name: Faker.Person.name(),
email: Faker.Internet.email(),
score: Faker.random_between(1, 100_000)
}
@invalid_attrs %{name: nil, email: "INVALID_EMAIL", score: -1}
@url "/players/new"
defp get_url(player), do: ~p"/players/#{player.id}/edit"
describe "New" do
setup [:create_player_1]
test "success: creates valid player", %{conn: conn} do
{:ok, view, _html} = live(conn, @url)
view
|> form("#player-form", player: @valid_attrs)
|> render_submit()
assert_patch(view, ~p"/players")
assert_player_in_table(view, @valid_attrs)
assert has_flash?(view, :info)
end
test "failure: does not create invalid player", %{conn: conn} do
{:ok, view, _html} = live(conn, @url)
view
|> form("#player-form", player: @invalid_attrs)
|> render_submit()
assert has_error_message?(view, :player, "name")
assert has_error_message?(view, :player, "email")
assert has_error_message?(view, :player, "score")
end
test "failure: does not create player with existing email", %{conn: conn, player_1: player_1} do
attrs = Map.put(@valid_attrs, :email, player_1.email)
{:ok, view, _html} = live(conn, @url)
view
|> form("#player-form", player: attrs)
|> render_submit()
assert has_error_message?(view, :player, "email")
end
end
describe "Edit" do
setup [:create_player_1]
test "success: updates valid values", %{conn: conn, player_1: player_1} do
attrs = %{
name: Faker.Person.name(),
email: player_1.email,
score: player_1.score + 1
}
{:ok, view, _html} = live(conn, get_url(player_1))
view
|> form("#player-form", player: attrs)
|> render_submit()
assert_patch(view, ~p"/players")
assert_player_in_table(view, attrs)
assert has_flash?(view, :info)
end
test "failure: does not update with invalid values", %{conn: conn, player_1: player_1} do
{:ok, view, _html} = live(conn, get_url(player_1))
view
|> form("#player-form", player: @invalid_attrs)
|> render_change()
assert has_error_message?(view, :player, "name")
assert has_error_message?(view, :player, "score")
end
test "failure: prevents decrementing score", %{conn: conn, player_1: player_1} do
attrs = Map.put(@valid_attrs, :score, player
_1.score - 1)
{:ok, view, _html} = live(conn, get_url(player_1))
view
|> form("#player-form", player: attrs)
|> render_change()
assert has_error_message?(view, :player, "score")
end
end
defp assert_player_in_table(view, attrs) do
column = "td"
assert has_element?(view, column, attrs.email)
assert has_element?(view, column, attrs.name)
assert has_element?(view, column, to_string(attrs.score))
end
end
-
New Player Tests: These verify the successful creation of new players with valid attributes and ensure errors are handled appropriately for invalid data.
-
Edit Player Tests: These confirm updates are correctly applied when valid data is submitted and verify the view rejects invalid updates, such as attempts to decrement a player’s score.
Both scenarios ensure that upon successful submission, the form redirects back to the list view and changes are applied and visible in the user interface.