Skip to main content
This guide covers testing full LiveView modules, including mounting, event handling, navigation, and async operations.

Mounting LiveViews

Basic Mount

Use live/2 to mount a LiveView and get the rendered HTML:
test "mounts and renders", %{conn: conn} do
  {:ok, view, html} = live(conn, "/thermo")
  
  assert html =~ "The temp is: 1"
  assert view.module == MyAppWeb.ThermoLive
end
The live/2 macro returns:
  • {:ok, view, html} - Successfully mounted view and initial HTML
  • {:error, {:redirect, %{to: path}}} - LiveView redirected during mount
  • {:error, {:live_redirect, %{to: path}}} - LiveView live-redirected during mount

Testing Mount with Redirects

Some LiveViews redirect during mount:
test "redirects when unauthorized", %{conn: conn} do
  assert {:error, {:redirect, %{to: "/login"}}} = live(conn, "/admin")
end

Disconnected and Connected Mounts

Test both lifecycle phases:
test "disconnected and connected mount", %{conn: conn} do
  # Disconnected mount (HTTP GET)
  conn = get(conn, "/thermo")
  assert html_response(conn, 200) =~ "The temp is: 0"
  
  # Connected mount (WebSocket)
  {:ok, view, html} = live(conn)
  assert html =~ "The temp is: 1"
end

Mount with Session and Params

Pass session data and connection parameters:
test "mounts with session", %{conn: conn} do
  conn = 
    conn
    |> Plug.Test.init_test_session(%{user_id: 123})
    |> put_connect_params(%{"token" => "abc"})
  
  {:ok, view, html} = live(conn, "/dashboard")
  assert html =~ "User: 123"
end
Use put_connect_params/2 to set parameters available via get_connect_params/1 in your LiveView’s mount callback.

Testing Events

LiveView provides functions to test all phx-* event bindings.

Click Events

Test phx-click events by finding elements:
test "handles click events", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/thermo")
  
  # Click by finding element with text
  assert view
         |> element("button", "Increment")
         |> render_click() =~ "The temp is: 2"
  
  # Click by ID
  assert view
         |> element("#dec-button")
         |> render_click() =~ "The temp is: 1"
end
You can also trigger events directly:
test "triggers click event directly", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/thermo")
  
  assert render_click(view, :inc, %{}) =~ "The temp is: 2"
end
Prefer using element/3 over direct event calls, as it validates that the element and event actually exist in your rendered HTML.

Form Events

Form Change Events

Test phx-change validation:
test "validates form on change", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/users/new")
  
  # Test validation errors
  assert view
         |> form("#user-form", user: %{name: ""})
         |> render_change() =~ "can't be blank"
  
  # Test valid input
  refute view
         |> form("#user-form", user: %{name: "Alice"})
         |> render_change() =~ "can't be blank"
end

Form Submit Events

Test phx-submit form submissions:
test "submits form", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/users/new")
  
  assert view
         |> form("#user-form", user: %{name: "Alice", email: "[email protected]"})
         |> render_submit() =~ "User created successfully"
end

Hidden Form Fields

For hidden fields not in the form data, pass them to render_submit/2:
test "submits with hidden fields", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/users/new")
  
  assert view
         |> form("#user-form", user: %{name: "Alice"})
         |> render_submit(%{user: %{"hidden_token" => "secret"}}) =~ "Success"
end
Anti-pattern: Don’t pass regular input field values to render_submit/2. Always pass visible fields through form/3 to ensure they exist in your template. Only use the value parameter for hidden fields.

Form Submit Button

Test specific submit buttons with put_submitter/2:
test "submits with specific button", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/posts/1/edit")
  
  # Click "Save Draft" button
  assert view
         |> form("#post-form", post: %{title: "Draft"})
         |> put_submitter("button[name=action][value=draft]")
         |> render_submit() =~ "Draft saved"
  
  # Click "Publish" button
  assert view
         |> form("#post-form", post: %{title: "Published"})
         |> put_submitter("button[name=action][value=publish]")
         |> render_submit() =~ "Post published"
end

Keyboard Events

Test phx-keydown, phx-keyup, and phx-window-keydown events:
test "handles keyboard events", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/thermo")
  
  # Keyup event
  assert view
         |> element("#temp-input")
         |> render_keyup(%{"key" => "ArrowUp"}) =~ "The temp is: 2"
  
  # Keydown event
  assert render_keydown(view, :key, %{"key" => "ArrowDown"}) =~ "The temp is: 1"
end

Focus and Blur Events

Test phx-focus and phx-blur events:
test "handles focus events", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/search")
  
  # Focus input
  assert view
         |> element("#search-input")
         |> render_focus() =~ "Search active"
  
  # Blur input
  assert view
         |> element("#search-input")
         |> render_blur() =~ "Search inactive"
end

Hook Events

Test events from JavaScript hooks with render_hook/3:
test "handles hook events", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/canvas")
  
  assert render_hook(view, :draw, %{x: 10, y: 20}) =~ "Drawing"
end
For hooks targeting components:
test "hook event to component", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/editor")
  
  assert view
         |> element("#editor-component")
         |> render_hook(:save, %{content: "Hello"}) =~ "Saved"
end

Testing Navigation

Live Patch

