An Introduction to Testing LiveView in Elixir
Tags: elixir
Published: 2021-08-24 11:43:53

In this [two-part series](https://blog.appsignal.com/category/testing-liveview-in-elixir.html), 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](https://pragprog.com/titles/liveview/programming-phoenix-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](https://blog.appsignal.com/images/blog/2021-08/survey-results-age-group-filtered.png)

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:

```elixir
  def update(assigns, socket) do
    {:ok,
     socket
     |> assign(assigns)
     |> assign_age_group_filter()
     |> assign_products_with_average_ratings()
     |> assign_chart_svg()}
  end
```

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:

```elixir
 def assign_age_group_filter(socket) do
  assign(socket, :age_group_filter, "all")
end
```

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:

```elixir
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
```

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:

```elixir
defmodule GamestoreWeb.SurveyResultsLiveTest do
  use ExUnit.Case
  alias GamestoreWeb.SurveyResultsLive
end
```

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:

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

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

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:

```elixir
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
```

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:

```elixir
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
```

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:

```elixir
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
```

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:


```elixir
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
```

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:

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

end
```

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:

```elixir
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
```

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:

```elixir
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
```

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`:

```elixir
defp assert_keys(socket, key, value) do
  assert socket.assigns[key] == value
  socket
end
```

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:

```elixir
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
```

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!](https://blog.appsignal.com/category/elixir-alchemy.html#elixir-alchemy)**

*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](https://twitter.com/sm_debenedetto) or [check out her blog](https://www.thegreatcodeadventure.com/).*