How to test handle_info/2 in Phoenix LiveView?

Greetings Phoenix LiveView Wizards! :wave:

Context

We have a basic LiveView counter app: GitHub - dwyl/phoenix-liveview-counter-tutorial: 🤯 beginners tutorial building a real time counter in Phoenix 1.7.7 + LiveView 0.19 ⚡️ Learn the fundamentals from first principals so you can make something amazing! 🚀

The code is very simple: /live/counter.ex

The App works as expected, see: https://live-view-counter.herokuapp.com

The test file is: test/live_view_counter_web/live/counter_test.exs

We are stuck with trying to invoke the handle_info/2 function in a test.

So we have code in our project that is untested. Which is undesirable.

See: Codecov

counter-not-covered

We have read through the official docs Phoenix.LiveViewTest — Phoenix LiveView v0.20.2

but have not been able to understand how to do it. What are we missing?

We really want to use LiveView in our “real” projects, but we want to ensure that our LiveView apps are fully tested.

Question

How do we write a test to invoke the handle_info/2 function?

Note: this question was also posted on StackOverflow: elixir - How to test handle_info/2 in Phoenix LiveView? - Stack Overflow

2 Likes

You basically just take on off the

render_click(view, event, value \\ %{})

so it could look like:

{:ok, view, html} =
  live_isolated(conn, AppWeb.ClockLive, session: %{"tz" => "EST"})

html = render_submit(view, "request", form)
assert html =~ "Result"

That would correspond to test

def handle_event("request", stuff, socket) do
  ...
end
1 Like

@andreaseriksson thanks for the quick reply. :+1:
Our test file has something similar: https://github.com/dwyl/phoenix-liveview-counter-tutorial/blob/master/test/live_view_counter_web/live/counter_test.exs

We are scratching our heads trying to invoke the handle_info/2 function. :man_shrugging:

1 Like

I guess you could just unit test it with calling it directly

test "handle_info/2", %{conn: conn} do
  assert {:noreply, %{val: "The message"}} = LiveViewCounterWeb.Counter.handle_info("The message", conn) 
end

@andreaseriksson yeah, that’s what we tried but the handle_info/2 function has the arguments msg, socket and we could not figure out how to create a mock socket because conn does not appear to work.

Our test is:

  test "handle_info/2", %{conn: conn} do
    msg = %{payload: %{ val: 1 }}
    {:noreply, result} = LiveViewCounterWeb.Counter.handle_info(msg, conn)
    assert result == %{val: 1}
  end

But we get the following error:

1) test handle_info/2 (LiveViewCounterWeb.CounterTest)
     test/live_view_counter_web/live/counter_test.exs:29
     ** (FunctionClauseError) no function clause matching in Phoenix.LiveView.assign/2

     The following arguments were given to Phoenix.LiveView.assign/2:

         # 1
         %Plug.Conn{adapter: {Plug.Adapters.Test.Conn, :...}, assigns: %{}, before_send: [], body_params: %Plug.Conn.Unfetched{aspect: :body_params}, cookies: %Plug.Conn.Unfetched{aspect: :cookies}, halted: false, host: "www.example.com", method: "GET", owner: #PID<0.358.0>}

         # 2
         [val: 1]

     Attempted function clauses (showing 1 out of 1):

         def assign(%Phoenix.LiveView.Socket{} = socket, attrs) when is_map(attrs) or is_list(attrs)

     code: {:noreply, result} = LiveViewCounterWeb.Counter.handle_info(msg, conn)
     stacktrace:
       (phoenix_live_view 0.10.0) lib/phoenix_live_view.ex:1475: Phoenix.LiveView.assign/2
       (live_view_counter 0.10.0) lib/live_view_counter_web/live/counter.ex:25: LiveViewCounterWeb.Counter.handle_info/2
       test/live_view_counter_web/live/counter_test.exs:31: (test)

After a lot of trial and error (and help from both this forum and StackOverflow) we wrote the test:

  test "handle_info/2", %{conn: conn} do
    {:ok, view, _html} = live(conn, "/")
    assert view.module == LiveViewCounterWeb.Counter
    msg = %{payload: %{ val: 1 }}
    result = send(view.pid, msg)
    assert result == msg
  end

It works. Thanks again @andreaseriksson for helping us work this out! :sunny:

3 Likes

That won’t test anything since send/2 returns the argument, so this assertion does not exercise any handling in the LV.

You also do not want to unit test the callbacks as that is very brittle and does not exercise the stateful LV. Aside: I also never unit tests GenServer’s in this way. For your test, you can send the message, and then assert the rendered LV content is what you expect after it experiences the side effects. This works because LiveViewTest.render/1 sends a message to the LV process to synchronize with it, which guarantees it has already processed the send:

  test "handle_info/2", %{conn: conn} do
    {:ok, view, disconnected_html} = live(conn, "/")
    assert disconnected_html =~ "Count: 0"
    assert render(view) =~ "Count: 0"
    send(view.pid, %{payload: %{ val: 1 }})
    assert render(view) =~ "Count: 1"
  end
14 Likes

Hi Chris! :wave:
Thanks for chiming in on this noob question
and clarifying that send/2 simply returns message so it’s not really testing anything. :man_facepalming:

Our understanding was that invoking send(view.pid, msg) would in turn call handle_info/2, but your suggested test is much better as it actually renders the LiveView and makes an assertion about the state of the counter. :bulb:

Tests updated: counter_test.exs#L22-L28 :white_check_mark:

Thanks again for Phoenix, LiveView and being awesome! :sunny:

send does send a message to the pid. That’s all it does and knows about. How the receiving process handles the message happens completely async and is up to the receiver alone.

3 Likes