32m read
Tags: elixir, phoenix, liveview, testing

Most LiveView tutorials gloss over testing. Mount a view, click a button, assert the result. Done. But real applications aren't one-page demos; they have dozens of components with JS hooks, stateful interactions spanning multiple user actions, and interdependencies that make isolated testing genuinely hard.

I've worked on codebases where component tests were either missing or so welded to their parent LiveViews that touching one component broke thirty unrelated files; the kind of test suite that makes people scared to refactor. Neither situation is acceptable. The gap isn't in framework tooling—Phoenix ships solid test primitives—it's in knowing which pattern to reach for and how to keep your tests from coupling to things they shouldn't care about.


LiveView Testing Primitives

Phoenix ships with Phoenix.LiveViewTest, a module that gives you live/2 for mounting views and render_click/2 for simulating interactions. You've probably used both.

defmodule MyAppWeb.CounterLiveTest do
  use MyAppWeb.ConnCase, async: true
  import Phoenix.LiveViewTest

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

    assert render_click(view, "increment") =~ "Count: 1"
  end
end

This works for full-page LiveViews. Components are a different problem. They don't have routes. They exist inside parent contexts; they receive assigns from above and sometimes send messages back up. You need to test them without dragging along the entire parent hierarchy.


Testing Stateless Function Components

Function components are the simpler case—pure functions that take assigns and return HEEx. No state, no lifecycle, no messages. Render the component directly; assert against the output.render-component-history

Phoenix 1.7+ gives you Phoenix.LiveViewTest.render_component/2 for exactly this:

defmodule MyAppWeb.Components.ButtonTest do
  use MyAppWeb.ConnCase, async: true
  import Phoenix.LiveViewTest

  alias MyAppWeb.Components.Button

  describe "render/1" do
    test "renders primary variant with correct classes" do
      html = render_component(&Button.button/1,
        variant: :primary,
        label: "Submit"
      )

      assert html =~ "bg-blue-600"
      assert html =~ "Submit"
    end

    test "renders disabled state" do
      html = render_component(&Button.button/1,
        variant: :primary,
        label: "Submit",
        disabled: true
      )

      assert html =~ "disabled"
      assert html =~ "opacity-50"
    end

    test "renders with custom class additions" do
      html = render_component(&Button.button/1,
        variant: :secondary,
        label: "Cancel",
        class: "ml-4"
      )

      assert html =~ "ml-4"
      assert html =~ "bg-gray-200"
    end
  end
end

The pattern is dead simple: call render_component/2 with a function capture and a keyword list of assigns. You get back rendered HTML as a string.

Function components that use slots need slightly different handling—you pass the slot content as part of the assigns:slot-testing

test "renders card with header and body slots" do
  html = render_component(&Card.card/1,
    inner_block: [
      %{__slot__: :header, inner_block: fn _, _ -> "Card Title" end},
      %{__slot__: :inner_block, inner_block: fn _, _ -> "Card content here" end}
    ]
  )

  assert html =~ "Card Title"
  assert html =~ "Card content here"
end

The slot syntax is awkward. No way around it. For anything beyond trivial slot scenarios, I write a test helper that wraps the boilerplate; otherwise every test file ends up with the same fifteen lines of ceremony.


Testing Stateful LiveComponents in Isolation

LiveComponents have their own lifecycle—mount/1, update/2, handle_event/3—and they hold state, communicate with parents via send/2, emit events upward. Testing them means creating a minimal harness: a bare-bones LiveView that hosts your component without the baggage of your actual parent views.test-harness-pattern

