DEV Community

Cover image for An Introduction to Testing LiveView in Elixir
Sophie DeBenedetto for AppSignal

Posted on • Originally published at blog.appsignal.com

An Introduction to Testing LiveView in Elixir

In this two-part series, you'll get a comprehensive overview of everything you need to know to test your LiveView applications in Elixir.

In Part I, I'll introduce you to LiveView testing guidelines and you'll write some flexible and elegant LiveView unit tests. In Part II, you'll write interactive LiveView tests that validate a full set of live view behaviors.

Introducing LiveView's Powerful Testing Tools

If you've worked with LiveView, you've already experienced how productive you and your team can be with a framework that lets you build interactive UIs, while keeping your brain firmly focused on the server-side.

Testing LiveView is no different—you can exercise the full functionality of your live views with pure Elixir tests written in ExUnit through the help of the LiveViewTest module. So your tests are fast, concurrent, and continue to keep your focus firmly on server-side code.

LiveView's testing framework empowers you to quickly write robust and comprehensive tests that are highly stable. With a high degree of test coverage firmly in hand, you and your team will be able to move quickly and confidently when building LiveView applications.

We'll explore more on what makes LiveView testing so powerful, understand the principles for approaching live view tests, and write some unit tests.

In the next post, you'll write more advanced integration tests, and even test a distributed, real-time update feature in LiveView.

Let's get going!

Principles for Testing LiveView in Elixir

The basic principles for testing LiveView don't differ much from testing in other languages and frameworks. Any given test has three steps:

  1. Set up preconditions,
  2. Provide a stimulus, and
  3. Compare an actual response to expectations.

We're going to write two kinds of tests—unit tests and integration tests—and we'll use this procedure for both types. Let's dig a little deeper into what these two kinds of tests look like in LiveView.

Unit Tests in LiveView

A unit test is designed to test an individual unit of code. Pure unit tests call one function at a time and then check expectations with one or more assertions.

Unit tests encourage depth. Such tests don't require much ceremony, so you can cover lots of scenarios quickly and easily. Unit tests also allow loose coupling because they don't rely on specific interactions between different parts of your code or your system.

We'll write some unit tests today to verify the behavior of the independent reducer functions that our live view implements to establish socket state under different conditions.

Integration Tests in LiveView

Integration tests, on the other hand, validate the interactions between different parts of your system. This kind of test offers testing breadth by exercising a wider swath of your application. The cost is tighter coupling since integration tests rely on specific interactions between parts of your system.

We'll write two integration tests that help us verify the behavior of the overall live view. Our first integration test will validate the interactions within a single live view process, and the second will verify a PubSub-backed interaction between two live views.

You might be surprised to hear that none of the tests we'll write in this post will be testing JavaScript. This is because the LiveView framework is specifically designed to handle JavaScript for us. For the average live view that doesn't implement any custom JavaScript, there's no need to test JS code or even to write any tests that use JavaScript. We can trust that the JS in the LiveView framework itself works as expected.

We're just about ready to start writing our very first LiveView test, and we'll start with some unit tests.

In general, it's a good idea to start with unit test coverage before moving on to integration testing. By exercising individual functions in unit tests with many different inputs, you can exhaustively cover corner cases. This lets you write a smaller number of integration tests to confirm that the complex interactions of the system work as you expect them to.

So, to recap, we can apply the following principles to LiveView testing:

  • Follow the three-step procedure of setting up preconditions, providing a stimulus, and validating expectations.
  • Write both unit and integration tests.
  • Don't test JavaScript you didn't write.
  • Start by writing comprehensive unit tests.

Alright, with these principles in hand, we're ready to start testing.

How to Unit Test LiveView

In this section, we'll use our three-step testing procedure to write a few unit tests for the behavior of a LiveView component.

We happen to be testing a LiveView component here, but the tests you'll learn how to write here apply to both components and regular live views.

As you build your tests, we'll look at how composing our LiveView component out of single-purpose reducer functions helps make the code easy to test.

The Feature

The testing examples that we'll be looking at in this post are drawn from the "Test Your Live Views" chapter in my book, Programming LiveView, co-authored with Bruce Tate. Check it out for an even deeper dive into LiveView testing and so much more.

In this example, we have an online game store that allows users to browse and review products. We provide our users with a survey that asks them for some basic demographic input, along with star ratings of each game. A LiveView component, SurveyResultsLive, displays this survey's results as a chart that graphs the games and their star ratings on a scale of 1 to 5. Here's a look at the component UI:

survey results chart

Notice the age group filter drop-down menu at the top of the chart. Users can select an age group by which to filter the survey results. When the page loads, the value of the age group filter should default to "all", and the chart should show all of the results. But when a user selects an age group from the menu, an event will be sent to the live view, and the results will be filtered.

Before we write our test, let's take a look at the code we're testing.

