DEV Community

Luiz Cezer
Luiz Cezer

Posted on

Start with unit tests on LiveView modules

I have been working with Phoenix and LiveView in a full-time position for around 18 months now, and most recently LiveView started to become something that I do often.

At the beginning I was unsure about my coding and whether I was doing it correctly on the LiveView modules. Should I keep logic there? Should I create utility functions on the LiveView module? Should I make an external module to handle that?

After these months what use as a rule of thumb is: to deal with the LiveView module the same way I deal with a controller. That means I try to keep the module as clean as possible and only with the needed actions, such as mounting, handling callbacks, and assigning data to the socket.

I try to avoid adding business logic related to product on these modules cause this logics usually require more testing and testing it using LiveView can be tricky and not consider all scenarios.

This will make not only the code simple but also the effort to test tend to be very straightforward.

When checking the LiveView module test, your tests should be readable in a way that basically tells you how the user interacts with the page flow.

Let me start with a very simple and basic example:

defmodule UrlShortenerWeb.ExampleLive.Index do
  use UrlShortenerWeb, :live_view

  def mount(_params, _session, socket) do
    {:ok, socket}
  end
end
Enter fullscreen mode Exit fullscreen mode

In this live view module, there is nothing but the mount function and in the project structure, there is the example_live/index.ex and the example_live/index.html.heex so this way I will not need to use the render function.

The HTML has only a message

<p>Hello Live View</p>
Enter fullscreen mode Exit fullscreen mode

Which will return the following when running the project

Page rendered

So, a simple way to test this basic render would be doing a test like this:

defmodule UrlShortenerWeb.ExampleLive.IndexTest do
  use UrlShortenerWeb.ConnCase, async: true
  import Phoenix.LiveViewTest

  test "must access page and see the initial message", %{conn: conn} do
    {:ok, _view, html} =
      conn
      |> get(Routes.example_index_path(conn, :index))
      |> live()

    assert html =~ "Hello Live View"
  end
end
Enter fullscreen mode Exit fullscreen mode

Here I have nothing to do with the view which is why I ignore it, in this case, I do not wanna test the connected view that has the socket and trigger some page behaviors but only the raw HTML generated.

By running it I got:

First test running

Now I will add a button on the page that, once clicked will hide the previous message and add a new one.

New button in action

I would like to test the following scenario

  • I see the default message
  • I wanna click on the button
  • The old message must disappear
  • The button must disappear
  • A new message must appear

So how can I test this? A simple way would be like this:

test "must update the message when click the button, hide the old message and button", %{
    conn: conn
  } do
    {:ok, view, html} =
      conn
      |> get(Routes.example_index_path(conn, :index))
      |> live()

    assert html =~ "Hello Live View"
    assert html =~ "Click me"

    view
    |> element("#button-click-me")
    |> render_click()

    assert has_element?(view, "p", "New message")
    refute has_element?(view, "p", "Hello Live View")
    refute has_element?(view, "button", "Click me")
  end
Enter fullscreen mode Exit fullscreen mode

The running result:

Image description

In this example we access the page the same way we did in the previous one and check the raw HTML to assert over the button and default text (keep in mind this test is over the text on the page and not the element, so it will return true if finds this text in any place of the page, so pay attention when using it).

After this first check, we simulate the button being clicked by targeting the element using the element/3 https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html#element/3 function and after targeting it we trigger the render_click/2 https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html#render_click/2, the same way a user would do, by pointing to the button and hitting click.

In the end, I want to use the updated version of the page and not the initial static one, I do not use html, instead I use view. If you wanna know more details about the returned tuple from the live/2 call please check the docs

Well, this is a very tiny example but is the core of how to test and manage testing with LiveView modules, it should be simple and act on top of real user actions.

If you see yourself doing much to test LiveView, like heavy business logic assertions, and socket assertions, maybe consider you possibly doing something wrong or adding too much logic inside the LiveView module.

Full example:

HTML

<%= if @update_messages do %>
  <p>New message</p>
<% else %>
  <p>Hello Live View</p>
<% end %>

<%= unless @update_messages do %>
  <button phx-click="update-messages" id="button-click-me">Click me</button>
<% end %>
Enter fullscreen mode Exit fullscreen mode

LiveView:

defmodule UrlShortenerWeb.ExampleLive.Index do
  use UrlShortenerWeb, :live_view

  def mount(_params, _session, socket) do
    socket = assign(socket, :update_messages, false)

    {:ok, socket}
  end

  def handle_event("update-messages", _params, socket) do
    socket = assign(socket, :update_messages, true)

    {:noreply, socket}
  end
end

Enter fullscreen mode Exit fullscreen mode

Testing:

defmodule UrlShortenerWeb.ExampleLive.IndexTest do
  use UrlShortenerWeb.ConnCase, async: true
  import Phoenix.LiveViewTest

  test "must access page and see the initial message and a button", %{conn: conn} do
    {:ok, _view, html} =
      conn
      |> get(Routes.example_index_path(conn, :index))
      |> live()

    assert html =~ "Hello Live View"
    assert html =~ "Click me"
  end

  test "must update the message when click the button, hide the old message and button", %{
    conn: conn
  } do
    {:ok, view, html} =
      conn
      |> get(Routes.example_index_path(conn, :index))
      |> live()

    assert html =~ "Hello Live View"
    assert html =~ "Click me"

    view
    |> element("#button-click-me")
    |> render_click()

    assert has_element?(view, "p", "New message")
    refute has_element?(view, "p", "Hello Live View")
    refute has_element?(view, "button", "Click me")
  end
end

Enter fullscreen mode Exit fullscreen mode

Top comments (2)

Collapse
 
tfantina profile image
Travis Fantina

Good article!
I've gone back and forth on even going a step further and off-loading event handlers into their own functions:


  def handle_event("update-messages", _params, socket), do: update_messages(socket)

 defp update_messages(socket) do 
    socket
    |> assign(:update_messages, true)
    |> noreply()

    # I generally make wrapper functions `noreply/1` and `ok/1` to pipe sockets.
  end
end 
Enter fullscreen mode Exit fullscreen mode

This may seem overly verbose here but when you are handling lots of events and each one could be updating multiple assigns or calling context functions I find it makes things more readable. Although, I've gone back and forth on this precisely because I do feel like it's a little more verbose to write.

Collapse
 
lcezermf profile image
Luiz Cezer

Thanks for the feedback.

Yes, your approach totally fits in the situations where we have multiple callbacks to update pieces of the socket info.