Phoenix LiveView Testing: Testing Real-Time UI Components

Phoenix LiveView Testing: Testing Real-Time UI Components

Phoenix LiveView enables rich, real-time web interfaces without writing JavaScript. Testing LiveView requires a different approach than traditional controller tests because LiveView components are stateful, event-driven, and update asynchronously. The Phoenix.LiveViewTest module provides the tools to test these interactions deterministically.

How LiveView Testing Works

Phoenix.LiveViewTest mounts a LiveView directly in your test process — no browser, no WebSocket connection required. It renders the initial HTML, and you interact with it by simulating user events. When LiveView processes an event and re-renders, you get the updated HTML back synchronously.

This makes LiveView tests fast (typically 10-50ms each) and deterministic, while still testing the actual LiveView logic, event handlers, and rendering pipeline.

Setup

Phoenix generates a LiveViewCase or you can use the standard ConnCase. Add Phoenix.LiveViewTest to your case:

defmodule MyAppWeb.ConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      import Plug.Conn
      import Phoenix.ConnTest
      import Phoenix.LiveViewTest
      import MyAppWeb.Router.Helpers

      @endpoint MyAppWeb.Endpoint
    end
  end

  # ... setup/sandbox code
end

Mounting a LiveView

Use live/2 to mount a LiveView by route, or live_isolated/3 to mount without a router:

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

  test "renders initial count", %{conn: conn} do
    {:ok, view, html} = live(conn, ~p"/counter")
    assert html =~ "Count: 0"
  end
end

The {:ok, view, html} tuple gives you:

  • view — the LiveView process you interact with
  • html — the initial rendered HTML string

Simulating User Events

LiveView responds to user interactions via events. Test them with event helpers:

test "increments counter on click", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/counter")

  # Click a button that sends "increment" event
  html = view |> element("button", "Increment") |> render_click()
  assert html =~ "Count: 1"

  # Click again
  html = view |> element("button", "Increment") |> render_click()
  assert html =~ "Count: 2"
end

test "decrements counter", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/counter")

  html = view |> element("button", "Decrement") |> render_click()
  assert html =~ "Count: -1"
end

Form Interactions

Forms are central to most LiveView UIs. Test them with render_submit/2 and render_change/2:

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

  test "validates form on change", %{conn: conn} do
    {:ok, view, _html} = live(conn, ~p"/users/new")

    # Trigger validate (phx-change)
    html = view
      |> form("#user-form", user: %{email: "not-an-email"})
      |> render_change()

    assert html =~ "is not a valid email"
  end

  test "submits form and creates user", %{conn: conn} do
    {:ok, view, _html} = live(conn, ~p"/users/new")

    view
    |> form("#user-form", user: %{
      name: "Alice",
      email: "alice@example.com",
      password: "secret123"
    })
    |> render_submit()

    # After successful submit, expect redirect
    assert_redirect(view, ~p"/users")
  end

  test "shows errors on invalid submit", %{conn: conn} do
    {:ok, view, _html} = live(conn, ~p"/users/new")

    html = view
      |> form("#user-form", user: %{email: ""})
      |> render_submit()

    assert html =~ "can't be blank"
  end
end

Testing Real-Time Updates

LiveView's power comes from server-pushed updates. Test them using send/2 to trigger messages to the LiveView process:

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

  test "displays new notification when received", %{conn: conn} do
    {:ok, view, html} = live(conn, ~p"/notifications")
    refute html =~ "New message from Alice"

    # Simulate PubSub message arriving
    send(view.pid, {:notification, %{from: "Alice", body: "Hello!"}})

    html = render(view)
    assert html =~ "New message from Alice"
    assert html =~ "Hello!"
  end
end

For PubSub-based updates, you can also use Phoenix.PubSub.broadcast/3 directly:

test "updates when new post published", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/feed")

  Phoenix.PubSub.broadcast(MyApp.PubSub, "posts", {:new_post, %{title: "Breaking News"}})

  assert render(view) =~ "Breaking News"
end

Testing LiveComponents

LiveComponents are isolated, reusable pieces of a LiveView. Test them with live_component/3:

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

  test "filters results on search", %{conn: conn} do
    # Mount the component in isolation
    {:ok, view, _html} = live_isolated(conn, MyAppWeb.SearchComponent,
      session: %{"items" => ["Apple", "Banana", "Cherry"]}
    )

    html = view
      |> element("input[type='search']")
      |> render_keyup(%{value: "an"})

    assert html =~ "Banana"
    refute html =~ "Apple"
    refute html =~ "Cherry"
  end
end

Or test components embedded within their parent LiveView by targeting them with within/2:

test "modal component opens and closes", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/dashboard")

  # Open modal
  view |> element("button", "Open Settings") |> render_click()
  assert has_element?(view, "#settings-modal")

  # Close modal from within component
  view |> element("#settings-modal button", "Cancel") |> render_click()
  refute has_element?(view, "#settings-modal")
end

Checking Element Presence

has_element?/2 and element/2 are your primary selectors:

# Check element exists
assert has_element?(view, "#user-profile")
assert has_element?(view, ".alert-success", "Saved!")

# Check element does not exist
refute has_element?(view, ".error-banner")

# Select with CSS and text
view |> element("li.user-item", "Alice") |> render_click()

Testing Navigation

LiveView supports push_navigate/2 and push_patch/2. Verify them with assert_redirect and assert_patch:

test "navigates to user profile after selection", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/users")

  view |> element("a", "View Alice") |> render_click()

  assert_redirect(view, ~p"/users/alice")
end

test "updates URL on filter change", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/products")

  view |> element("select#category") |> render_change(%{value: "electronics"})

  assert_patch(view, ~p"/products?category=electronics")
end

Async Updates and Timeouts

When LiveView spawns async work (like assign_async or start_async), use assert_receive/2 or poll with render/1:

test "loads data asynchronously", %{conn: conn} do
  {:ok, view, html} = live(conn, ~p"/reports")

  # Initial render shows loading state
  assert html =~ "Loading..."

  # Wait for async work to complete
  assert render(view) =~ "Q1 Revenue: $125,000"
end

For longer async operations, increase the timeout:

assert_receive {:updated, result}, 5_000

Browser-Level Testing

Phoenix.LiveViewTest covers server-side logic but doesn't test JavaScript hooks, CSS transitions, or browser-specific behavior. For complete end-to-end validation of your LiveView application in a real browser, HelpMeTest provides browser automation with Robot Framework and Playwright:

*** Test Cases ***
Counter Increments
    Go To    https://yourapp.com/counter
    Element Should Contain    css:h1    Count: 0
    Click Element    xpath://button[contains(text(),'Increment')]
    Element Should Contain    css:h1    Count: 1

LiveViewTest catches logic errors; browser tests catch rendering and JavaScript integration issues.

Summary

Phoenix LiveView testing with Phoenix.LiveViewTest gives you:

  • Fast, deterministic tests — no browser, no WebSocket overhead
  • Event simulationrender_click, render_change, render_submit
  • Real-time update testing — send messages directly to the LiveView process
  • Component isolation — test LiveComponents independently with live_isolated
  • Navigation assertionsassert_redirect, assert_patch

The key mental model: your test IS a client of the LiveView. You mount it, interact with it, and observe what it renders — just as a browser would, but faster and without network overhead.

Read more