defmodule MyAppWeb.Components.SearchInputTest do
  use MyAppWeb.ConnCase, async: true
  import Phoenix.LiveViewTest

  # Minimal harness LiveView for testing
  defmodule TestHarness do
    use Phoenix.LiveView

    def render(assigns) do
      ~H"""
      <.live_component
        module={MyAppWeb.Components.SearchInput}
        id="test-search"
        placeholder={@placeholder}
        on_search={@on_search}
      />
      """
    end

    def mount(_params, _session, socket) do
      {:ok, assign(socket, placeholder: "Search...", on_search: nil)}
    end

    def handle_info({:search, query}, socket) do
      {:noreply, assign(socket, last_search: query)}
    end
  end

  describe "SearchInput component" do
    test "renders with placeholder", %{conn: conn} do
      {:ok, view, html} = live_isolated(conn, TestHarness)

      assert html =~ "Search..."
    end

    test "emits search event on form submit", %{conn: conn} do
      {:ok, view, _html} = live_isolated(conn, TestHarness)

      view
      |> element("form")
      |> render_submit(%{"query" => "elixir testing"})

      # Assert the component updated
      assert render(view) =~ "elixir testing"
    end

    test "debounces rapid input changes", %{conn: conn} do
      {:ok, view, _html} = live_isolated(conn, TestHarness)

      # Simulate rapid typing
      view |> element("input") |> render_change(%{"query" => "e"})
      view |> element("input") |> render_change(%{"query" => "el"})
      view |> element("input") |> render_change(%{"query" => "eli"})

      # Only one search should be triggered after debounce
      Process.sleep(350)  # Wait for debounce

      # Assert debounced behavior
      assert render(view) =~ "eli"
    end
  end
end

live_isolated/2 mounts your harness without needing a router entry.live-isolated The harness gives the component just enough context to function—nothing more. That Process.sleep(350) in the debounce test is ugly, and I'll be the first to admit it; in production test suites I've replaced these with assert_receive and explicit message passing, but the sleep-based version communicates intent more clearly in a tutorial context.process-sleep


Mocking Parent Assigns and Context

Real components depend on data passed from parents: current user, permissions, feature flags, loaded records. You need to simulate these without spinning up the entire parent stack.

I use a combination of harness assigns and Mox for external dependencies:mox-design

defmodule MyAppWeb.Components.UserCardTest do
  use MyAppWeb.ConnCase, async: true
  import Phoenix.LiveViewTest
  import Mox

  setup :verify_on_exit!

  defmodule TestHarness do
    use Phoenix.LiveView

    def render(assigns) do
      ~H"""
      <.live_component
        module={MyAppWeb.Components.UserCard}
        id="user-card"
        user={@user}
        current_user={@current_user}
        permissions={@permissions}
      />
      """
    end

    def mount(_params, session, socket) do
      {:ok, assign(socket,
        user: session["user"],
        current_user: session["current_user"],
        permissions: session["permissions"] || []
      )}
    end
  end

  describe "UserCard component" do
    test "shows edit button for admin users", %{conn: conn} do
      user = %{id: 1, name: "John Doe", email: "john@example.com"}
      current_user = %{id: 2, role: :admin}

      {:ok, view, html} = live_isolated(conn, TestHarness,
        session: %{
          "user" => user,
          "current_user" => current_user,
          "permissions" => [:edit_users]
        }
      )

      assert html =~ "Edit"
      assert html =~ "John Doe"
    end

    test "hides edit button for regular users", %{conn: conn} do
      user = %{id: 1, name: "John Doe", email: "john@example.com"}
      current_user = %{id: 2, role: :member}

      {:ok, view, html} = live_isolated(conn, TestHarness,
        session: %{
          "user" => user,
          "current_user" => current_user,
          "permissions" => []
        }
      )

      refute html =~ "Edit"
      assert html =~ "John Doe"
    end

    test "fetches additional user data on expand", %{conn: conn} do
      user = %{id: 1, name: "John Doe", email: "john@example.com"}

      # Mock the user service
      expect(MyApp.UserServiceMock, :get_user_details, fn 1 ->
        {:ok, %{bio: "Elixir developer", joined: ~D[2020-01-15]}}
      end)

      {:ok, view, _html} = live_isolated(conn, TestHarness,
        session: %{
          "user" => user,
          "current_user" => %{id: 2, role: :member},
          "permissions" => []
        }
      )

      html = view
        |> element("[data-action='expand']")
        |> render_click()

      assert html =~ "Elixir developer"
      assert html =~ "2020"
    end
  end
