Browse lessons

Testing LiveComponents

Now that we’ve seen how to test function components, let’s take a look at how to test LiveComponents.

Unlike function components, LiveComponents can have their own state and events.

So, let’s take a look at how we can test them.

SettingsLive

If you go to our router, you’ll notice we have a SettingsLive mounted on /users/:user_id/settings.

If you launch your browser and visit /users/1/settings, you’ll see that the settings page has two inputs: one for a name and one for an email.

Each input is editable inline. We can click on it and edit it. Hitting enter will save the new value. Clicking away will stop editing.

Let’s see how we implemented that. Open up SettingsLive.

defmodule RangerWeb.SettingsLive do
  use RangerWeb, :live_view

  alias Ranger.{Repo, User}
  alias RangerWeb.SettingsLive.EditableInputComponent

  def render(assigns) do
    ~H"""
    <div class="space-y-4">
      <.live_component module={EditableInputComponent} id="name" field={:name} user={@user} />
      <.live_component module={EditableInputComponent} id="email" field={:email} user={@user} />
    </div>
    """
  end

  def mount(params, _, socket) do
    user = get_user(params["user_id"])

    {:ok, assign(socket, user: user)}
  end

  def handle_info({:updated_user, user}, socket) do
    socket
    |> assign(:user, user)
    |> noreply()
  end

  defp noreply(socket), do: {:noreply, socket}

  defp get_user(user_id) do
    Repo.get(User, user_id)
  end
end

As you can see, we do the following:

  • Our LiveView renders two EditableInputComponent LiveComponents.
  • The components pass a field and the user struct.
  • mount/3 takes "user_id", fetches the corresponding user, and sets it as an assign.
  • We also have handle_info/2 to communicate with the LiveComponents.
  • The other private functions are helpers to keep code clean.

EditableInputComponent

Let’s dive into the EditableInputComponent found in lib/ranger_web/live/settings_live/editable_input_component.ex.

defmodule RangerWeb.SettingsLive.EditableInputComponent do
  use RangerWeb, :live_component

  alias Ranger.{Repo, User}

  def render(assigns) do
    ~H"""
    <div class="max-w-md mx-auto">
      <%= if @edit do %>
        <.simple_form
          :let={f}
          id={"#{@field}-form"}
          for={@form}
          phx-click-away="cancel"
          phx-submit="save"
          phx-target={@myself}
        >
          <.input field={{f, @field}} value={Map.get(@user, @field)} />
        </.simple_form>
      <% else %>
        <div class="space-y-8 bg-white mt-10">
          <div
            id={@field}
            phx-click="edit"
            phx-target={@myself}
            class="text-sm bg-gray-100 border-1 p-2 rounded-lg mt-2 flex items-center justify-between gap-6"
          >
            <%= Map.get(@user, @field) %>
            <Heroicons.pencil solid class="text-blue-500 w-4 h-4 stroke-current inline" />
          </div>
        </div>
      <% end %>
    </div>
    """
  end

  def mount(socket) do
    {:ok, assign(socket, form: to_form(%{}), edit: false)}
  end

  def handle_event("edit", _, socket) do
    socket
    |> assign(:edit, true)
    |> noreply()
  end

  def handle_event("cancel", _, socket) do
    socket
    |> assign(:edit, false)
    |> noreply()
  end

  def handle_event("save", params, socket) do
    user = socket.assigns.user
    updated_user = update_user(user, params)

    send(self(), {:updated_user, updated_user})

    socket
    |> assign(:edit, false)
    |> noreply()
  end

  defp noreply(socket), do: {:noreply, socket}

  defp update_user(user, params) do
    user
    |> User.changeset(params)
    |> Repo.update!()
  end
end

This is where most of the behavior lives:

  • We have an if/else that renders either a form or a display div based on @edit.
  • In the else case, we render the current field value and wire phx-click="edit" targeted to @myself.
  • Clicking it triggers handle_event("edit", ...), which flips :edit to true.
  • In edit mode, we render a form with one input and handle phx-submit="save" targeted to @myself.
  • On save, we update the user, send {:updated_user, updated_user} to the parent LiveView, and set edit mode back to false.

Testing LiveComponents

There are two things we can test about LiveComponents:

  • static content
  • interactivity

Each needs a different approach.

Testing static content

Open test/ranger_web/live/settings_live/editable_input_component_test.exs.

defmodule RangerWeb.SettingsLive.EditableInputComponentTest do
  use RangerWeb.ConnCase, async: true

  import Phoenix.LiveViewTest

  alias RangerWeb.SettingsLive.EditableInputComponent
end

Let’s write our first test:

test "renders field text" do
  user = %{name: "Frodo"}

  html = render_component(EditableInputComponent, id: "name", field: :name, user: user)

  assert html =~ "Frodo"
end

Downsides of render_component/3 here:

  • It only tests static content, not interactivity.
  • It has the usual HTML-blob assertion trade-offs (too broad vs too brittle).

Testing interactivity

To test interactivity, we need to go through SettingsLive, so this becomes a normal LiveView test.

That means we’re testing both SettingsLive and EditableInputComponent together.

Let’s write tests in settings_live_test.exs and focus on behavior (not implementation):

defmodule RangerWeb.SettingsLiveDoneTest do
  use RangerWeb.ConnCase, async: true

  import Phoenix.LiveViewTest

  alias Ranger.{Repo, User}

  test "renders user's information", %{conn: conn} do
    user = create_user()

    {:ok, _view, html} = live(conn, ~p"/users/#{user}/settings")

    assert html =~ user.name
    assert html =~ user.email
  end

  test "users can edit their name", %{conn: conn} do
    user = create_user(%{name: "frodo"})
    {:ok, view, _html} = live(conn, ~p"/users/#{user}/settings")

    view
    |> element("#name")
    |> render_click()

    view
    |> form("#name-form", %{name: "bilbo"})
    |> render_submit()

    assert has_element?(view, "#name", "bilbo")
  end

  test "users can edit their email", %{conn: conn} do
    user = create_user(%{email: "frodo@hobitton.com"})
    {:ok, view, _html} = live(conn, ~p"/users/#{user}/settings")

    view
    |> element("#email")
    |> render_click()

    view
    |> form("#email-form", %{email: "frodo@fellowship.com"})
    |> render_submit()

    assert has_element?(view, "#email", "frodo@fellowship.com")
  end

  defp create_user(params \\ %{}) do
    %{email: "random@example.com", name: "randomname"}
    |> Map.merge(params)
    |> User.changeset()
    |> Repo.insert!()
  end
end

Notice we test each field even though both use the same component.

That can feel like duplication, but it’s the trade-off when interactivity must be exercised through the parent LiveView.

Since users only care about behavior on the page, that’s what we optimize these tests for.