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.

See the code

See the documentation

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.

See the code

  • ConnSetup module: Only pipeable functions that transform Phoenix LiveView’s context 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.