Browse lessons

Testing Forms

TodoLive

If you look at our router, you’ll notice we have a TodoLive mounted on /todo.

# lib/ranger_web/router.ex
live "/todo", TodoLive

Let’s take a look at what TodoLive is doing. Open lib/ranger_web/live/todo_live.ex:

# lib/ranger_web/live/todo_live.ex
defmodule RangerWeb.TodoLive do
  use RangerWeb, :live_view

  alias Ranger.{Repo, Todo}

  def render(assigns) do
    ~H"""
    <ul id="todos" phx-update="append">
      <%= for todo <- @todos do %>
        <li id={"todo-#{todo.id}"} data-role="todo"><%= todo.body %></li>
      <% end %>
    </ul>

    <.simple_form :let={f} for={@changeset} phx-submit="create" id="add-todo">
      <.input field={{f, :body}} />
      <:actions>
        <.button>Create</.button>
      </:actions>
    </.simple_form>

    """

  end

  def mount(_, _, socket) do
    changeset = Todo.changeset(%{})
    todos = Todo |> Repo.all()

    {
      :ok,
      socket
      |> assign(:changeset, changeset)
      |> assign(:todos, todos),
      temporary_assigns: [todos: []]
    }
  end

  def handle_event("create", %{"todo" => params}, socket) do
    case create_todo(params) do
      {:ok, todo} ->
        {:noreply, update(socket, :todos, fn todos -> [todo | todos] end)}

      {:error, changeset} ->
        {:noreply, assign(socket, :changeset, changeset)}
    end
  end

  defp create_todo(params) do
    params
    |> Todo.changeset()
    |> Repo.insert()
  end

end
  • Our LiveView displays a list of existing todo items, and it has a form to create more todos.
  • The form uses a @changeset, and we define the phx-submit="create" attribute so that we can submit the form with Phoenix LiveView.
  • Our "create" event handler is pretty standard. We grab the params, pipe them into Todo.changeset/2 and insert that into the database. We skip usage of context modules for simplicity.

Note: after I recorded this lesson, Phoenix Streams came out. They don’t affect how we test our TodoLive, but if you want to see me change the current implementation to Streams, you can see that in this YouTube video I made https://www.youtube.com/watch?v=2o-2oj4dk_s.

Now let’s see how we test that.

Testing Forms

Open up our test file in test/ranger_web/live/todo_live_test.exs.

Let’s start writing a test in which a user can create a new todo.

To interact with a form, we’ll use Phoenix.LiveViewTest.form/3.

Like the element/3 helper, form/3 takes in a view, a CSS selector, and as a third argument it takes in the form data. The form data has to match how we structure the HTML. In this case, we have a form for a todo with a text field called body. Once we have targeted the form, we submit it with render_submit/2.

Finally, we can write our assertion with our has_element?/3 helper, checking that we have a todo element (notice we target it with "[data-role=todo]") with the correct body.

# test/ranger_web/live/todo_live_test.exs
test "user can create a new todo", %{conn: conn} do
  {:ok, view, _} = live(conn, ~p"/todo")

  view
  |> form("#add-todo", %{todo: %{body: "Form fellowship"}})
  |> render_submit()

  assert has_element?(view, "[data-role=todo]", "Form fellowship")
end

Let’s run our test. It passes.

Alternative tests

As with previous lessons, I want us to examine other ways we could write this test and why I advise against them.

First, we’ll look at how we could avoid using form/3. Then, we’ll see how we could avoid using has_element?/3.

Without form/3 (using render_submit/3)

Another way to write the previous test is to drop the form/3 helper and use the render_submit/3 alternate.

Similar to how render_click/3 worked, render_submit/3 sends the event directly to LiveView, bypassing the HTML. And that means, we can have bad false positives where the test passes but production is broken.

Let’s take a look at it. Update the test to drop form/3 and update render_submit/3 to pass the "create" event directly along with the form data:

test "user can create new todo (send event directly)", %{conn: conn} do
  {:ok, view, _} = live(conn, ~p"/todo")

  view
  |> render_submit("create", %{todo: %{body: "Form fellowship"}})

  assert has_element?(view, "[data-role=todo]", "Form fellowship")
