Phoenix LiveView Testing: Components, JS Hooks, and Real-Time Events

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
end

Basic 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
end

live/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}']")
end

Submitting 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")
end

Sending 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"
end

Key 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")
end

Testing 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
end

For 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']")
end

Testing 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"
end

For 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!"
end

JS 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")
end

Testing 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"|
end
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")
end

Assertions 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"
end

Continuous 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.

Read more