Browse lessons

Testing Function Components

AboutLive

If you look in our router.ex, you’ll see that we have an /about page that takes us to the AboutLive live view.

Go ahead and launch your phoenix server and check out the about page.

You’ll see a collection of cards (that’s a component) with a title and body. And you’ll also see a badge on each card (that’s another component). The badges have different colors based on the type of being we’re looking at.

You can see the LiveView HTML for that if you open up AboutLive.

defmodule RangerWeb.AboutLive do
  use RangerWeb, :live_view
  import RangerWeb.AboutComponents

  alias Ranger.FellowshipMember

  def render(assigns) do
    ~H"""
    <div class="grid grid-cols-2 gap-4 space-x-2">
      <%= for  member <- @members do %>
        <.card>
          <:title>
            <span class="text-lg font-bold"><%= member.name %></span>
            <.badge type={member.type} />
          </:title>

          <div>
            <%= member.description %>
          </div>
        </.card>
      <% end %>
    </div>
    """
  end

  def mount(_, _, socket) do
    members = fellowship_characters()

    {:ok, assign(socket, members: members)}
  end

  defp fellowship_characters do
    [
      FellowshipMember.new(
        "Frodo",
        "hobbit",
        "The main protagonist of The Lord of the Rings, a Hobbit of exceptional character."
      ),
      FellowshipMember.new(
        "Sam",
        "hobbit",
        "The former gardener at Bag End and Frodo's indomitable servant throughout his quest."
      )
      # ... other members
    ]
  end
end

As you can see, we’re rendering a list of members of the Fellowship of the Ring with their names, types, and descriptions.

If you look at the mount/3 function, you’ll see that we’re simply populating that members assign from a static list of fellowship members.

AboutComponents

But what we’re after today is the <.card> and <.badge> components you see in our render/1 function. Those are coming from our AboutComponents module.

Let’s open that up. It lives in lib/ranger_web/components/about_components.ex:

defmodule RangerWeb.AboutComponents do
  use Phoenix.Component

  attr :type, :string,
    values: ["hobbit", "wizard", "human", "elf", "dwarf"],
    required: true

  def badge(assigns) do
    ~H"""
    <span class={["px-2 py-1 rounded-lg text-sm", colors_from_badge_type(@type)]}>
      <%= @type %>
    </span>
    """
  end

  defp colors_from_badge_type(type) do
    %{
      "hobbit" => "bg-green-700 text-green-100",
      "human" => "bg-blue-700 text-blue-100",
      "dwarf" => "bg-yellow-700 text-yellow-100",
      "wizard" => "bg-red-700 text-red-100",
      "elf" => "bg-gray-700 text-gray-100"
    }[type]
  end

  slot :title, required: true
  slot :inner_block, required: true

  def card(assigns) do
    ~H"""
    <div class="border-2 p-4 rounded-lg max-w-sm">
      <div class="text-sm border-b-1 mb-4 flex flex-row justify-between align-center">
        <%= render_slot(@title) %>
      </div>

      <div>
        <%= render_slot(@inner_block) %>
      </div>
    </div>
    """
  end
end

As you can see, we have the two components defined: badge and card.

  • The badge component takes in a type and renders that as the badge text. But the more interesting part is that it renders different text and background colors based on the type.
  • The card component has two slots: a named slot for the title and a default slot that uses inner_block.

Let’s see how to test those components.

Testing about components

Open up your test file in test/ranger_web/components/about_components_test.exs.

There you should see a little setup:

  • We’re importing Phoenix.Component.
  • We’re importing Phoenix.LiveViewTest to get all of our live view test helpers.
  • And we’re importing the components module.

Alright, let’s write our first test.

Testing with render_component/2

Let’s test that the badge renders the type as text:

describe "badge" do
  test "renders content in badge (render_component)" do
    component = render_component(&badge/1, type: "hobbit")

    assert component =~ "hobbit"
  end
end
  • We use the render_component/2 helper that comes from Phoenix.LiveViewTest.
  • We pass the captured component function as the first argument and the assigns.
  • We assert that the word "hobbit" is somewhere in the HTML blob that render_component/2 returned.

Before we analyze the test too much, let’s take a look at another way to write the test.

Testing with ~H + rendered_to_string/1

Instead of using render_component/2, we can also use the ~H sigil directly.

In fact, that’s what LiveView’s docs suggest since components can get more complex. So let’s write the same test but with HEEX:

test "renders content in badge (HEEX)" do
  assigns = %{}

  component =
    rendered_to_string(~H"""
    <.badge type="hobbit" />
    """)

  assert component =~ "hobbit"
end