end

The test passes.

Now, let’s delete the HTML form:

     <ul>
       <%= for todo <- @todos do %>
         <li data-role="todo"><%= todo.body %></li>
       <% end %>
     </ul>

-    <.form :let={f} for={@changeset} phx-submit="create" id="add-todo">
-      <%= text_input(f, :body) %>
-      <%= submit("Create") %>
-    </.form>

Rerun the test. It still passes.

As you can see, the form/3 helper is doing a lot for us. By contrast, if we run our test with the form/3 helper, we get an error:

mix test test/ranger_web/live/todo_live_done_test.exs:8
Excluding tags: [:test]
Including tags: [line: "8"]

  1) test user can create a new todo (RangerWeb.TodoLiveTest)
     test/ranger_web/live/todo_live_done_test.exs:6

     ** (ArgumentError) expected selector "#add-todo" to return a single element, but got none within:
         #... large HTML blob
     code: |> render_submit()

     stacktrace:
       (phoenix_live_view 0.18.7) lib/phoenix_live_view/test/live_view_test.ex:1014: Phoenix.LiveViewTest.call/2
       test/ranger_web/live/todo_live_done_test.exs:11: (test)

Finished in 0.2 seconds (0.00s async, 0.2s sync)
3 tests, 1 failure, 2 excluded

It’s a long error, but you can see that the test is trying to find an element with the "#add-todo" id:

(ArgumentError) expected selector "#add-todo" to return a single element, but got none within:

Let’s put that form back and get our tests to pass again.

Without has_element?/3

Like all other render_* functions, render_submit/2 returns an HTML blob that we can assert against. So we could write our test like this:

# test/ranger_web/live/todo_live_test.exs
test "user can create new todo", %{conn: conn} do
  {:ok, view, _} = live(conn, ~p"/todo")

  html =
    view
    |> form("#add-todo", %{todo: %{body: "Form fellowship"}})
    |> render_submit()

  assert html =~ "Form fellowship"
end

But just as we saw with the counter example, the assertion only tells us that the string "Form fellowship" is somewhere within the HTML.

What if the form submission failed and our text field is simply populated with that string?

Our test would pass and give us a false positive.

You can confirm that by adding a validation in our changeset. Let’s say the todo body has to have a minimum length of 60 characters.

Open up lib/ranger/todo.ex and add the validation:

def changeset(todo \\ %__MODULE__{}, attrs) do
  todo
  |> cast(attrs, [:body])
  |> validate_required([:body])
+ |> validate_length(:body, min: 60)
end

Let’s run the test:

mix test test/ranger_web/live/todo_live_done_test.exs
.

Finished in 0.2 seconds
1 test, 0 failures, 0 excluded

It passes.

That’s a false positive. Our test says everything is good but our users wouldn’t be able to create todos.

Our assertion is too broad. We need to be more specific. That’s why I recommend the has_element?/3 helper with a good CSS selector to scope the assertion.

Of course, if you know that the only way the text will be present in the HTML is when your creation succeeds, then I think it’s absolutely fine to use the html blob returned by render_submit/2. But it’s important you know whether or not that is the case.

Just to confirm, go ahead and run the test with the has_element?/3 assertion to see it fail:

mix test test/ranger_web/live/todo_live_done_test.exs:10
Excluding tags: [:test]
Including tags: [line: "10"]

  1) test user can create a new todo (RangerWeb.TodoLiveTest)

     Expected truthy, got false
     code: assert has_element?(view, "[data-role=todo]", "Form fellowship")

Finished in 0.3 seconds (0.00s async, 0.3s sync)
3 tests, 1 failure, 2 excluded

Much better. That’s what we’d expect.

Remove that extra validation, and let’s move onto our next lesson.

Bonus

Take a look at what happens when we have the HTML form defined but we don’t have a phx-submit attribute.

Remove the phx-submit attribute and run our test. How does our form/3 helper help us then?

Resources