Browse lessons

Testing JS Hooks

Testing JavaScript hooks (infinite scroll)

LiveView can do a lot for us. But there are times when we want to interact with JavaScript. For those cases, LiveView exposes hooks.

Phoenix.LiveViewTest comes with helpers that can help us test part of that. Let’s see what we can test.

TimelineLive

If you open your router, you’ll see that we have TimelineLive mounted on /timeline.

If you launch your server and navigate there, you’ll see that our timeline has a list of posts.

By default we’re loading 10 posts at a time. When you go past that, you’ll notice that we automatically load more posts. Our page has infinite scroll-like behavior.

Let’s take a look at the TimelineLive code:

defmodule RangerWeb.TimelineLive do
  use RangerWeb, :live_view

  alias Ranger.Timeline

  def render(assigns) do
    ~H"""
    <ul
      id="posts"
      role="list"
      class="px-6 py-3 rounded-md bg-gradient-to-b from-cyan-100 to-red-100 mx-auto max-w-lg divide-y divide-gray-300"
      phx-update="stream"
    >
      <li :for={{id, post} <- @streams.posts} id={id} class="py-6 space-y-2" data-role="post">
        <div class="font-semibold" data-role="author"><%= post.author %></div>
        <div class="text-lg"><%= post.text %></div>
      </li>
    </ul>

    <div class="py-4 text-center text-lg animate-pulse" id="load-more" phx-hook="InfiniteScroll">
      Loading ...
    </div>
    """
  end

  def mount(_, _, socket) do
    offset = 0
    limit = 10
    posts = Timeline.posts(limit: limit)

    {:ok,
     socket
     |> stream(:posts, posts)
     |> assign(:offset, offset)
     |> assign(:limit, limit)}
  end

  def handle_event("load-more", _, socket) do
    socket =
      socket
      |> update(:offset, fn offset -> offset + socket.assigns.limit end)
      |> stream_new_posts()

    {:noreply, socket}
  end

  defp stream_new_posts(socket) do
    offset = socket.assigns.offset
    limit = socket.assigns.limit
    posts = Timeline.posts(offset: offset, limit: limit)

    Enum.reduce(posts, socket, fn post, socket ->
      stream_insert(socket, :posts, post)
    end)
  end
end

As you can see:

  • In render/1 we render each post in @streams.posts.
  • In mount/3 we seed posts with offset: 0 and limit: 10.
  • We have a loading indicator tied to phx-hook="InfiniteScroll".
  • When the hook sends a "load-more" event, handle_event/3 loads another 10 posts.

Let’s take a look at the JavaScript side of things. Open app.js:

let Hooks = {}

Hooks.InfiniteScroll = {
  mounted() {
    const observer = new IntersectionObserver(entries => {
      const entry = entries[0];

      if (entry.isIntersecting) {
        this.pushEvent("load-more");
      }
    });

    observer.observe(this.el);
  }
}

As you can see, we’re using IntersectionObserver to push an event when the loading indicator intersects the viewport.

Testing TimelineLive

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

  import Phoenix.LiveViewTest

  alias Ranger.Timeline

  test "user can see older posts with infinite scroll", %{conn: conn} do
    [post] = Timeline.posts(offset: 10, limit: 1)
    {:ok, view, _html} = live(conn, ~p"/timeline")

    view
    |> element("#load-more", "Loading ...")
    |> render_hook("load-more")

    assert has_element?(view, "[data-role=post]", post.text)
    assert has_element?(view, "[data-role=author]", post.author)
  end
end

What this does:

  • In setup, we grab the 11th post (offset: 10, limit: 1).
  • We mount with live/2.
  • We target #load-more and perform render_hook("load-more").
  • We assert the new post and author are present.

As you might notice, render_hook/2 is slightly different from other helpers because we pass the event name explicitly.

That’s because the event is coming from JavaScript code, not from a phx-* event in static HTML.

And that is the downside of testing JavaScript hooks in LiveView tests: we can only test the Elixir portion of the hook. If you remove the JavaScript hook code completely, this test will continue to pass.

Is element/3 helpful in this case?

Yes. element/3 still provides safety because it ensures our #load-more element with that selector and text is present in the rendered page.

What if we want to test JS?

Whether or not you want to test the JavaScript portion is up to you.

If the JS hook is playing a vital role (for example, checkout flow), it can be worth introducing end-to-end tooling.

If you’re interested in that, take a look at Wallaby or Hound.