The Code

The update/2 function of the SurveyResultsLive component sets the initial state with the help of some single-purpose reducer functions. A reducer function is a function that takes in some input of some type and returns an updated version of that input.

We can use single-purpose reducer functions that take in a live view socket, add some state to that socket, and return an updated socket, to build nice clean pipelines for managing state in LiveView. Here's our update/2 function:

  def update(assigns, socket) do
    {:ok,
     socket
     |> assign(assigns)
     |> assign_age_group_filter()
     |> assign_products_with_average_ratings()
     |> assign_chart_svg()}
  end
Enter fullscreen mode Exit fullscreen mode

The details of most of these reducer functions are not important, but we will take a closer look at the assign_age_group_filter reducer right now:

 def assign_age_group_filter(socket) do
  assign(socket, :age_group_filter, "all")
end
Enter fullscreen mode Exit fullscreen mode

Our reducer is pretty simple. It adds a key :age_group_filter to the socket and sets it to the default value of "all".

However, we need a second version of this function to call in the handle_event/3 that gets invoked when the user selects an age group from the drop-down menu. This version of the function should take the selected age group from the event and set that as the value in the :age_group_filter key of socket assigns, like this:

def handle_event("age_group_filter", %{"age_group_filter" => age_group_filter}, socket) do
  {:noreply,
    socket
    |> assign_age_group_filter(age_group_filter)
    |> assign_products_with_average_ratings()
    #...
    }
end

def assign_age_group_filter(socket, age_group_filter) do
  assign(socket, :age_group_filter, age_group_filter)
end
Enter fullscreen mode Exit fullscreen mode

Okay, with a basic understanding of this code in place, we're ready to write our test.

The Test

First off, we need to implement our test module in a file, gamestore/test/gamestor_web/live/survey_results_live_test.exs, like this:

defmodule GamestoreWeb.SurveyResultsLiveTest do
  use ExUnit.Case
  alias GamestoreWeb.SurveyResultsLive
end
Enter fullscreen mode Exit fullscreen mode

Here, we're aliasing the SurveyResultsLive component to make it easier to refer to in our tests and we're using the ExUnit.Case behavior to gain access to ExUnit testing functionality. We're not importing any other code or even any other testing utilities since we can write our unit test in pure Elixir without any database interactions.

Next up, we'll establish a fixture that we can share across test cases. Every test will assert some expectations about a LiveView socket. So, we'll add a fixture that returns a socket, like this:

defmodule GamestoreWeb.SurveyResultsLiveTest do
  use ExUnit.Case
  alias GamestoreWeb.SurveyResultsLive

  defp create_socket(_) do
    %{socket: %Phoenix.LiveView.Socket{}}
  end
end
Enter fullscreen mode Exit fullscreen mode

Now that our test module is defined and we've implemented a helper function to create test data (i.e. our socket), we're ready to write our very first test.

We'll start with a test that verifies the default socket state when no age group filter is supplied. Open up a describe block and add a call to the setup/1 function with a call to the helper function that returns a socket struct, like this:

defmodule GamestoreWeb.SurveyResultsLiveTest do
  use ExUnit.Case
  alias GamestoreWeb.SurveyResultsLive

  defp create_socket(_) do
    %{socket: %Phoenix.LiveView.Socket{}}
  end

  describe "Socket state" do
    setup do
      create_socket()
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

This ensures the socket will be available to all of our test cases. Now we're ready to write the test itself.

Create a test block within the describe block that pattern matches the socket out of the context we created in the setup function:

defmodule GamestoreWeb.SurveyResultsLiveTest do
  use ExUnit.Case
  alias GamestoreWeb.SurveyResultsLive

  defp create_socket(_) do
    %{socket: %Phoenix.LiveView.Socket{}}
  end

  describe "Socket state" do
    setup do
      create_socket()
    end

    test "when no age group filter is provided", %{socket: socket} do
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's pause and think through what we're testing here and try to understand what behavior we expect to see. This test covers the function assign_age_group_filter/1. If it's working correctly, the socket should contain a key of :age_group_filter that points to a value of "all".

Now that we understand our expectation, let's finish up the test:

test "when no age group filter is provided", %{socket: socket} do
  socket =
    socket
    |> SurveyResultsLive.assign_age_group_filter()

  assert
    socket.assigns.age_group_filter == "all"
end
Enter fullscreen mode Exit fullscreen mode

Great! We can see that our three-step testing process is represented here like this:

  1. Set up preconditions by calling the setup function to establish our initial socket and make it available to the test case.
  2. Provide the stimulus by calling the SurveyResultsLive.assign_age_group_filter/1 function to return a new socket.
  3. Validate the expectations regarding the new socket's state.

Let's quickly add another test that validates the socket state when an age group filter is provided:

