Browse lessons

Testing Interactions

Testing a Counter

We’ll test a canonical example of interaction: a counter.

In our router, you should see that we have a CounterLive mounted under /counter.

# lib/ranger_web/router.ex
live "/counter", CounterLive

CounterLive

defmodule RangerWeb.CounterLive do
  use RangerWeb, :live_view

  def render(assigns) do
    ~H"""
    <div class="max-w-xs mx-auto flex justify-center space-x-10 border-2 p-8">

      <.button id="decrement" phx-click="decrease" class="bg-red-500 hover:bg-red-700" type="button">
        <svg
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke-width="1.5"
          stroke="currentColor"
          class="w-6 h-6"
        >
          <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
        </svg>
      </.button>

      <div id="count" class="font-extrabold text-3xl"><%= @count %></div>

      <.button id="increment" phx-click="increase" class="bg-blue-500 hover:bg-blue-700" type="button">
        <svg
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke-width="1.5"
          stroke="currentColor"
          class="w-6 h-6"
        >
          <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
        </svg>
      </.button>
    </div>
    """
  end

  def mount(_, _, socket) do
    {:ok, assign(socket, :count, 0)}
  end

  def handle_event("increase", _, socket) do
    {:noreply, update(socket, :count, fn count -> count + 1 end)}
  end

  def handle_event("decrease", _, socket) do
    {:noreply, update(socket, :count, fn count -> count - 1 end)}
  end
end

Our CounterLive holds a simple counter in its state. We can increment it or decrement it. And the LiveView renders buttons to decrement and increment it as well as showing the current counter.

Initial test

Let’s go ahead and open the test for it in test/ranger_web/live/counter_live_test.exs:

defmodule RangerWeb.CounterLiveTest do
  use RangerWeb.ConnCase

  import Phoenix.LiveViewTest
end

Let’s add our first test, user can increase counter.

The exercise is simple, but it’ll help us get acquainted with LiveViewTest’s tools. We’ll use our traditional setup with live/2, and we’ll use the view since we want to interact with the page before making assertions:

test "user can increase counter", %{conn: conn} do
  {:ok, view, _} = live(conn, ~p"/counter")
end

Then, we want to use two LiveView helpers to target an element on the page and click on it:

  • Phoenix.LiveViewTest.element/3 takes three arguments: a view, a CSS selector, and an optional text filter (to scope whatever action we take to that element).
  • render_click/3 is what I call an action function. It performs an action on the view or on a scoped element of the view. There are many such functions like render_blur, render_keydown, render_submit, etc. We’ll look into some of those in future videos. All render_* functions return the HTML string that is rendered after the action is done.

Let’s put those into practice.

Grab the view, pass it to the element/3 helper, and let’s target an element with a #increment ID. Then let’s click on it.

# test/ranger_web/live/counter_live_test.exs
test "user can increase counter", %{conn: conn} do
  {:ok, view, _} = live(conn, ~p"/counter")

  view
  |> element("#increment")
  |> render_click()
end

Once we’ve done that, let’s assert that the HTML that is rendered by that has a count of "1":

test "user can increase counter", %{conn: conn} do
  {:ok, view, _} = live(conn, ~p"/counter")

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

  assert has_element?(view, "#count", "1")
end

Let’s run the test:

mix test test/ranger_web/live/counter_live_test.exs
.
Finished in 0.1 second

1 test, 0 failures, 0 excluded

Excellent.

But before we move on, let’s talk a little bit more about two alternative ways of writing this test (and their downsides): changing the assertion and changing the action function.

Alternative assertion

Each render function returns an HTML blob. So, we could have technically written our test like this:

test "user can increase counter", %{conn: conn} do
  {:ok, view, _} = live(conn, ~p"/counter")

  html =
    view
    |> element("#increment")
    |> render_click()

  assert html =~ "1"
end

If we run that test, it passes. So why not do that? It seems simpler. As we’ve discussed in a previous lesson, using the has_element?/3 helper allows us to have more precise assertions.

When we write assert html =~ "1" we’re simply asserting there’s a "1" anywhere in our HTML blob. Do you have an <h1> tag? That test will pass incorrectly.

You can see it in iex:

iex(1)> "<h1>" =~ "1"
true