Test push_patch navigation within the same LiveView:
test "patches to different tab", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/users")
  
  # Trigger patch
  view
  |> element("a", "Settings")
  |> render_click()
  
  # Assert patch happened
  assert_patch view, "/users?tab=settings"
end
Or manually patch:
test "manually patches view", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/users")
  
  assert render_patch(view, "/users?page=2") =~ "Page 2"
end

Live Navigation

Test push_navigate to different LiveViews:
test "navigates to different LiveView", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/posts")
  
  # Click navigate link
  result = view
           |> element("a", "New Post")
           |> render_click()
  
  # Follow the redirect
  assert {:error, {:live_redirect, %{to: "/posts/new"}}} = result
  
  {:ok, new_view, html} = follow_redirect(result, conn)
  assert html =~ "Create Post"
end

Regular Redirects

Test redirect/2 to non-LiveView pages:
test "redirects to external page", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/dashboard")
  
  result = render_click(view, :logout)
  
  assert {:error, {:redirect, %{to: "/login"}}} = result
  
  {:ok, conn} = follow_redirect(result, conn)
  assert html_response(conn, 200) =~ "Login"
end

Testing Messages

LiveViews are GenServers and can receive messages:
test "handles messages", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/thermo")
  
  # Send message to LiveView process
  send(view.pid, {:set_temp, 50})
  
  # Assert the view updated
  assert render(view) =~ "The temp is: 50"
end

Testing Async Operations

Wait for assign_async, start_async, and stream_async operations:
test "loads data asynchronously", %{conn: conn} do
  {:ok, view, html} = live(conn, "/users")
  
  # Initial loading state
  assert html =~ "Loading users..."
  
  # Wait for async operation to complete
  assert render_async(view) =~ "Alice"
  assert render_async(view) =~ "Bob"
end
With custom timeout:
test "waits for slow async operations", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/reports")
  
  # Wait up to 5 seconds
  assert render_async(view, 5000) =~ "Report generated"
end

Testing Uploads

Test file uploads with file_input/4 and render_upload/3:
test "uploads file", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/upload")
  
  # Create file input
  avatar = file_input(view, "#upload-form", :avatar, [
    %{
      last_modified: 1_594_171_879_000,
      name: "avatar.jpg",
      content: File.read!("test/fixtures/avatar.jpg"),
      size: 10_000,
      type: "image/jpeg"
    }
  ])
  
  # Upload file
  assert render_upload(avatar, "avatar.jpg") =~ "100%"
  
  # Submit form
  assert view
         |> form("#upload-form")
         |> render_submit() =~ "File uploaded"
end

Chunked Uploads

Test progressive upload:
test "uploads file in chunks", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/upload")
  
  avatar = file_input(view, "#upload-form", :avatar, [%{...}])
  
  # Upload 50%
  assert render_upload(avatar, "avatar.jpg", 50) =~ "50%"
  
  # Upload remaining 50%
  assert render_upload(avatar, "avatar.jpg", 50) =~ "100%"
end

Element Selection

The element/3 function finds elements by CSS selector:
# By ID
element(view, "#submit-button")

# By class and text
element(view, "button.primary", "Save")

# By attribute
element(view, ~s{[data-user-id="123"]})

# Complex selector
element(view, "#user-list > li:first-child a")

Text Filters

Use text filters to narrow selection:
# Exact text match (substring)
element(view, "button", "Save")

# Regex match
element(view, "a", ~r/^Edit$/)

# Avoid unintended matches
element(view, "a", ~r/(?<!un)opened/)  # Matches "opened" but not "unopened"
Add data-test-id attributes to elements that are hard to select with CSS alone:
<button data-test-id="save-draft">Save Draft</button>
Then select with:
element(view, "[data-test-id='save-draft']")

Checking Element Existence

Use has_element?/1 and has_element?/3:
test "shows and hides elements", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/users/1")
  
  assert has_element?(view, "#user-details")
  
  # Delete user
  view
  |> element("button", "Delete")
  |> render_click()
  
  refute has_element?(view, "#user-details")
end

Rendering Views

The render/1 function returns the current HTML:
test "renders current state", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/counter")
  
  current_html = render(view)
  assert current_html =~ "Count: 0"
end
Render a specific element:
test "renders element content", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/users")
  
  user_list = view
              |> element("#user-list")
              |> render()
  
  assert user_list =~ "Alice"
end

Testing Page Title

Assert on page title updates:
test "updates page title", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/posts/1")
  
  render_click(view, :edit)
  
  assert page_title(view) =~ "Edit Post"
end

Testing Nested LiveViews

Access child LiveViews with live_children/1 and find_live_child/2:
test "interacts with child LiveView", %{conn: conn} do
  {:ok, parent, _html} = live(conn, "/dashboard")
  
  # Get all children
  [clock, weather] = live_children(parent)
  
  # Or find specific child by ID
  clock = find_live_child(parent, "clock")
  
  assert render_click(clock, :snooze) =~ "Snoozing"
end

Best Practices

1
Use Element Selection
2
Prefer element/3 over direct event calls to validate your HTML structure.
3
Test User Workflows
4
Test complete user journeys, not just individual functions.
5
Verify HTML Structure
6
Assert on rendered HTML to ensure correct DOM structure and attributes.
7
Test Error Cases
8
Test validation errors, failed operations, and error states.
9
Use Descriptive Selectors
10
Add data-test-id attributes for complex or dynamic elements.

Build docs developers (and LLMs) love