test "when age group filter is provided", %{socket: socket} do
  socket =
    socket
    |> SurveyResultsLive.assign_age_group_filter("18 and under")

  assert
    socket.assigns.age_group_filter == "18 and under"
end
Enter fullscreen mode Exit fullscreen mode

Simple enough. Let's tackle a more complex scenario. Recall that our update/2 and handle_event/3 functions invoke assign_age_group_filter and then pipe the resulting socket into a call to the assign_products_with_average_ratings/1 function.

We won't worry about the details of this function for now. Just know that it queries for the product ratings given the filter info that is set in socket assigns. The composable nature of our single-purpose reducer functions lets us write multi-stage unit tests that exercise the behavior of the reducer pipeline as a whole.

Let's write a test that validates the product ratings assignment under different filter conditions.

Start by defining a new test and providing it with the socket from the setup function, like this:

test "ratings are filtered by age group", %{socket: socket} do

end
Enter fullscreen mode Exit fullscreen mode

First, we'll set the socket state with a call to SurveyResultsLive.assign_age_group_filter/1 and validate that the products with average ratings are set correctly:

test "ratings are filtered by age group", %{socket: socket} do
  socket =
    socket
    |> SurveyResultsLive.assign_age_group_filter()
    |> SurveyResultsLive.assign_products_with_average_ratings()

  assert
    socket.assigns.products_with_average_ratings == all_products_with_ratings # don't worry about the value of `all_products_with_ratings`, just assume its a list of all products with their average ratings
end
Enter fullscreen mode Exit fullscreen mode

Great! Next up, we'll update the socket's :age_group_filter state with the help of the assign_age_group_filter/2 reducer function, pipe the updated socket into another call to assign_products_with_average_ratings/1, and validate another assertion:

test "ratings are filtered by age group", %{socket: socket} do
  socket =
    socket
    |> SurveyResultsLive.assign_age_group_filter()
    |> SurveyResultsLive.assign_products_with_average_ratings()

  assert
    socket.assigns.products_with_average_ratings == all_products_with_ratings # don't worry about the value of `all_products_with_ratings`, just assume its a list of all products with their average ratings

  socket =
    socket
    |> SurveyResultsLive.assign_age_group_filter("18 and under")
    |> SurveyResultsLive.assign_products_with_average_ratings()

  assert
    socket.assigns.products_with_average_ratings == filtered_products_with_ratings # don't worry about the value of `filtered_products_with_ratings`, assume it's a list of all products with their average ratings provided by users in the specified age group
end
Enter fullscreen mode Exit fullscreen mode

This test works, but it's pretty verbose. We can clean it up by taking inspiration from the clean, readable reducer pipelines we used in the SurveyResultsLive component to set state. Let's start by defining a helper function for use in our test, assert_keys:

defp assert_keys(socket, key, value) do
  assert socket.assigns[key] == value
  socket
end
Enter fullscreen mode Exit fullscreen mode

This function takes in a socket, performs a test assertion, and then returns the socket. Since it takes in a socket and returns a socket, it behaves just like a reducer. Now, we can use our helper function to string together a beautiful, clean testing pipeline, like this:

test "ratings are filtered by age group", %{socket: socket} do
  socket
  |> SurveyResultsLive.assign_age_group_filter()
  |> SurveyResultsLive.assign_products_with_average_ratings()
  |> assert_keys(:products_with_average_ratings, all_products_with_ratings)
  |> SurveyResultsLive.assign_age_group_filter("18 and under")
  |> SurveyResultsLive.assign_products_with_average_ratings()
  |> assert_keys(:products_with_average_ratings, filtered_products_with_ratings)
end
Enter fullscreen mode Exit fullscreen mode

Much better. Our usage of small, single-purpose reducer functions not only helped us write a clean, readable, and organized live view, it also helped us quickly write comprehensive unit tests.

Furthermore, we were able to string together a test pipeline that functions just like the real reducer pipeline in our live view. This let us write a unit test that exercised the pipeline's functionality as a whole.

You can easily imagine writing additional test cases for the other reducer functions implemented in the LiveView component, and additional pipeline tests that exercise the behavior of the reducer pipelines used in the update/2 and handle_event/3 functions of our component.

We applied our three-step testing process to end up with clean, readable test cases for both kinds of unit tests.

Next Up

In the next post, we'll leverage this same process to write some integration tests that verify the internal interactions within a single live view and the interactions between live view processes.

Until next time!

P.S. If you'd like to read Elixir Alchemy posts as soon as they get off the press, subscribe to our Elixir Alchemy newsletter and never miss a single post!

Sophie is a Senior Engineer at GitHub, co-author of Programming Phoenix LiveView, and co-host of the BeamRad.io podcast. She has a passion for coding education. Historically, she is a cat person but will admit to owning a dog. You can find her on Twitter or check out her blog.

Top comments (0)