Suppose a user’s avatar is an important concept in our application. And we want to write a test to ensure that we’re rendering the avatar on the page. How should we properly test that?
AvatarLive
Take a look at your router. You’ll see we have an AvatarLive mounted on /avatar/:email.
scope "/", RangerWeb do
live "/avatar/:email", AvatarLive
end
Let’s take a look at what that LiveView is doing. Open up lib/ranger_web/live/avatar_live.ex:
defmodule RangerWeb.AvatarLive do
use RangerWeb, :live_view
def render(assigns) do
~H"""
<div class="flex justify-center">
<img class="avatar" src={@avatar_url} />
</div>
"""
end
def mount(params, _, socket) do
avatar_url = Ranger.Gravatar.generate(params["email"])
{:ok, assign(socket, avatar_url: avatar_url)}
end
end
We’re doing the following:
AvatarLivetakes the email param, uses our Gravatar module to generate a Gravatar URL from it, and then sets that as an assign.- For our current tests, it’s not important that it’s a Gravatar URL. We could use something else.
Testing AvatarLive
Open up the corresponding test file in lib/ranger_web/live/avatar_live_test.exs, and let’s write a test.
Suppose we want to make sure the avatar is present on the page. We could write a test like this:
alias Ranger.Gravatar
test "renders avatar for given email", %{conn: conn} do
email = "frodo@shire.com"
avatar_url = Gravatar.generate(email)
{:ok, _view, html} = live(conn, ~p"/avatar/#{email}")
assert html =~ avatar_url
end
- We grab a sample email and generate an expected
avatar_url. - Then, we take a
connstruct and mount the live view. - Finally, we grab the
htmlthat is returned and check if the avatar URL is there.
If we run our test right now, it passes.
mix test test/ranger_web/live/avatar_live_done_test.exs
.
Finished in 0.1 second
1 test, 0 failures, 0 excluded
But our assertion is somewhat imprecise.
It’s not actually checking that the avatar is present. It’s only checking that the avatar URL is present somewhere in the HTML blob.
That is a valid critique of our test. Maybe we’re comfortable enough just knowing that the URL is present. In that case, we don’t need to do anything else.
But what if we want more certainty?
For example, it could be that the URL is somewhere else in the HTML.
<img class="avatar" src="" />
<%= @avatar_url %>
Our test will still pass because the html returned is just giving us the full HTML blob.
A more precise (but brittle) assertion
How can we make more precise assertions so that we know the URL is part of the image tag?
One way to do this – and I’ve seen many people do this – is to copy a large portion of the img tag:
test "renders avatar for given email", %{conn: conn} do
email = "frodo@shire.com"
avatar_url = Gravatar.generate(email)
{:ok, _view, html} = live(conn, ~p"/avatar/#{email}")
avatar = ~s(<img class="avatar" src="#{avatar_url}")
assert html =~ avatar
end
If we run that test, it will pass.
Unfortunately, even though we’re now testing that the avatar is present, our test is now coupled to specifics of the HTML markup in ways we don’t want it to be. Our test is more brittle.
Suppose someone in our team changes the image’s class and adds rounded borders to it.
def render(assigns) do
~H"""
<img class="avatar rounded-lg" src={@avatar_url} />
"""
end
We aren’t changing the behavior we intended to test (that the avatar is on the page). Therefore, our test should not break. But if we change the class, our test will break. That’s what I mean by our test being too coupled to the HTML markup.
How can we make our assertions more specific without causing our test to become brittle?
Introducing has_element?/3
Phoenix.LiveViewTest ships with a has_element?/3 helper that can let us make specific assertions while keeping our tests decoupled.
The has_element?/3 helper takes in our view element, and it takes a CSS selector through which we can target the image with the URL as its source. The function also takes an optional text filter that we won’t use in this test.
Let’s change our assertion to use has_element?/3 helper. Notice how we’ll no longer use the html value returned by live/2 but instead we’ll use the view:
test "renders avatar for given email", %{conn: conn} do
email = "frodo@shire.com"
avatar_url = Gravatar.generate(email)
{:ok, view, _html} = live(conn, ~p"/avatar/#{email}")
assert has_element?(view, ~s(img[src*="#{avatar_url}"]))
end
We use the CSS selector img[src*=value] to match all img elements that contain at least one occurrence of our avatar URL.
Let’s run the test. It passes.
Our test is finally more specific but it is also less brittle.
Using element/3
Since this is the first time we’re using has_element?/3, we might ask, why is it called has_element?
The has_element?/3 helper function can also take an element that is created with the element/3 helper from Phoenix.LiveViewTest.
The element/3 helper takes the same arguments that the has_element?/3 function takes: a view, a CSS selector, and an optional text filter.
Let’s rewrite our test to use the element/3 helper:
test "renders avatar for given email", %{conn: conn} do
email = "frodo@shire.com"
avatar_url = Gravatar.generate(email)
{:ok, view, _html} = live(conn, ~p"/avatar/#{email}")
avatar = element(view, ~s(img[src*="#{avatar_url}"]))
assert has_element?(avatar)
end
The element/3 helper doesn’t find the element on the page. It’s simply creating an %Element{} struct to represent the element we’re trying to find on the page.
Is element/3 helpful?
Given that the element/3 helper is only creating a struct, and we can do the same without it, we might ask, “is it useful?”
We won’t always need to use the element/3 helper. But as we’ll see later, using element/3 has many benefits, especially when combined with render functions.
It also created a clearer test.
Our img CSS selector is a little bit complicated. So, our teammates might not understand what the test is doing at a glance. By naming it avatar we create a test that is easier to read and understand.
So, whenever you need to scope actions or assertions in a page, remember to use the has_element?/3 and element/3 helpers.
Resources
has_element?/3helper: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html#has_element?/3element/3helper: https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html#element/3