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:
- Set up preconditions,
- Provide a stimulus, and
- 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:
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
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
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
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
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
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
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
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
Great! We can see that our three-step testing process is represented here like this:
- Set up preconditions by calling the
setup
function to establish our initial socket and make it available to the test case. - Provide the stimulus by calling the
SurveyResultsLive.assign_age_group_filter/1
function to return a new socket. - 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
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
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
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
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
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
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)