Browse lessons

Testing Async Assigns

Testing Async Assigns

LiveView allows us to assign things asynchronously. Let’s see how we can test that.

If you open the app, you’ll see MetricsLive mounted under /metrics. It shows a loading indicator first, then metric data.

In mount/3:

def mount(params, _, socket) do
  {:ok, socket |> assign_async(:metric, fn -> fetch_metric(params) end)}
end

And in fetch_metric/1:

defp fetch_metric(params) do
  force_fail = Map.get(params, "test_force_failure", false)

  if force_fail do
    {:error, "Loading error"}
  else
    Process.sleep(1500)
    {:ok, %{metric: %{value: 123, name: "User Visits"}}}
  end
end

This sleeps to emulate async work and then returns metric data.

Render behavior

render/1 typically handles three states:

  • @metric.loading -> loading skeleton (data-role="loading")
  • @metric.ok? -> success state (data-role="success")
  • else -> failure state (data-role="failure")

Testing loading indicator

test "renders loading indicator when loading", %{conn: conn} do
  {:ok, _view, html} = live(conn, ~p"/metrics")

  assert html =~ "data-role=\"loading\""
end

This is a by-proxy assertion because we don’t have obvious user text in the loading skeleton.

Testing async data with sleep (works, but not ideal)

test "renders metric after loading (sleep)", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/metrics")

  Process.sleep(2000)

  assert has_element?(view, "[data-role=success]", "User Visits")
end

Why this is weak:

  • Process.sleep/1 in tests is usually a smell.
  • It always waits the full time, slowing test suites.

Better approach: render_async/2

test "renders metric after loading", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/metrics")

  assert render_async(view, 2000) =~ "User Visits"
end

render_async/2 waits up to timeout and returns as soon as async work completes.

Remove mystery timeout values

One way is to expose timeout in your LiveView and use it in test:

test "renders metric after loading", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/metrics")

  timeout = RangerWeb.MetricsLive.metric_timeout()

  assert render_async(view, timeout + 100) =~ "User Visits"
end

Another way is to read application config:

timeout = Application.get_env(:ranger, RangerWeb.MetricsLive)[:timeout]
assert render_async(view, timeout + 100) =~ "User Visits"

Testing failure path

One option is forcing failure via query params:

test "renders failure case upon failure", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/metrics?test_force_failure=true")

  assert render_async(view) =~ "Failed to load"
end

This is pragmatic but exposes test-oriented behavior.

Alternatives:

  • Use app config toggles in tests (async: false when mutating config)
  • Mock external metric-fetching module (for example, Mox/Mock)

Resources