View Source Testing

Elixir comes with a powerful testing framework called ExUnit. It makes writing unit tests incredibly simple. For end-to-end tests, the go-to library is Wallaby, but if you use LiveView, you should be able to write most of your integration tests in ExUnit without having to start a browser. Let's have a look at how to use ExUnit and Wallaby for our tests.

Unit Tests

Unit tests in ExUnit usually have the following structure and are stored in the test folder. They need to have the *_test.exs file ending, otherwise the test runner will ignore them.

# To run the tests, you need to start ExUnit first.
# In your application, this is usually executed in the "test/test_helper.exs" file.
ExUnit.start()

# lib/example.ex
defmodule RunElixir.Example do
  @moduledoc "The module we want to test."

  def div(nominator, denominator), do: nominator / denominator
end

# test/example_test.exs
defmodule RunElixir.ExampleTest do
  # Defines this module as a test case and instructs the test runner
  # to run the tests in parallel with other tests.
  use ExUnit.Case, async: true

  alias RunElixir.Example

  # A setup callback that runs before every test.
  #
  # You can also use `setup_all` to run a test setup
  # only once for all tests.
  setup do
    # The "context" map returned by the setup and setup_all callbacks
    # is passed to every describe and test block as an argument.
    %{number: 2}
  end

  # descibe/1 blocks are useful for wrapping tests for a function or feature
  describe "div/2" do

    # describe blocks can have their own setup and setup_all callbacks
    # which run only for the tests inside the describe-block
    setup %{number: number} do
      # You can modify the context map if you want.
      %{number: number + 2, divisor: 4}
    end

    test "returns a fraction", %{number: number, divisor: divisor} do
      # assert expects a "truthy" value and otherwise fails the test.
      assert Example.div(number, divisor) == 0.5
    end

    test "throws an exception if divided by Zero", %{number: number} do
      # You can assert many different scenarios.
      # Check the `ExUnit.Assertions` block for all options.
      assert_raise ArithmeticError, fn ->
        Example.div(number, 0)
      end
    end
  end
end

# You could run the tests with "mix test" in your terminal.

# Alternatively, you can execute tests from an IEx session like this:
ExUnit.run()
Running ExUnit with seed: 576563, max_cases: 20

.

  1) test div/2 see how a test fail looks like (RunElixir.ExampleTest)
     Library/Application Support/livebook/autosaved/2024_09_27/10_48_dvyj/untitled_notebook.livemd#cell:hpn2l3c23pevgamx:55
     Assertion with == failed
     code:  assert Example.div(2, 4) == 1.0
     left:  0.5
     right: 1.0
     stacktrace:
       Library/Application Support/livebook/autosaved/2024_09_27/10_48_dvyj/untitled_notebook.livemd#cell:hpn2l3c23pevgamx:56: (test)

..
Finished in 0.00 seconds (0.00s async, 0.00s sync)
4 tests, 1 failure

Integration Tests with LiveView

You can write powerful unit tests with ExUnit and easily extend them to integration tests using the LiveView test helpers.

If you generated the ToDo app in chapter Create a Phoenix Project, Phoenix generated a complete test setup and some unit tests for you. You can find the unit tests in test/demo/to_dos_test.exs and test/demo_web/live/to_do_live_test.exs and the test setup in test/test_helper.exs and test/support/(data|conn)_case.ex.

Let's have a look at the "saves new to_do" unit test in test/demo_web/live/to_do_live_test.exs:

