Iamvery

The musings of a nerd


Better LiveView Tests

— Oct 19, 2022

In short, I want to take LiveView test from something like this:

 {:ok, index_live, _html} = live(conn, Routes.link_index_path(conn, :index))

 assert index_live |> element("#link-#{link.id} a", "Edit") |> render_click() =~ "Edit Link"

 {:ok, _, html} =
    index_live
    |> form("#link-form", link: @update_attrs)
    |> render_submit()
    |> follow_redirect(conn, Routes.link_index_path(conn, :index))

  assert html =~ "Link updated successfully"
  assert html =~ "some updated description"

To something like this:

start(conn, Routes.link_index_path(conn, :index))
|> click("#link-#{link.id} a", "Edit")
|> assert_html("Edit Link")
|> submit_form("#link-form", link: @update_attrs)
|> assert_html("Link updated successfully")
|> assert_html("some updated description")

I put together some helper functions which you can install with the Hex package https://hex.pm/packages/iamvery to achieve this.

Which do you think is easier to read? It’s not just about line count, it’s also about context switching. Read on.

Background

I’m just getting started with Phoenix LiveView, but thus far I’ve really enjoyed using it. One of the reasons I have enjoyed using LiveView is how it simplifies system testing by avoiding the need for a browser running in many cases. However, I have found the tests somewhat difficult to get my head around.

Here’s a full example test from the live generator.

test "updates link in listing", %{conn: conn, link: link} do
  {:ok, index_live, _html} = live(conn, Routes.link_index_path(conn, :index))

  assert index_live |> element("#link-#{link.id} a", "Edit") |> render_click() =~
           "Edit Link"

  assert_patch(index_live, Routes.link_index_path(conn, :edit, link))

  assert index_live
         |> form("#link-form", link: @invalid_attrs)
         |> render_change() =~ "can't be blank"

  {:ok, _, html} =
    index_live
    |> form("#link-form", link: @update_attrs)
    |> render_submit()
    |> follow_redirect(conn, Routes.link_index_path(conn, :index))

  assert html =~ "Link updated successfully"
  assert html =~ "some updated description"
end

While I do love that this test covers the system from the perspective of an interacting user, it also leaks a bit of underlying detail from the framework. Notably, there are three concepts to keep track of: the views, the rendered HTML documents, and the test conn.

This gets extra confusing depending on the functions being used. For example, the live/2 function returns the view and some rendered HTML which may be useful in different contexts depending on what you intend to do with them. The view is used on the next line to locate and click an element:

 assert index_live |> element("#link-#{link.id} a", "Edit") |> render_click() =~
           "Edit Link"

But the result of the click is used in an assertion in comparison with some text. That is because the render_click/1 function returns an HTML document.

Later on, the conn comes into play when there is a redirect.

|> follow_redirect(conn, Routes.link_index_path(conn, :index))

This toggling between views, html documents, and conns forces line breaks, but more importantly forces the reader to mentally juggle the value in context of each line.

I can imagine this gets a lot easier with experience, but I think we can do better from the start.

Test Helpers

To address the issue of context switching, we can introduce a set of test helpers that handle the context for you. Then, so long as you keep with the pattern, you don’t have to perform the mental gymnastics yourself. This essentially comes down to a set of helper functions that keep the conn, view, and document bundled together and forwarded along for you between steps.

You can find the full set of helpers here, but here are a few to illustrate the example.

def start(conn, path) do
  {conn, live(conn, path)}
end

def assert_html({conn, {:ok, view, html}}, expected_html) do
  assert html =~ expected_html
  {conn, {:ok, view, html}}
end

def click({conn, {:ok, view, _html}}, selector, text, opts \\ []) do
  html =
    view
    |> element(selector, text)
    |> render_click()

  if Keyword.get(opts, :follow, false) do
    {conn, follow_redirect(html, conn)}
  else
    {conn, {:ok, view, html}}
  end
end

As you can see, each helper function receives and returns the conn along with the typical “ok, view” return value from built-in helpers. By implementing a few more helpers in assert_path/2, change_form/3, submit_form/3 we can dramatically refactor the test and eliminate nearly all mention of these bits of context.

test "updates link in listing", %{conn: conn, link: link} do
  start(conn, Routes.link_index_path(conn, :index))
  |> click("#link-#{link.id} a", "Edit")
  |> assert_html("Edit Link")
  |> assert_path(Routes.link_index_path(conn, :edit, link))
  |> change_form("#link-form", link: @invalid_attrs)
  |> assert_html(escape("can't be blank"))
  |> submit_form("#link-form", link: @update_attrs)
  |> assert_html("Link updated successfully")
  |> assert_html("some updated description")
end

With that, you have a clean test written as a pipeline that insulates the reader from the state and relevant context that’s forwarded along.

I’m sure the helpers I have put together are incomplete, and you will have all manner of edge cases to help work out. However, you can tap in at any time to do something novel and you’ll always get {conn, {:ok, live, html}}. For the sake of testing the waters, I’ve published them in my namesake’s Hex package: https://hex.pm/packages/iamvery.

Let me know what you think!