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.
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!