Let’s confirm that by removing the @count from our CounterLive and adding an h1 heading:

+   <h1>Counter</h1>

    <div class="max-w-xs mx-auto flex justify-center space-x-10 border-2 p-8">

      <.button id="decrement" phx-click="decrease" class="bg-red-500 hover:bg-red-700" type="button">
        <svg
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke-width="1.5"
          stroke="currentColor"
          class="w-6 h-6"
        >

          <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
        </svg>
      </.button>

-
-     <div id="count" class="font-extrabold text-3xl"><%= @count %></div>

      <.button id="increment" phx-click="increase" class="bg-blue-500 hover:bg-blue-700" type="button">
        <svg
          xmlns="http://www.w3.org/2000/svg"
          fill="none"
          viewBox="0 0 24 24"
          stroke-width="1.5"
          stroke="currentColor"
          class="w-6 h-6"
        >
          <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
        </svg>
      </.button>
    </div>

If you run the test now, it passes because it finds the "1" in h1.

That is why our has_element?/3 helper is so valuable.

Alternative action function

Before we finish this lesson, I want to talk about an alternative version of the render_click function.

If you look at the docs, render_click has two varieties: render_click/3 and render_click/2.

Thus far, we’ve been using render_click/2 where we pass an element to it. But we could also use render_click/3, which takes in the view, the event, and an optional value directly.

Let’s write the test like that:

test "user can increase counter", %{conn: conn} do
  {:ok, view, _} = live(conn, ~p"/counter")

  render_click(view, "increase")

  assert has_element?(view, "#count", "1")
end

As you can see, we can render the "increase" event. (Note that it’s different from our #increment ID.)

Instead of using element/3 to find an element with #increment and clicking on it, we can instead call render_click/3 directly passing the event and the value.

In this case, we don’t have a value to pass, so we simply call render_click(view, "increase").

Seems simpler, right?

So you may rightly wonder, why not use this test?

The problem is that this test is bypassing our HTML and sending the event directly to our CounterLive.

Let’s confirm that by removing the + button from our HTML.

# lib/ranger_web/live/counter_live.ex
<div class="max-w-xs mx-auto flex justify-center space-x-10 border-2 p-8">
  <.button id="decrement" phx-click="decrease" class="bg-red-500 hover:bg-red-700" type="button">
    <svg
      xmlns="http://www.w3.org/2000/svg"
      fill="none"
      viewBox="0 0 24 24"
      stroke-width="1.5"
      stroke="currentColor"
      class="w-6 h-6"
    >

      <path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12h-15" />
    </svg>
  </.button>

  <div id="count" class="font-extrabold text-3xl"><%= @count %></div>

-
- <.button id="increment" phx-click="increase" class="bg-blue-500 hover:bg-blue-700" type="button">
-   <svg
-     xmlns="http://www.w3.org/2000/svg"
-     fill="none"
-     viewBox="0 0 24 24"
-     stroke-width="1.5"
-     stroke="currentColor"
-     class="w-6 h-6"
-   >
-     <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
-   </svg>
- </.button>

Now rerun the test.

mix test test/ranger_web/live/counter_live_test.exs

.

Finished in 0.1 seconds

1 test, 0 failures, 0 excluded

It still passes.

The test is misleading us into thinking that a user can increase the count. But our production code will be broken.

To compare, let’s run our test that uses element/3:

mix test test/ranger_web/live/counter_live_done_test.exs:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

  1) test user can increase counter (RangerWeb.CounterLiveDoneTest)
     test/ranger_web/live/counter_live_done_test.exs:6

     ** (ArgumentError) expected selector "#increment" to return a single element, but got none

Finished in 0.2 seconds (0.00s async, 0.2s sync)

4 tests, 1 failure, 3 excluded

It fails because it cannot find an element with "#increment" ID, just like we would expect if we didn’t have the button there.

So, my advice is to use the element/3 helper in combination with render actions unless you have a good reason to try to bypass the HTML markup.

Bonus exercise

If you’re up for it, go ahead and add a test for the - button.

You can see how element/3 helps you by removing different portions of the HTML.

For example, what happens if you remove the entire <button>? What about if you only remove the phx-click attribute?

Resources