LiveView Integration Tests in Elixir

Sophie Sophie DeBenedetto on

In the second part of this two-part series on testing LiveView in Elixir, 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

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 generatorregister_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:

1
2
3
4
5
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:

1
2
3
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:

1
2
3
4
5
6
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:

1
2
3
4
5
6
7
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:

1
2
3
4
5
6
7
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.

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:

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:

1
2
3
4
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:

1
2
3
4
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:

1
2
3
4
5
6
7
8
9
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:

1
2
3
4
5
6
7
8
9
10
11
12
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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:

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!

Our guest author 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.

5 favorite Elixir articles

10 latest Elixir articles

Go back
Elixir alchemy icon

Subscribe to

Elixir Alchemy

A true alchemist is never done exploring. And neither are we. Sign up for our Elixir Alchemy email series and receive deep insights about Elixir, Phoenix and other developments.

We'd like to set cookies, read why.