end

The session map passed to live_isolated/3 flows into your harness's mount/3; you inject whatever context the component needs without touching the real parent. Clean separation.


Testing Component Interactions and send_update

Components that coordinate with each other—or send updates back to parents—need tests that verify message flow, not just rendered output.

A modal component that notifies its parent when closed:

defmodule MyAppWeb.Components.ModalTest do
  use MyAppWeb.ConnCase, async: true
  import Phoenix.LiveViewTest

  defmodule TestHarness do
    use Phoenix.LiveView

    def render(assigns) do
      ~H"""
      <div>
        <p>Modal open: <%= @modal_open %></p>
        <%= if @modal_open do %>
          <.live_component
            module={MyAppWeb.Components.Modal}
            id="test-modal"
            title="Confirm Action"
            on_close={fn -> send(self(), :modal_closed) end}
          >
            <p>Are you sure?</p>
          </.live_component>
        <% end %>
      </div>
      """
    end

    def mount(_params, _session, socket) do
      {:ok, assign(socket, modal_open: true)}
    end

    def handle_info(:modal_closed, socket) do
      {:noreply, assign(socket, modal_open: false)}
    end
  end

  test "closes modal and notifies parent", %{conn: conn} do
    {:ok, view, html} = live_isolated(conn, TestHarness)

    assert html =~ "Modal open: true"
    assert html =~ "Confirm Action"

    # Click close button
    view
    |> element("[data-action='close-modal']")
    |> render_click()

    # Modal should be closed
    html = render(view)
    refute html =~ "Confirm Action"
    assert html =~ "Modal open: false"
  end
end

For sibling components that talk via send_update/3, the harness coordinates both:

defmodule TestHarness do
  use Phoenix.LiveView

  def render(assigns) do
    ~H"""
    <.live_component module={FilterPanel} id="filters" />
    <.live_component module={ResultsList} id="results" filters={@active_filters} />
    """
  end

  def handle_info({:filters_changed, filters}, socket) do
    send_update(ResultsList, id: "results", filters: filters)
    {:noreply, assign(socket, active_filters: filters)}
  end
end

Interact with FilterPanel; verify ResultsList updates. The harness is doing what your real parent view would do—routing messages between children—but without the twenty other concerns your parent view carries.


JavaScript Hooks and the Browser Gap

You cannot fully test JavaScript hooks with ExUnit. The BEAM doesn't run JavaScript; Phoenix's test helpers simulate the server side of LiveView and nothing else. No DOM, no browser, no hook lifecycle.

Three options, each with tradeoffs.

1. Test the Elixir side of hook communication

Hooks talk to the server via pushEvent and handleEvent. You can verify that your LiveView handles these events correctly even without a browser:render-hook

test "handles chart data request from JS hook", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/dashboard")

  # Simulate the event that a JS hook would push
  render_hook(view, "request-chart-data", %{
    "chart_id" => "revenue",
    "date_range" => "last_30_days"
  })

  # Assert the server responded with the expected data
  # The actual chart rendering happens in JS, but we verify
  # the data pipeline works
  assert_push_event(view, "chart-data", %{
    chart_id: "revenue",
    data: data
  })

  assert length(data) == 30
end

2. Use Wallaby or Hound for integration tests

For critical JS hook behavior, browser-based tests are the only way to get real confidence:wallaby-tradeoffs

