Phoenix LiveView Testing: live/2, Interactions, Forms, and JS Hooks

Phoenix LiveView Testing: live/2, Interactions, Forms, and JS Hooks

Phoenix LiveView's test module gives you a full server-side rendering environment without a browser. This post covers mounting views, simulating user interactions, testing navigation, form submission, JS hook callbacks, and real-time push events — all in fast, deterministic ExUnit tests.

LiveView moved the rendering loop to the server, and that same decision makes testing straightforward: you test against the rendered HTML and the server-side state, not a browser's DOM. The Phoenix.LiveViewTest module gives you live/2 to mount views, render_click/2 to fire events, and a set of assertion helpers that let you verify what users actually see.

Setting Up LiveView Tests

Add Phoenix.LiveViewTest to your test case template:

defmodule MyAppWeb.ConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      import Plug.Conn
      import Phoenix.ConnTest
      import Phoenix.LiveViewTest
      alias MyAppWeb.Router.Helpers, as: Routes
      @endpoint MyAppWeb.Endpoint
    end
  end

  setup tags do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(MyApp.Repo)
    unless tags[:async] do
      Ecto.Adapters.SQL.Sandbox.mode(MyApp.Repo, {:shared, self()})
    end
    {:ok, conn: Phoenix.ConnTest.build_conn()}
  end
end

Mounting a LiveView with live/2

defmodule MyAppWeb.ProductLiveTest do
  use MyAppWeb.ConnCase, async: true

  import Phoenix.LiveViewTest

  alias MyApp.Factory

  setup do
    product = Factory.insert!(:product, name: "Widget", price: 29_99)
    {:ok, product: product}
  end

  test "renders product details", %{conn: conn, product: product} do
    {:ok, view, html} = live(conn, ~p"/products/#{product.id}")

    assert html =~ "Widget"
    assert html =~ "$29.99"
  end
end

live/2 returns {:ok, view, html} where view is a LiveViewTest.View struct you use for subsequent interactions, and html is the initial rendered markup. Always check the initial html for static content; use render(view) later to get the current HTML after interactions.

Simulating Click Events

LiveView components respond to phx-click attributes. Use render_click/2 to fire them:

test "adds product to cart", %{conn: conn, product: product} do
  {:ok, view, _html} = live(conn, ~p"/products/#{product.id}")

  html = render_click(view, "add_to_cart", %{"product_id" => product.id})

  assert html =~ "Added to cart"
  assert html =~ "1 item"
end

test "increments quantity on repeated clicks", %{conn: conn, product: product} do
  {:ok, view, _html} = live(conn, ~p"/products/#{product.id}")

  render_click(view, "add_to_cart", %{"product_id" => product.id})
  html = render_click(view, "add_to_cart", %{"product_id" => product.id})

  assert html =~ "2 items"
end

For elements inside nested live_component/3, target the component:

test "removes item via component event", %{conn: conn, product: product} do
  {:ok, view, _html} = live(conn, ~p"/cart")

  html =
    view
    |> element("#cart-item-#{product.id} button[phx-click='remove']")
    |> render_click()

  refute html =~ product.name
end

element/2 selects a DOM element by CSS selector and render_click/1 fires its phx-click event. This is more robust than passing event names directly — it tests that the button exists and has the right event wired up.

Testing Forms

For phx-change and phx-submit events, use render_change/2 and render_submit/2:

defmodule MyAppWeb.RegistrationLiveTest do
  use MyAppWeb.ConnCase, async: true

  import Phoenix.LiveViewTest

  test "shows validation errors on invalid input", %{conn: conn} do
    {:ok, view, _html} = live(conn, ~p"/register")

    html = render_change(view, "validate", %{
      "user" => %{"email" => "not-an-email", "password" => "short"}
    })

    assert html =~ "must have the @ sign"
    assert html =~ "should be at least 8 character"
  end

  test "creates account on valid submission", %{conn: conn} do
    {:ok, view, _html} = live(conn, ~p"/register")

    assert {:ok, conn} =
      view
      |> form("#registration-form", user: %{
        email: "new@example.com",
        password: "securepassword"
      })
      |> render_submit()
      |> follow_redirect(conn, ~p"/dashboard")

    assert conn.resp_body =~ "Welcome"
  end
end

form/3 selects a form element, pre-fills it, and returns a FormElement struct. render_submit/1 fires both the phx-change (optional) and phx-submit events. If submission triggers a redirect via push_navigate/2 or redirect/2, follow_redirect/2 follows it and returns the resulting conn.

Testing Navigation with assert_patch and assert_navigate

When a LiveView patches the URL without a full page reload, use assert_patch/2:

test "search updates URL query params", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/products")

  render_change(view, "search", %{"query" => "widget"})

  assert_patch(view, ~p"/products?q=widget")
  assert render(view) =~ "Widget Pro"
  refute render(view) =~ "Gadget Plus"
end

For full navigation (LiveView to LiveView), use assert_navigate/2:

test "clicking product navigates to detail page", %{conn: conn, product: product} do
  {:ok, view, _html} = live(conn, ~p"/products")

  assert {:ok, detail_view, html} =
    view
    |> element("a[href='/products/#{product.id}']")
    |> render_click()
    |> follow_redirect(conn)

  assert html =~ product.name
end

Testing Real-Time Pushes

