A minimalist LiveView testing guide

Sandi Metz gave an excellent talk about unit testing back in 2013. In it, she talked about what to test, what not to test, and how to assert outcomes so that we can write as few tests as possible while being thorough and keeping our tests fast and stable.

Two by three table with six fields. Top headers refer to message type: Query
or Command. Left column headers refer to message origin: Incoming, Sent to Self,
or Outgoing. Cell 1:1 - Assert result of Incoming Query. Cell 1:2 - Assert
direct public side effects of Incoming Command. Cells in row 2 - Ignore Sent to
Self. Cell 3:1 - Ignore Outgoing Query. Cell 3:2 - Expect to send Outgoing
Command.

Sandi’s minimalist unit testing guide breaks down messages by their origin – whether they’re incoming to the object under test, outgoing from it, or sent to itself – and by whether the messages are commands or queries:

  • Queries: messages that return a result without changing the state of the system
  • Commands: messages that perform side effects but do not return a result

Sandi’s talk uses Ruby, so she talked about method calls, which we should think of as messages in object-oriented languages. But surprisingly (or unsurprisingly), the talk is highly applicable to testing LiveView. After all, as a process, LiveView receives and sends messages.

Incoming Queries

Sandi’s guide shows that we should assert the result of an incoming query. In LiveView, we can send a query message to get the LiveView’s current rendered state:

test "renders the home page", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/")

  assert render(view) =~ "Home"
end

Since LiveView is a process that renders a UI, we tend to interact with the process via the UI. In our test, calling render(view) will send the query message to render the UI. So, we assert some truth about the result.

Incoming Commands

For incoming commands, Sandi suggests we test the direct public side effects. In LiveView, direct public side effects are changes in the rendered state that happen as a response to some action:

test "user can add a todo", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/")

  view
  |> element("a", "Show notes")
  |> render_click()

  assert has_element?(view, ".notes", "Welcome to testing LiveView")
end

The function render_click/1 sends a command message to perform the click action on the “Show notes” link. You can imagine our link having a phx-click attribute attached to it and our LiveView having a handle_event("show-notes", _, socket) callback.

Our action has a direct public side effect – showing the notes – and following Sandi’s guide, we assert that those notes are now visible.

Sending to self

Since LiveView is a process, it can send a message to itself: send(self(), :message). So how do we test those messages? As Sandi states, we shouldn’t test those messages directly because they happen inside the black box. We should only test LiveView through incoming public messages.

If an incoming message causes LiveView to send a query message to itself, we should assert the result of the original incoming message. If it causes LiveView to send a command message to itself, we should assert the direct public side effect by testing the original message. In other words, we should use one of the two methods described above without regard to how LiveView is communicating with itself internally.

But there is one exception where I consider breaking the black box encapsulation: when a LiveView re-renders itself based on a timer. Consider the case when a LiveView is polling a different process or a database every 10 minutes. Should our test wait 10 minutes to assert that the UI has changed? That seems impractical.

Instead, we can mock the timer, inject a fake timer, or we can have our test act as the timer by sending the message:

test "updates notes at an interval", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/")

  # act as the timer by sending the message LiveView sends itself
  send(view.pid, :tick)

  assert has_element(view, ".notes", "These are the updated notes")
end

The test does not cover the scheduling of the timer, so that logic remains untested. Whether or not that’s enough for you depends on your level of confidence on this test and the cost of using an alternative testing strategy.

Outgoing Query

If LiveView makes an outgoing query to another process (or even another collaborator module), Sandi’s guide tells us that we should not explicitly test that. From our test’s perspective, that is happening inside the black box. And the other process should test its own incoming query. We only care about the overall result of our LiveView’s query.

For example, if our LiveView process calls another process or module to fetch the user’s data on mount/3, we shouldn’t test that we’re making that message call or function call. We should only assert the result of having that user in our LiveView:

test "renders current user's email", %{conn: conn} do
  user = insert(:user)

  {:ok, view, _html} = conn |> log_in(user) |> live("/")

  assert render(view) =~ user.email
end

Outgoing Command

Outgoing commands are those messages that have side effects in the world. If our LiveView is directly responsible for sending such a message, we should test that it is sent.

In her talk, Sandi suggests we test outgoing commands with mocks. That is certainly possible in LiveView, but setting up Mox (or something equivalent) for testing LiveView can be cumbersome.

A nice alternative is to make our test process a mock recipient. That is easy if the broadcasting happens via Phoenix.PubSub:

test "broadcasts message when post is liked", %{conn: conn}
  # subscribe to broadcast
  Phoenix.PubSub.subscribe(YourApp.PubSub, "posts")

  post = insert(:post)
  {:ok, view, _html} = live(conn, "/")

  # broadcast happens when we click on the like
  view
  |> element("#like-post-#{post.id}")
  |> render_click()

  # assert the test process received the broadcast
  assert_receive {:post_liked, ^post}
end

We subscribe our test process to the topic, perform our command, and then assert the message was sent by checking that our test received it.

More on testing LiveView

If you liked this post, I’m covering more on testing LiveView effectively in my online course. It’s not ready yet, but you can sign up on that page to hear when it goes live. Happy testing!