# Using Wallaby
defmodule MyAppWeb.ChartIntegrationTest do
  use MyAppWeb.FeatureCase, async: false
  use Wallaby.Feature

  feature "chart renders with live data", %{session: session} do
    session
    |> visit("/dashboard")
    |> assert_has(css(".chart-container"))
    |> assert_has(css(".chart-container svg"))  # Chart rendered
    |> click(button("Last 7 Days"))
    |> assert_has(css(".chart-container[data-range='7']"))
  end
end

3. Test JS in isolation with a JS test runner

Move hook logic into standalone modules; test them with Jest or Vitest:hook-extraction

// assets/js/hooks/chart.test.js
import { parseChartData, calculateTrend } from './chart';

describe('chart utilities', () => {
  test('parseChartData handles empty dataset', () => {
    expect(parseChartData([])).toEqual({ labels: [], values: [] });
  });

  test('calculateTrend returns positive for upward data', () => {
    const data = [10, 12, 15, 18, 22];
    expect(calculateTrend(data)).toBe('positive');
  });
});

The split approach—ExUnit for server logic, Jest for client logic, Wallaby for the seams between them—gives you solid coverage without making your entire suite dependent on a headless browser. Most teams I've worked with run Wallaby tests in a separate CI step; they're too slow to include in the main feedback loop.


Snapshot Testing for Complex Component Output

When components produce deeply nested HTML—data tables, multi-step forms, rich text displays—asserting individual elements gets painful fast. You end up with forty assertions per test, and every minor CSS class change breaks half of them.snapshot-alternatives

Snapshot testing captures the full output and flags changes:

defmodule MyAppWeb.SnapshotHelpers do
  @snapshot_dir "test/snapshots"

  def assert_snapshot(html, name) do
    path = Path.join(@snapshot_dir, "#{name}.html")
    normalized = normalize_html(html)

    if File.exists?(path) do
      expected = File.read!(path)
      if normalized != expected do
        File.write!(Path.join(@snapshot_dir, "#{name}.new.html"), normalized)
        flunk """
        Snapshot mismatch for #{name}.
        New output written to #{name}.new.html
        Run `mix test.update_snapshots` to accept changes.
        """
      end
    else
      File.mkdir_p!(@snapshot_dir)
      File.write!(path, normalized)
      IO.puts("Created new snapshot: #{name}")
    end
  end

  defp normalize_html(html) do
    html
    |> String.replace(~r/\s+/, " ")
    |> String.replace(~r/> </, ">\n<")
    |> String.trim()
  end
end

Usage in tests:

test "renders complex data table correctly", %{conn: conn} do
  users = [
    %{id: 1, name: "Alice", role: :admin, last_login: ~U[2025-01-15 10:30:00Z]},
    %{id: 2, name: "Bob", role: :member, last_login: ~U[2025-01-14 09:00:00Z]}
  ]

  html = render_component(&UserTable.table/1, users: users, sortable: true)

  assert_snapshot(html, "user_table_with_data")
end

Snapshots work best for stable components—the ones that haven't changed in weeks. For components under active development, traditional assertions are less brittle; you'll spend less time approving meaningless snapshot diffs and more time actually testing behavior.


What This Looks Like in Practice

I've worked on a LiveView application with over 200 components. Without isolated component tests, every refactor was a coin flip; you'd change a date formatter in a shared component and discover—sometimes days later—that three different pages had broken in subtle ways. With the patterns above, the test suite catches those regressions in minutes.

That doesn't mean you need all of these patterns on day one. If your components are simple function components, render_component/2 might be everything you need for the next six months. If you're building something with deeply nested stateful components talking to each other across the page, invest in harness infrastructure early—it pays for itself the first time a refactor doesn't blow up in staging.

The one thing I'd push back on is skipping component tests because "the page-level tests cover it." They do, until they don't; until someone restructures the page layout and your component tests were the only thing verifying that the permission check actually hides the edit button. Isolated tests aren't redundant with integration tests. They're a different lens on the same code.


What do you think of what I said?

Share with me your thoughts. You can tweet me at @allanmacgregor.

Other articles that may interest you