When the server calls send(self(), :some_message) or uses Process.send_after/3 to schedule updates, you need to trigger those messages in tests:

test "live counter updates automatically", %{conn: conn} do
  {:ok, view, html} = live(conn, ~p"/dashboard/stats")

  assert html =~ "Loading..."

  # Simulate the timer firing
  send(view.pid, :tick)

  assert render(view) =~ "Active Users:"
  refute render(view) =~ "Loading..."
end

For PubSub-driven updates, broadcast directly:

test "order status updates via pubsub", %{conn: conn, order: order} do
  {:ok, view, _html} = live(conn, ~p"/orders/#{order.id}")

  MyAppWeb.Endpoint.broadcast("order:#{order.id}", "status_changed", %{
    status: "shipped",
    tracking: "1Z999"
  })

  assert render(view) =~ "Shipped"
  assert render(view) =~ "1Z999"
end

Testing LiveComponents in Isolation

LiveComponents (modules using Phoenix.LiveComponent) can be tested in isolation with live_isolated/3:

defmodule MyAppWeb.SearchBoxComponentTest do
  use MyAppWeb.ConnCase, async: true

  import Phoenix.LiveViewTest

  test "calls parent with search query on submit", %{conn: conn} do
    {:ok, view, _html} =
      live_isolated(conn, MyAppWeb.SearchBoxComponent,
        id: "search-box",
        placeholder: "Search..."
      )

    render_submit(view, "search", %{"query" => "elixir"})

    assert render(view) =~ ~r/data-query="elixir"/
  end
end

Testing JS Hooks

JS hooks run in the browser, so you cannot test their JavaScript side in ExUnit. What you can test is the server behavior that hooks interact with: the phx-hook element is mounted, custom events pushed via pushEvent trigger the right handlers, and handle_event callbacks respond correctly.

For the server side:

defmodule MyAppWeb.ChartLiveTest do
  use MyAppWeb.ConnCase, async: true

  import Phoenix.LiveViewTest

  test "chart hook element is rendered with correct data attributes", %{conn: conn} do
    {:ok, _view, html} = live(conn, ~p"/analytics")

    assert html =~ ~r/phx-hook="Chart"/
    assert html =~ ~r/data-series="\[/
  end

  test "responds to data_loaded event from hook", %{conn: conn} do
    {:ok, view, _html} = live(conn, ~p"/analytics")

    # Simulate pushEvent from the JS hook
    html = render_hook(view, "data_loaded", %{"points" => 42})

    assert html =~ "42 data points"
  end
end

render_hook/3 is the server-side equivalent of this.pushEvent("event", payload) from a JS hook.

Testing Authentication and Redirects

For protected routes, use conn with a logged-in user:

setup %{conn: conn} do
  user = Factory.insert!(:user)
  conn = log_in_user(conn, user)
  {:ok, conn: conn, user: user}
end

test "unauthenticated users are redirected to login", %{conn: conn} do
  unauthenticated = Phoenix.ConnTest.build_conn()

  assert {:error, {:redirect, %{to: "/login"}}} =
    live(unauthenticated, ~p"/dashboard")
end

test "authenticated users see dashboard", %{conn: conn, user: user} do
  {:ok, _view, html} = live(conn, ~p"/dashboard")
  assert html =~ user.email
end

log_in_user/2 is a helper generated by mix phx.gen.auth — it sets the session token on the conn.

Putting It Together: A Full Flow Test

defmodule MyAppWeb.CheckoutLiveTest do
  use MyAppWeb.ConnCase, async: false  # shared sandbox for multi-step test

  import Phoenix.LiveViewTest

  setup %{conn: conn} do
    user = Factory.insert!(:user)
    product = Factory.insert!(:product, name: "Elixir Book", price: 39_99, stock: 5)
    conn = log_in_user(conn, user)
    {:ok, conn: conn, user: user, product: product}
  end

  test "full checkout flow", %{conn: conn, product: product} do
    # Step 1: Browse to product
    {:ok, view, _html} = live(conn, ~p"/products/#{product.id}")
    assert render(view) =~ "Elixir Book"

    # Step 2: Add to cart
    render_click(view, "add_to_cart", %{"product_id" => product.id})

    # Step 3: Go to cart
    {:ok, cart_view, html} = live(conn, ~p"/cart")
    assert html =~ "Elixir Book"
    assert html =~ "$39.99"

    # Step 4: Submit checkout form
    assert {:ok, conn} =
      cart_view
      |> form("#checkout-form", payment: %{card: "4242424242424242", expiry: "12/28"})
      |> render_submit()
      |> follow_redirect(conn, ~p"/orders")

    # Step 5: Verify order created
    assert conn.resp_body =~ "Order confirmed"
  end
end

Key Takeaways

  • live/2 mounts a LiveView and returns the initial HTML — always verify static content here.
  • Use element/2 + render_click/1 for click events to simultaneously verify the element exists and has the right event.
  • form/3 + render_submit/1 handles form testing; follow_redirect/2 chases navigation after submission.
  • assert_patch/2 checks URL changes without page reloads; use it for search filters and pagination.
  • Send messages directly to view.pid or broadcast via PubSub to trigger real-time updates in tests.
  • render_hook/3 simulates JS hook events pushed to the server — test the server-side handler, not the JS.

Read more

Start now free