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
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>
Which will return the following when running the project
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
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:
Now I will add a button on the page that, once clicked will hide the previous message and add a new one.
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
The running result:
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 %>
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
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
Top comments (2)
Good article!
I've gone back and forth on even going a step further and off-loading event handlers into their own functions:
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.
Thanks for the feedback.
Yes, your approach totally fits in the situations where we have multiple callbacks to update pieces of the socket info.