Phoenix LiveView Testing: Components, JS Hooks, and Real-Time Events
Phoenix LiveView testing uses Phoenix.LiveViewTest to render LiveViews server-side and simulate user interactions without a browser. Tests run fast — no Selenium, no ChromeDriver — while still testing the full LiveView lifecycle: mount, events, component updates, and live navigation.
This guide covers testing LiveViews and LiveComponents, simulating JS hook interactions, testing PubSub broadcasts, and handling asynchronous updates.
Setup
# mix.exs
defp deps do
[
{:phoenix_live_view, "~> 0.20"},
# phoenix_live_view test helpers are included
]
end# test/support/conn_case.ex
defmodule MyAppWeb.ConnCase do
use ExUnit.CaseTemplate
using do
quote do
import Plug.Conn
import Phoenix.ConnTest
import Phoenix.LiveViewTest
use MyAppWeb, :verified_routes
@endpoint MyAppWeb.Endpoint
end
end
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
endBasic LiveView Tests
defmodule MyAppWeb.UserLiveTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.LiveViewTest
test "renders user list on mount", %{conn: conn} do
user = Factory.insert(:user, name: "Alice")
{:ok, view, html} = live(conn, ~p"/users")
assert html =~ "Alice"
assert has_element?(view, ".user-card", "Alice")
end
test "filters users by search query", %{conn: conn} do
Factory.insert(:user, name: "Alice Smith")
Factory.insert(:user, name: "Bob Jones")
{:ok, view, _html} = live(conn, ~p"/users")
html = view
|> element("form#search")
|> render_submit(%{query: "Alice"})
assert html =~ "Alice Smith"
refute html =~ "Bob Jones"
end
endlive/2 mounts the LiveView and returns {:ok, view, html}. The view is a handle you use to interact with the mounted LiveView.
Interacting with LiveView
Clicking Elements
test "deletes user on click", %{conn: conn} do
user = Factory.insert(:user)
{:ok, view, _} = live(conn, ~p"/users")
assert has_element?(view, "[data-id='#{user.id}']")
view
|> element("[data-id='#{user.id}'] button.delete")
|> render_click()
refute has_element?(view, "[data-id='#{user.id}']")
endSubmitting Forms
test "creates a post via form", %{conn: conn} do
{:ok, view, _} = live(conn, ~p"/posts/new")
html = view
|> form("#post-form", post: %{title: "Hello World", body: "Content here"})
|> render_submit()
# After successful creation, LiveView navigates away
assert_redirect(view, ~p"/posts")
endSending Events
test "increments counter on event", %{conn: conn} do
{:ok, view, _} = live(conn, ~p"/counter")
assert render(view) =~ "Count: 0"
render_click(view, "increment")
assert render(view) =~ "Count: 1"
render_click(view, "increment", %{amount: 5})
assert render(view) =~ "Count: 6"
endKey Events
test "saves on Ctrl+S", %{conn: conn} do
{:ok, view, _} = live(conn, ~p"/editor")
fill_in_editor(view, "My draft content")
render_keydown(view, "keydown", %{"key" => "s", "ctrlKey" => true})
assert has_element?(view, ".save-indicator", "Saved")
endTesting LiveComponents
Test LiveComponent modules independently:
defmodule MyAppWeb.UserCardComponentTest do
use MyAppWeb.ConnCase, async: true
import Phoenix.LiveViewTest
test "renders user name and email" do
user = %{id: 1, name: "Alice", email: "alice@example.com", role: :admin}
{:ok, view, html} = live_isolated(build_conn(), MyAppWeb.UserCardComponent,
session: %{"user" => user}
)
assert html =~ "Alice"
assert html =~ "alice@example.com"
assert has_element?(view, ".badge.admin", "Admin")
end
endFor components embedded in a parent LiveView, test through the parent:
test "UserCard shows delete button for admin viewers", %{conn: conn} do
admin = Factory.insert(:user, role: :admin)
target_user = Factory.insert(:user)
conn = log_in_user(conn, admin)
{:ok, view, _} = live(conn, ~p"/users/#{target_user.id}")
assert has_element?(view, "button[phx-click='delete']")
end
test "UserCard hides delete button for non-admin viewers", %{conn: conn} do
regular = Factory.insert(:user, role: :member)
target_user = Factory.insert(:user)
conn = log_in_user(conn, regular)
{:ok, view, _} = live(conn, ~p"/users/#{target_user.id}")
refute has_element?(view, "button[phx-click='delete']")
endTesting PubSub and Real-Time Updates
LiveViews that subscribe to PubSub topics update when broadcasts arrive. Test by broadcasting directly:
test "updates when new notification arrives", %{conn: conn} do
user = Factory.insert(:user)
conn = log_in_user(conn, user)
{:ok, view, _} = live(conn, ~p"/notifications")
assert render(view) =~ "No new notifications"
# Simulate a broadcast the LiveView subscribes to
Phoenix.PubSub.broadcast(
MyApp.PubSub,
"user:#{user.id}:notifications",
{:new_notification, %{message: "You have a new message", type: :info}}
)
# Give the LiveView time to process
:timer.sleep(50)
assert render(view) =~ "You have a new message"
endFor cleaner async handling, use assert_receive:
test "chat room receives new messages in real time", %{conn: conn} do
room = Factory.insert(:room)
user = Factory.insert(:user)
conn = log_in_user(conn, user)
{:ok, view, _} = live(conn, ~p"/rooms/#{room.id}")
# Another user posts a message
Task.async(fn ->
:timer.sleep(100)
MyApp.Chat.post_message(room.id, "Hello from test!", from_user_id: 999)
end)
# Assert the LiveView eventually shows the message
assert_receive {_, {:live_view, _}}, 500
assert render(view) =~ "Hello from test!"
endJS Hooks Testing
JS hooks (JavaScript that runs client-side) can't be fully tested without a browser, but you can test the server-side push events and handle events sent from hooks:
# Test that the LiveView handles events that JS hooks send
test "handles file-drop event from JS hook", %{conn: conn} do
{:ok, view, _} = live(conn, ~p"/upload")
# Simulate the phx:hook sending an event to the server
render_hook(view, "file_dropped", %{
filename: "document.pdf",
size: 1_024_000,
type: "application/pdf"
})
assert has_element?(view, ".upload-preview", "document.pdf")
assert has_element?(view, ".file-size", "1 MB")
endTesting pushEvent to JS Hooks
When LiveView calls push_event/3, it sends data to the client JS hook. Test the server-side behavior:
test "pushes chart data to JS hook on filter change", %{conn: conn} do
{:ok, view, _} = live(conn, ~p"/analytics")
render_click(view, "filter_changed", %{period: "last_30_days"})
# The LiveView should have updated its assigns (which would trigger the hook)
assert render(view) =~ ~s|data-chart-period="last_30_days"|
endNavigation and Redirects
test "redirects to login when unauthenticated", %{conn: conn} do
{:error, {:redirect, %{to: path}}} = live(conn, ~p"/dashboard")
assert path == ~p"/users/log_in"
end
test "navigates to post after creation", %{conn: conn} do
conn = log_in_user(conn, Factory.insert(:user))
{:ok, view, _} = live(conn, ~p"/posts/new")
view
|> form("#post-form", post: %{title: "New Post", body: "Body"})
|> render_submit()
assert_redirect(view, ~p"/posts")
end
test "live-navigates between pages", %{conn: conn} do
{:ok, view, _} = live(conn, ~p"/posts")
{:ok, new_view, _} = view
|> element("a.post-link", "First Post")
|> render_click()
|> follow_redirect(conn)
assert has_element?(new_view, "h1", "First Post")
endAssertions Reference
# Element presence
assert has_element?(view, "selector")
assert has_element?(view, "selector", "text content")
refute has_element?(view, "selector")
# Full HTML render
html = render(view)
assert html =~ "expected text"
# Render after interaction
html = view |> element("button") |> render_click()
# Redirect
assert_redirect(view, "/expected/path")
{:error, {:redirect, %{to: path}}} = live(conn, "/protected")
# Patch (live navigation)
assert_patch(view, "/new/path")Handling Uploads
test "uploads profile photo", %{conn: conn} do
user = Factory.insert(:user)
conn = log_in_user(conn, user)
{:ok, view, _} = live(conn, ~p"/profile/edit")
image = file_input(view, "#upload-form", :photo, [
%{
last_modified: 1_594_171_879_000,
name: "photo.jpg",
content: File.read!("test/fixtures/photo.jpg"),
size: 210_023,
type: "image/jpeg"
}
])
render_upload(image, "photo.jpg")
html = view |> element("#upload-form") |> render_submit()
assert html =~ "photo.jpg"
endContinuous Testing for LiveView Apps
LiveView test helpers cover the server-side rendering path. But client-side JS, CSS, and WebSocket connection reliability require real browser testing. HelpMeTest runs end-to-end scenarios against your live Phoenix application continuously, catching WebSocket issues, JS errors, and visual regressions that server-side tests miss.
Summary
Phoenix LiveView tests are fast, deterministic, and comprehensive — no browser required. Use live/2 to mount views, element/2 to scope interactions, render_click/render_submit/render_hook to trigger events, and has_element? for assertions. Test PubSub scenarios by broadcasting directly. Verify redirects with assert_redirect and assert_patch.
This approach covers the full LiveView lifecycle — from mount through user interactions to real-time updates — in milliseconds per test.