LiveView Integration Tests in Elixir
Tags: elixir
Published: 2021-09-07 12:05:03

In the second part of this [two-part series on testing LiveView in Elixir](https://blog.appsignal.com/category/testing-liveview-in-elixir.html), we'll write an integration test that validates interactions within a single live view, and an integration test that validates the interactions between two separate live views.

You will focus on testing the behavior of the survey results chart filter from the previous post. We'll use the `LiveViewTest` module's functions to simulate LiveView connections without a browser. With the help of this module, your tests can mount and render live views, trigger events, and then execute assertions against the rendered view.

That's the whole LiveView lifecycle.

So let's get going and write some interactive LiveView tests!

## Testing Interactions within a Live View
Our first integration test focuses on the interactions within a single live view. We'll validate the live view's behavior when a user performs some activity on the page. First off, we're going to take a look at the feature that we'll be testing.

## The Feature

The `SurveyResultsLive` component mentioned in the previous post is rendered within a parent live view, `AdminDashboardLive`, that lives at the `/admin-dashboard` route. Here's a refresher of the content displayed by that component:

![survey results chart](https://blog.appsignal.com/images/blog/2021-08/survey-results-age-group-filtered.png)

Here, you see a chart that displays survey results and can be filtered by a given age group.

Our test will simulate a user's visit to `/admin-dashboard`, followed by their filter selection of the `18 and under` age group. The test will verify an updated survey results chart that displays product ratings from users in that age group.

Because components run in their parent's processes, we'll focus our tests on the `AdminDashboardLive` view. `LiveViewTest` helper functions will run our admin dashboard live view and interact with the survey results chart. Along the way, you'll get a taste for the wide variety of interactions that the `LiveViewTest` module allows you to test.

Begin by setting up a LiveView test for the `AdminDashboardLive` view.

## The Test
It's best to segregate unit tests and integration tests into their own modules, so create a new file `test/gamestore_web/live/admin_dashboard_live_test.exs` and define the module with some fixtures, like this:

```elixir
defmodule GamestoreWeb.AdminDashboardLiveTest do
  use GamestoreWeb.ConnCase

  import Phoenix.LiveViewTest
  alias Gamestore.{Accounts, Survey, Catalog}

  @create_product_attrs %{description: "test description", name: "Test Game", sku: 42, unit_price: 120.5}
  @create_user_attrs %{email: "test@test.com", password: "passwordpassword"}
  @create_user2_attrs %{email: "test2@test.com", password: "passwordpassword"}
  @create_user3_attrs %{email: "test3@test.com", password: "passwordpassword"}
  @create_demographic_attrs %{gender: "female", year_of_birth: DateTime.utc_now.year - 15}
  @create_demographic_over_18_attrs %{gender: "female", year_of_birth: DateTime.utc_now.year - 30}

  defp product_fixture do
    {:ok, product} = Catalog.create_product(@create_product_attrs)
    product
  end

  defp user_fixture(attrs \\ @create_user_attrs) do
    {:ok, user} = Accounts.register_user(attrs)
    user
  end

  defp demographic_fixture(user, attrs) do
    attrs =
      attrs
      |> Map.merge(%{user_id: user.id})
    {:ok, demographic} = Survey.create_demographic(attrs)
    demographic
  end

  defp rating_fixture(user, product, stars) do
    {:ok, rating} = Survey.create_rating(%{stars: stars, user_id: user.id, product_id: product.id})
    rating
  end

  defp create_product(_) do
    product = product_fixture()
    %{product: product}
  end

  defp create_user(_) do
    user = user_fixture()
    %{user: user}
  end

  defp create_demographic(user, attrs \\ @create_demographic_attrs) do
    demographic = demographic_fixture(user, attrs)
    %{demographic: demographic}
  end

  defp create_rating(user, product, stars) do
    rating = rating_fixture(user, product, stars)
    %{rating: rating}
  end
end
```

Let's break this down. First, our test module uses the `GamestoreWeb.ConnCase` behavior. This lets us route to live views using the test connection by giving our tests access to a context map with a key of `:conn` pointing to a value of the test connection. Then, import the `LiveViewTest` module to get access to LiveView testing functions.

Lastly, define some fixtures that you'll use to create test data. The nitty-gritty details of those fixture functions aren't important. Just understand that they generate the database records needed to log in users and render the admin dashboard page to display a chart with product ratings.

Now that our module is set up, we'll add a `describe` block to encapsulate the feature we're testing—the survey results chart functionality:

```elixir
describe "Survey Results" do
  setup [:register_and_log_in_user, :create_product, :create_user]

  setup %{user: user, product: product} do
    create_demographic(user)
    create_rating(user, product, 2)

    user2 = user_fixture(@create_user2_attrs)
    create_demographic(user2, @create_demographic_over_18_attrs)
    create_rating(user2, product, 3)
    :ok
  end
  # test coming soon!
end
```

Two calls to `setup/1` seed the test database with a product, users, demographics, and ratings. One of the two users is in the `18 and under` age group, and the other is in a different age group. Then we create a rating for each user.

We're also using a test helper provided for us by the [Phoenix authentication generator](https://github.com/aaronrenner/phx_gen_auth)—`register_and_log_in_user/1`. This function creates a `conn` struct with a logged-in user, a necessary step because visiting the `/admin-dashboard` route requires an authenticated user.

Now that our setup is complete, define the test:

```elixir
describe "Survey Results" do
  # ...
  test "it filters by age group", %{conn: conn} do
  end
end
```

Before writing the body of our test, make a plan. To test this feature, we need to:

1. Mount and render the live view.
2. Find the age group drop-down menu and select an item from it.
3. Assert that the re-rendered survey results chart has the correct data and markup.

This is the pattern you'll always apply to testing live view features: run the live view, simulate some interaction, then validate the rendered result. This pattern should sound familiar—it neatly matches up to the three-step testing process we've been using so far:

1. Set up preconditions
2. Provide input
3. Validate your expectations

Begin with the first step: mounting and rendering the LiveView. Call the `LiveViewTest.live/2` function, which takes in the test `conn` and spawns a simulated live view process:

```elixir
test "it filters by age group", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/admin-dashboard")
end
```

The call to `live/2` returns a three-element tuple with `:ok`, the LiveView process, and the rendered HTML returned from the live view's call to `render/1`. We don't need to access that HTML in this test, so ignore it.

Components run in their parent's process. That means the test *must* start up the `AdminDashboardLive` view rather than rendering _just_ the `SurveyResultsLive` component. By spawning the `AdminDashboardLive` view, we're _also_ rendering the components of the view.

So, by interacting with the `view` variable representing the `AdminDashboardLive` process above, we'll interact with elements within the `SurveyResultsLive` component and test that it behaves appropriately in response to events. This is the correct way to test LiveView component behavior within a live view page.

The test has a running live view now, so provide your input by selecting the `18 and under` age filter. This will require two steps:

1. Find the age group drop-down menu
2. Choose an item from it

Use the `LiveViewTest.element/3` function to find the age group drop-down on the page.

Assuming the drop-down menu HTML form element has an ID of `age-group-filter`, you can target it with `element/3` like this:

```elixir
test "it filters by age group", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/admin-dashboard")
  html =
    view
    |> element("#age-group-form")
end
```

`element/3` returns `Phoenix.LiveViewTest.Element` struct that we can pipe into another `LiveViewTest` function in order to simulate the selection of an item and submission of the form:

```elixir
test "it filters by age group", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/admin-dashboard")
  html =
    view
    |> element("#age-group-form")
    |> render_change(%{"age_group_filter" => "18 and under"})
end
```

The `LiveViewTest.render_change/2` function is one of the functions you'll use to simulate user interactions when testing live views. It takes an argument of the selected element and some params, triggering a `phx-change` event. Here, make sure to call `render_change/2` with the exact params that would be sent to the live view when the user selects an age group filter in the UI.

This event will trigger the associated handler, invoking the reducers that update our socket, and re-rendering the survey results chart with the filtered product rating info.

With our setup and input in place, we're ready to write our assertions. The call to `render_change/2` will return the re-rendered template. Add an assertion that the re-rendered chart displays the correct data by validating the presence of an updated title for the product's average rating:

```elixir
test "it filters by age group", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/admin-dashboard")
    view
    |> element("#age-group-form")
    |> render_change(%{"age_group_filter" => "18 and under"})
    |> assert =~ "<title>2.00</title>" # validates that we now display an average rating of 2.00
end
```

Once again, the details of our assertion aren't important. Just understand that when the product ratings are filtered by age group, you can expect to see the element on the page: `<title>2.00</title>`.

And with that, our first integration test is complete! The `LiveViewTest` module provided everything we needed to mount and render a connected live view, target elements within that live view—even elements nested within child components—and assert the state of the view after firing DOM events against those elements.

The test code is clean and elegantly composed with a simple pipeline. All of it is written in Elixir with ExUnit and `LiveViewTest` functions—we didn't need to bring in any JavaScript dependencies. As a result, writing our test was a straightforward process. We ended up with a reliable test that runs fast and is easy to read.

This is only a small subset of the `LiveViewTest` functions that support LiveView testing, but there are many more `LiveViewTest` functions that allow you to send any number of DOM events—blurs, form submissions, live navigation, and more. [Learn more about them in the docs](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveViewTest.html).

Before we go, let's write one more integration test; this time, to exercise interactions between live views.

## Testing Distributed Updates in LiveView

Testing message passing in a distributed application can be painful, but `LiveViewTest` makes it easy to test the PubSub-backed real-time features that you can build into your live views. That is because LiveView tests interact with views via process communication. Since PubSub uses simple Elixir message passing, it's easy to test a live view's ability to handle such messages: use `send/2`.

In this section, we'll write an integration test that validates the behavior of the `AdminDashboardLive` when it receives a specific message over PubSub.

## The Feature
Our `AdminDashboardLive` supports the following real-time update feature: when a user anywhere in the world submits a new product rating, then the survey results chart on the admin dashboard updates accordingly, in real-time.

The following code flow backs this:

- When a user submits a product rating, then a PubSub event, `"rating_created"` is broadcast over a topic.
- The `AdminDashboardLive` view subscribes to that topic and responds to the event by re-rendering the `SurveyResultsLive` component with updated data from the database.

The details of the code aren't important for our purposes today—a high-level understanding is all that's needed to write our test. Let's get started.

## The Test
Like in our unit test earlier, you can group similar test cases in a single describe block. We already have a `describe` block in our integration test module for `"Survey Results"`. Since the test of the real-time update feature also describes the behavior of the `SurveyResultsLiveComponent`, we'll add another test case to this same describe block:

```elixir
describe "Survey Results" do
  # ...
  test "it updates to display newly created ratings", %{conn: conn, product: product} do
end
```

This time, our test case retrieves both the test `conn` and the `product` from the setup context. Use this product to create a new rating for display.

Once again, before filling in the body of our test, make a plan. Follow the same three-step process you used for your earlier integration test:

1. Mount and render the connected live view.
2. Interact with that live view—in this case, by sending the `"rating_created"` message to the live view.
3. Re-render the view and verify changes in the resulting HTML.

Start by mounting and rendering the live view with the `live/2` function:

```elixir
test "it updates to display newly created ratings", %{conn: conn, product: product}
  {:ok, view, html} = live(conn, "/admin-dashboard")
  assert html =~ "<title>2.50</title>"
end
```

Here, we add an intermediate assertion to check the starting state of the product ratings label. Expect to change this value once you create a new rating and send the `"rating_created"` message to the live view.

Next up, provide your input (a two-step process). First, create a new rating for the product:

```elixir
test "it updates to display newly created ratings", %{conn: conn, product: product}
  {:ok, view, html} = live(conn, "/admin-dashboard")
  assert html =~ "<title>2.50</title>"

  # create a new user + demographic and then create a new rating with those records
  user3 = user_fixture(@create_user3_attrs)
  create_demographic(user3)
  create_rating(user3, product, 3)
end
```

Then, send the `"rating_created"` message to the live view:

```elixir
test "it updates to display newly created ratings", %{conn: conn, product: product}
  {:ok, view, html} = live(conn, "/admin-dashboard")
  assert html =~ "<title>2.50</title>"

  # create a new user + demographic and then create a new rating with those records
  user3 = user_fixture(@create_user3_attrs)
  create_demographic(user3)
  create_rating(user3, product, 3)

  # send the message to the live view
  send(view.pid, %{event: "rating_created"})
end
```

Here, use a simple `send/2` to mimic the code flow of PubSub broadcasting the `"rating_creating"` message. Our live view should respond by re-rendering the `SurveyResultsLive` component with fresh data from the DB, including the newly created rating. All we need to do now is add our assertion:

```elixir
test "it updates to display newly created ratings", %{conn: conn, product: product}
  {:ok, view, html} = live(conn, "/admin-dashboard")
  assert html =~ "<title>2.50</title>"

  # create a new user + demographic and then create a new rating with those records
  user3 = user_fixture(@create_user3_attrs)
  create_demographic(user3)
  create_rating(user3, product, 3)
  # send the message to the live view
  send(view.pid, %{event: "rating_created"})
  # give the live view time to re-render
  :timer.sleep(2)
  assert render(view) =~ "<title>2.67</title>"
end
```

And that's it! You:

- Established your initial state by mounting and rendering the live view with the call to `live/2`
- Provided some input by creating a new rating record and sending a message to the live view with `send/2`
- Validated your expectations by asserting that the re-rendered view had some expected content.

Our three-step LiveView testing procedure neatly applies to both integration tests that exercise internal live view behavior, and tests that validate the interactions between live view processes.

Now for wrapping up.

## Wrap Up: Write Robust and Comprehensive LiveView Tests

LiveView empowers you to write robust and comprehensive tests without a huge investment of engineering effort.

In the previous post, we comprised our individual live views from pipelines of single-purpose reducers. This provided opportunities for deep unit testing to quickly cover lots of scenarios and edge cases. You can even use the same elegant reducer pipelines in your tests to verify the behavior of your live view pipelines.

This article showed that the `LiveViewTest` module provides all the functionality you need to exercise the full range of LiveView interactions in integration tests. We can use the functions in the `LiveViewTest` module to apply the same three-step process that guides all of our tests, making it quick and easy to spin up tests for even complex LiveView interactions.

LiveView is built on top of OTP, and a live view is nothing more than a process. This means you can easily test interactions _between_ live views by relying on simple message passing.

The powerful set of tools in your LiveView testing kit is just one of the many reasons that teams can be so productive in LiveView. You and your team can guarantee comprehensive test coverage for your LiveView applications, ensuring that you move quickly while maintaining bug-free code.

**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/).*