Testing Real-Time

WebSocket handlers and live views need different testing approaches than HTTP endpoints. This chapter covers strategies for testing both.

Testing WebSocket handlers

WebSocket handlers are Erlang modules with callbacks. Test them by calling the callbacks directly:

-module(blog_ws_handler_tests).
-include_lib("eunit/include/eunit.hrl").

init_test() ->
    {ok, State} = blog_ws_handler:init(#{}),
    ?assertMatch(#{}, State).

echo_test() ->
    {reply, {text, Reply}, _State} =
        blog_ws_handler:websocket_handle({text, <<"hello">>}, #{}),
    ?assertEqual(<<"Echo: hello">>, Reply).

ignore_binary_frames_test() ->
    {ok, _State} =
        blog_ws_handler:websocket_handle({binary, <<1,2,3>>}, #{}),
    ok.

Testing PubSub integration

For handlers that use PubSub, verify that messages are forwarded:

-module(blog_feed_handler_tests).
-include_lib("eunit/include/eunit.hrl").

pubsub_message_forwarded_test() ->
    State = #{},
    Msg = {nova_pubsub, posts, self(), "post_created", #{id => 1, title => <<"Test">>}},
    {reply, {text, Json}, _State} =
        blog_feed_handler:websocket_info(Msg, State),
    Decoded = thoas:decode(Json),
    ?assertMatch({ok, #{<<"channel">> := <<"posts">>}}, Decoded).

Integration testing WebSockets

For end-to-end WebSocket tests, use gun (an Erlang HTTP/WebSocket client):

-module(blog_ws_SUITE).
-include_lib("common_test/include/ct.hrl").

-export([all/0, init_per_suite/1, end_per_suite/1,
         test_ws_echo/1, test_ws_feed/1]).

all() -> [test_ws_echo, test_ws_feed].

init_per_suite(Config) ->
    application:ensure_all_started(gun),
    nova_test:start(blog, Config).

end_per_suite(Config) ->
    nova_test:stop(Config).

test_ws_echo(Config) ->
    {ok, ConnPid} = gun:open("localhost", 8080),
    {ok, _} = gun:await_up(ConnPid),
    StreamRef = gun:ws_upgrade(ConnPid, "/ws"),
    receive {gun_upgrade, ConnPid, StreamRef, _, _} -> ok end,

    gun:ws_send(ConnPid, StreamRef, {text, <<"hello">>}),
    receive
        {gun_ws, ConnPid, StreamRef, {text, Reply}} ->
            <<"Echo: hello">> = Reply
    after 1000 ->
        ct:fail("No WebSocket response")
    end,
    gun:close(ConnPid).

test_ws_feed(_Config) ->
    {ok, ConnPid} = gun:open("localhost", 8080),
    {ok, _} = gun:await_up(ConnPid),
    StreamRef = gun:ws_upgrade(ConnPid, "/feed"),
    receive {gun_upgrade, ConnPid, StreamRef, _, _} -> ok end,

    %% Trigger a broadcast
    nova_pubsub:broadcast(posts, "post_created", #{id => 99, title => <<"Test">>}),

    receive
        {gun_ws, ConnPid, StreamRef, {text, Json}} ->
            {ok, Decoded} = thoas:decode(Json),
            #{<<"event">> := <<"post_created">>} = Decoded
    after 2000 ->
        ct:fail("No feed message received")
    end,
    gun:close(ConnPid).

Testing Arizona live views

Arizona live views use opaque state types (arizona_view and arizona_stateful), so unit testing callbacks directly requires constructing views with arizona_view:new/3. Test the mount and event callbacks:

-module(blog_counter_live_tests).
-include_lib("eunit/include/eunit.hrl").

mount_test() ->
    View = blog_counter_live:mount(#{}, undefined),
    State = arizona_view:get_state(View),
    ?assertEqual(0, arizona_stateful:get_binding(count, State)).

increment_test() ->
    View = blog_counter_live:mount(#{}, undefined),
    {_Actions, View1} =
        blog_counter_live:handle_event(<<"increment">>, #{}, View),
    State = arizona_view:get_state(View1),
    ?assertEqual(1, arizona_stateful:get_binding(count, State)).

decrement_test() ->
    View = blog_counter_live:mount(#{}, undefined),
    {_Actions, View1} =
        blog_counter_live:handle_event(<<"decrement">>, #{}, View),
    State = arizona_view:get_state(View1),
    ?assertEqual(-1, arizona_stateful:get_binding(count, State)).

Testing live view rendering

Verify that render/1 produces expected content:

render_shows_count_test() ->
    Html = blog_counter_live:render(#{count => 42}),
    %% Check that the rendered output contains the count
    ?assertNotEqual(nomatch, binary:match(iolist_to_binary(Html), <<"42">>)).

Testing PubSub in live views

handle_info_new_comment_test() ->
    Comment = #{id => 1, body => <<"Nice!">>, author => #{username => <<"bob">>}},
    %% Build a view with empty comments
    View = arizona_view:new(blog_post_live, #{
        id => ~"test", comments => [], post => #{}, new_comment => <<>>,
        channel => test_channel
    }, none),
    Msg = {nova_pubsub, comments_1, self(), "new_comment", Comment},
    {_Actions, NewView} = blog_post_live:handle_info(Msg, View),
    State = arizona_view:get_state(NewView),
    ?assertEqual([Comment], arizona_stateful:get_binding(comments, State)).

Test structure

test/
├── post_changeset_tests.erl              %% EUnit — changeset validation
├── blog_posts_controller_tests.erl       %% EUnit — controller unit tests
├── blog_auth_tests.erl                   %% EUnit — security functions
├── blog_ws_handler_tests.erl             %% EUnit — WebSocket handler unit tests
├── blog_counter_live_tests.erl           %% EUnit — live view unit tests
├── blog_api_SUITE.erl                    %% CT — API integration tests
└── blog_ws_SUITE.erl                     %% CT — WebSocket integration tests

With testing covered, let's prepare for production. Next: Configuration.