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
endMounting 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
endThe {:ok, view, html} tuple gives you:
view— the LiveView process you interact withhtml— 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"
endForm 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
endTesting 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
endFor 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"
endTesting 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
endOr 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")
endChecking 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")
endAsync 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"
endFor longer async operations, increase the timeout:
assert_receive {:updated, result}, 5_000Browser-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: 1LiveViewTest 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 simulation —
render_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 assertions —
assert_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.