defmodule DemoWeb.ToDoLiveTest do
  # This imports common test functions like our assertions,
  # creates a sandboxed database connection, and builds a connection
  # that we can use to load the website.
  # Since our database connections are sandboxes, we can run tests in parallel.
  # That's what the async: true flag does here. If this was async: false,
  # the tests in this file would run sequentially, not in parallel.
  use DemoWeb.ConnCase, async: true

  # These import our LiveView-specific test helpers and assertions
  # and our "fixtures" with which we can quickly create a
  # ToDo database record.
  import Phoenix.LiveViewTest
  import Demo.ToDosFixtures

  @create_attrs %{name: "some name", done: true, deadline: "2024-09-22T12:12:00Z"}
  @invalid_attrs %{name: nil, done: false, deadline: nil}

  # This defines a setup callback we can execute before each test execution.
  # It inserts a ToDo database record and returns a map that is merged into
  # the context map which our tests receive as an argument.
  defp create_to_do(_) do
    to_do = to_do_fixture()
    %{to_do: to_do}
  end

  # We use 'describe' to wrap a group of tests, in this case
  # we wrap all tests related to the Index LiveView which lists all ToDos.
  describe "Index" do
    # We can define one or multiple setup callbacks that are executed
    # before each test unit using `setup`. We could also use `setup_all` if
    # we wanted to execute the callbacks only once for all tests.
    setup [:create_to_do]

    # Each test needs a name and might receive a context map as a second parameter.
    # The context map can be populated in test setup callbacks like `create_to_do/1`.
    test "saves new to_do", %{conn: conn, to_do: to_do} do
      # First, we navigate to the "List ToDos" route and receive a LiveView connection
      # and the rendered html as return values.
      {:ok, index_live, _html} = live(conn, ~p"/todos")

      # Next, we click on the "New To do" button which should open up a modal.
      assert index_live |> element("a", "New To do") |> render_click() =~
               "New To do"

      # We assert that the click on the button patch-navigated us to the modal-showing route.
      # Since it's a patch navigation, we don't reload the LiveView, but only execute the
      # handle_params/3 callback in the LiveView.
      assert_patch(index_live, ~p"/todos/new")

      # We try to insert invalid parameters and assert that
      # the form shows us a "can't be blank" form error.
      assert index_live
             |> form("#to_do-form", to_do: @invalid_attrs)
             |> render_change() =~ "can't be blank"

      # We fill out the form with valid attributes and submit the form
      # which should create a new ToDo database record.
      assert index_live
             |> form("#to_do-form", to_do: @create_attrs)
             |> render_submit()

      # We assert that we patch-navigate back to the "List ToDos" route
      # after we submitted the form.
      assert_patch(index_live, ~p"/todos")

      # We re-render the page to assert that we see a Flash message
      # and that the name of the new ToDo shows on the page.
      html = render(index_live)
      assert html =~ "To do created successfully"
      assert html =~ "some name"
    end
  end
end

The best thing about running integration tests with LiveView is that you can easily drop down to the database level and check that a record was persisted correctly. Let's extend the test above to test just that.

test "saves new to_do", %{conn: conn, to_do: to_do} do

  # Prior test code omitted ...

  html = render(index_live)
  assert html =~ "To do created successfully"
  assert html =~ "some name"

  # Add the following lines:

  # Fetch all ToDos in the database.
  todos = Demo.ToDos.list_todos()

  # Assert that we now have two ToDos in the database,
  # one from the setup callback and one from submitting the form.
  assert length(todos) == 2

  # Find the new ToDo which we created through the form.
  todo = Enum.find(todos, & &1.id != to_do.id)

  # Assert that all fields were set properly.
  assert todo.name == "some name"
  assert todo.done == true
  assert todo.deadline == ~U[2024-09-22 12:12:00Z]
end

You can run your tests with:

mix test

# Or run the test in only one file
mix test test/demo_web/live/to_do_live_test.exs

# Or run only the test starting in line 26
mix test test/demo_web/live/to_do_live_test.exs:26

# Calculate the test coverage with
mix test --cover

Notes about Unit Tests

  • All test files must end with *_test.exs, otherwise they are not executed.
  • You commonly create test files in the folders: test/app_name/ and test/app_name_web and mirror the folder structure of your app. So, if you want to test the file at lib/demo/to_dos.ex, you'd create a test file at test/demo/to_dos_test.exs.
  • You can also colocate your test and your code files, so lib/demo/to_dos.ex would have its test file at lib/demo/to_dos_test.exs. You only need to add test_paths: ["test", "lib"] to your project options in mix.exs and copy&paste the test/test_helper.exs to lib/test_helper.exs. That will instruct ExUnit to execute any *_test.exs file in your lib folder as well.

End-to-end Tests

For end-to-end testing, Wallaby is a popular choice in the Elixir community. It allows you to write tests that simulate user interactions in a real browser, which can be especially useful for testing complex UI interactions or scenarios that are difficult to simulate with LiveView tests alone.

Explaining how to set up and use Wallaby is a bit too complex for this guide, so please have a look at their official documentation.