There’s a few interesting things about this test:

  • We need an assigns map defined in the context of the test. Without that, our test will fail.
  • We use the ~H sigil directly, which we imported from Phoenix.Component, and it’s also the reason why we need the assigns map.
  • We use rendered_to_string/1 instead of the render_component/2 helper.

As mentioned before, this is the preferred approach of testing function components. It allows us to more easily test complex components that have slots and things of that nature.

Is the test valuable?

Now, as we’ve seen with other HTML assertions, these tests can be brittle if we’re too specific about the HTML structure being returned.

Or they can be too vague.

In this case, I only care that the type is somewhere in there. And I prefer not to specify much about where in the HTML that lives.

So, our test is a little vague, but there’s not much we can do about it.

That brings up an important question: is this test valuable enough to exist?

All tests have a cost. They are a maintenance burden. So, we have to ask ourselves if the test is worth it.

In this case, I find the test of very little value. That’s highlighted even more by the fact that Phoenix components have the attr declarations which allow us to set the required: true flag.

Take a look at that in AboutComponents:

attr :type, :string,
  values: ["hobbit", "wizard", "human", "elf", "dwarf"],
  required: true

def badge(assigns) do
  # ...
end

Because we have the required: true, the compiler will warn us if we ever try to use that component without passing the type.

So, I won’t say the test has no value, but you may consider whether or not you want to keep that test around in your codebase.

Testing conditional logic

But there’s something else we might want to test about our badge component.

Anytime we have conditional logic in our application, it makes me think it might be worth testing the different paths. Function components aren’t different.

If you look at the badge component, you’ll see that one of our CSS classes is computed by the colors_from_badge_type/1 private function:

defp colors_from_badge_type(type) do
  %{
    "hobbit" => "bg-green-700 text-green-100",
    "human" => "bg-blue-700 text-blue-100",
    "dwarf" => "bg-yellow-700 text-yellow-100",
    "wizard" => "bg-red-700 text-red-100",
    "elf" => "bg-gray-700 text-gray-100"
  }[type]
end

It’s a simple function, but that’s one of the main responsibilities of the badge. Let’s see how we can test that:

test "renders blue badge for humans" do
  assigns = %{}

  component =
    rendered_to_string(~H"""
    <.badge type="human" />
    """)

  assert component =~ "bg-blue"
end

We can write a test that checks that the "bg-blue" string is in the component when we pass the "human" type. And we can write a similar test for each of the different types:

test "renders green badge for humans" do
  assigns = %{}

  component =
    rendered_to_string(~H"""
    <.badge type="hobbit" />
    """)

  assert component =~ "bg-green"
end

test "renders red badge for wizards" do
  assigns = %{}

  component =
    rendered_to_string(~H"""
    <.badge type="wizard" />
    """)

  assert component =~ "bg-red"
end

test "renders gray badge for elves" do
  assigns = %{}

  component =
    rendered_to_string(~H"""
    <.badge type="elf" />
    """)

  assert component =~ "bg-gray"
end

test "renders yellow badge for dwarves" do
  assigns = %{}

  component =
    rendered_to_string(~H"""
    <.badge type="dwarf" />
    """)

  assert component =~ "bg-yellow"
end

Notice how we continue to keep our tests as decoupled from the HTML structure as possible.

  • We’re not asserting that those are CSS classes, even though we assume they are.
  • We also don’t specify the full type of color (e.g. "bg-blue-700").
  • And we don’t specify all the things that change conditionally (e.g. the text color also changes to "text-blue-100").

Instead, we try to write a test that:

  • exercises our conditional logic,
  • makes sense from the consumer of the component API perspective (e.g. “I pass human, it comes out blue”), and
  • leaves us as decoupled from the HTML as possible.

Are these tests valuable?

Now, we may once again ask the question, are these tests valuable?

I think that in this case they are somewhat valuable. As mentioned before, we’re testing the conditional logic.

Granted, I’m not the biggest fan of testing CSS classes, but if you’re creating a library of components, you’d probably want to make sure that those types render the correct colors.

Still, I grant you that the tests might be lower value than others in your application. Sometimes we have to make judgment calls.

Whatever you decide, I suggest asking the question: is this test testing the behavior of my function/module/component? Or is it testing its implementation?

We always want to test the behavior of our code, not the implementation. In this case, I think our tests have tested the behavior of components (e.g. “human” type renders blue).

Bonus: test the card component

As a bonus, go ahead and try to test the card component. It uses slots, which you may think makes it more important to test. But try to apply the same logic we did above.

You can see how I might test the card component in the done version of the tests found in test/ranger_web/components/about_components_done_test.exs.