TDD Phoenix

Putting the Chat in Chat Rooms

In this chapter, we'll add the chatting functionality to our chat rooms. We want to continue to work from the perspective of the user. So let's write the feature as a user story:

As a user, I can join a chat room, so that I can have a conversation with another user.

Writing our feature test

Let's create a file for our feature test: test/chatter_web/features/user_can_chat_test.exs. This test will be more complex than the ones we've written so far. It will be comprised of the following sections:

  • Two users join the chat room
  • Once they've joined, one user will send a message
  • The second user will see the message and respond
  • Finally, the first user will see the response

Two users join the chat room

To have two users in our test, we'll need to initiate two wallaby sessions. To do that, we need to make the metadata from ChatterWeb.FeatureCase available in our test. Let's do that before we write the test. Update the ChatterWeb.FeatureCase:

# test/support/feature_case.ex

    metadata = Phoenix.Ecto.SQL.Sandbox.metadata_for(Chatter.Repo, self())
    {:ok, session} = Wallaby.start_session(metadata: metadata)
-   {:ok, session: session}
+   {:ok, session: session, metadata: metadata}
  end

Good. Now we can write the test. We will use the metadata provided instead of the session. Let's write the first part of the test:

# test/chatter_web/features/user_can_chat_test.exs

defmodule ChatterWeb.UserCanChatTest do
  use ChatterWeb.FeatureCase, async: true

  test "user can chat with others successfully", %{metadata: metadata} do
    room = insert(:chat_room)

    user =
      metadata
      |> new_user()
      |> visit(rooms_index())
      |> join_room(room.name)

    other_user =
      metadata
      |> new_user()
      |> visit(rooms_index())
      |> join_room(room.name)
  end

  defp new_user(metadata) do
    {:ok, user} = Wallaby.start_session(metadata: metadata)
    user
  end

  defp rooms_index, do: Routes.chat_room_path(@endpoint, :index)

  defp join_room(session, name) do
    session |> click(Query.link(name))
  end
end

I've already written some logic as private functions because it will make it easier to understand the test. By creating join_room/2, for example, we don't have to figure out what click(Query.link(name)) is doing in the test. It's clear that our user is joining a chat room.

Let's look at what we've done so far:

  • new_user/1 is creating a new Wallaby session by providing the metadata. This function also returns user (which is really the session), instead of {:ok, user}, making it easy to pipe.
  • The users visits the rooms index.
  • The users then join a chat room by its name. In join_room/2, you'll see that we're just clicking on a link that has the chat room's name. That will take the user to the chat room's page, where the chat will be available.

A user sends a message

Let's now have the first user send a message. From Wallaby's perspective, sending a message means the user will fill in a text field and submit a form. So let's add that:

# test/chatter_web/features/user_can_chat_test.exs

defmodule ChatterWeb.UserCanChatTest do
  use ChatterWeb.FeatureCase, async: true

  test "user can chat with others successfully", %{metadata: metadata} do
    room = insert(:chat_room)

    user =
      metadata
      |> new_user()
      |> visit(rooms_index())
      |> join_room(room.name)

    other_user =
      metadata
      |> new_user()
      |> visit(rooms_index())
      |> join_room(room.name)

    user    |> fill_in(Query.text_field("New Message"), with: "Hi everyone")    |> click(Query.button("Send"))  end
end

Just as with the logic for joining a chat room, it would be nice to write the sending of the message in the language of stakeholders by extracting the filling and submitting of the message form. So lets' extract a private function:

# test/chatter_web/features/user_can_chat_test.exs

defmodule ChatterWeb.UserCanChatTest do
  use ChatterWeb.FeatureCase, async: true

  test "user can chat with others successfully", %{metadata: metadata} do
    room = insert(:chat_room)

    user =
      metadata
      |> new_user()
      |> visit(rooms_index())
      |> join_room(room.name)

    other_user =
      metadata
      |> new_user()
      |> visit(rooms_index())
      |> join_room(room.name)

    user
+   |> add_message("Hi everyone")
  end

  defp new_user(metadata) do
    {:ok, user} = Wallaby.start_session(metadata: metadata)
    user
  end

  defp rooms_index, do: Routes.chat_room_path(@endpoint, :index)

  defp join_room(session, name) do
    session |> click(Query.link(name))
  end
+
+ defp add_message(session, message) do
+   session
+   |> fill_in(Query.text_field("New Message"), with: message)
+   |> click(Query.button("Send"))
+ end
end

Good!

Second user sees message and responds

We'll ensure the second users sees the first message by using assert_has/2 in the middle of the test. I usually dislike making assertions in the middle of a test — I find they obscure the goal of the test — but I think there are exceptions. In this case, I think the assertion is helpful and reads nicely. After receiving the message from the first user, the second user will respond with a welcome message.

# test/chatter_web/features/user_can_chat_test.exs

    user
    |> add_message("Hi everyone")

    other_user    |> assert_has(Query.data("role", "message", text: "Hi everyone"))    |> add_message("Hi, welcome to #{room.name}")

As we have done with other Wallaby queries, let's extract Query.data("role", "message", text: "Hi everyone") to be more intention revealing. Move it to a private message/1 function:

# test/chatter_web/features/user_can_chat_test.exs

    user
    |> add_message("Hi everyone")

    other_user
-   |> assert_has(Query.data("role", "message", text: "Hi everyone"))
+   |> assert_has(message("Hi everyone"))
    |> add_message("Hi, welcome to #{room.name}")
  end

+ defp message(text) do
+   Query.data("role", "message", text: text)
+ end

Assert the first user received the message

Finally, we assert that the first user receives the welcome message in the chat room:

# test/chatter_web/features/user_can_chat_test.exs

    other_user
    |> assert_has(message("Hi everyone"))
    |> add_message("Hi, welcome to #{room.name}")

    user    |> assert_has(message("Hi, welcome to #{room.name}"))

It's a complex test. But by extracting those private functions, I think the test is easy to read and understand: "We create two user sessions, and they each join the same chat room. One user comments first. The second user sees the message and responds. The first user then sees the response."

Our full test looks like this:

# test/chatter_web/features/user_can_chat_test.exs

defmodule ChatterWeb.UserCanChatTest do
  use ChatterWeb.FeatureCase, async: true

  test "user can chat with others successfully", %{metadata: metadata} do
    room = insert(:chat_room)

    user =
      metadata
      |> new_user()
      |> visit(rooms_index())
      |> join_room(room.name)

    other_user =
      metadata
      |> new_user()
      |> visit(rooms_index())
      |> join_room(room.name)

    user
    |> add_message("Hi everyone")

    other_user
    |> assert_has(message("Hi everyone"))
    |> add_message("Hi, welcome to #{room.name}")

    user
    |> assert_has(message("Hi, welcome to #{room.name}"))
  end

  defp new_user(metadata) do
    {:ok, user} = Wallaby.start_session(metadata: metadata)
    user
  end

  defp rooms_index, do: Routes.chat_room_path(@endpoint, :index)

  defp join_room(session, name) do
    session |> click(Query.link(name))
  end

  defp add_message(session, message) do
    session
    |> fill_in(Query.text_field("New Message"), with: message)
    |> click(Query.button("Send"))
  end

  defp message(text) do
    Query.data("role", "message", text: text)
  end
end

Running the test

Now let's run our test and see where the failures take us:

$ mix test test/chatter_web/features/user_can_chat_test.exs


  1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)

      ** (Wallaby.QueryError) Expected to find 1, visible link 'chat room
      0' but 0, visible links were found.

      code: |> join_room(room.name)
      stacktrace:
        (wallaby) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
        (wallaby) lib/wallaby/browser.ex:456: Wallaby.Browser.find/3
        test/chatter_web/features/user_can_chat_test.exs:11: (test)


Finished in 3.8 seconds
1 test, 1 failure

Wallaby cannot find a link to join the room. In our chat room's index page, we only create list items with the chat room names, but they aren't links. Let's update that now:

# lib/chatter_web/templates/chat_room/index.html.eex

<h1 class="title">Welcome to Chatter!</h1>

<ul>
  <%= for room <- @chat_rooms do %>
-   <li data-role="room"><%= room.name %></li>
+   <li data-role="room"><%= link room.name, to: Routes.chat_room_path(@conn, :show, room) %></li>
  <% end %>
</ul>

<div>
  <%= link "New chat room", to: Routes.chat_room_path(@conn, :new) %>
</div>

Now, let's rerun our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs


  1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
     test/chatter_web/features/user_can_chat_test.exs:4
     ** (Wallaby.QueryError) Expected to find 1, visible text input or textarea
     'New Message' but 0, visible text inputs or textareas were found.

     code: |> add_message("Hi everyone")
     stacktrace:
       (wallaby) lib/wallaby/browser.ex:475: Wallaby.Browser.find/2
       (wallaby) lib/wallaby/browser.ex:456: Wallaby.Browser.find/3
       test/chatter_web/features/user_can_chat_test.exs:43: ChatterWeb.UserCanChatTest.add_message/2
       test/chatter_web/features/user_can_chat_test.exs:20: (test)


Finished in 4.1 seconds
1 test, 1 failure

Good. Wallaby clicks the link and goes to the chat room's page, but it cannot find an input field to send a new message. Let's add a new form to the chat_rooms/show.html.eex page. We won't add a form action to our form. Instead, we'll submit our form via JavaScript, using Phoenix Channels. Copy the following:

# lib/chatter_web/templlates/chat_room/show.html.eex

<h1 data-role="room-title"><%= @chat_room.name %></h1>

<form>
  <label>
    New Message <input id="message" name="message" type="text" />
  </label>

  <button type="submit">Send</button>
</form>

Let's run our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs


  1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
     test/chatter_web/features/user_can_chat_test.exs:4
     ** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element
     with the attribute 'data-role' with value 'message' but 0, visible elements
     with the attribute were found.

     code: |> assert_has(message("Hi everyone"))
     stacktrace:
       test/chatter_web/features/user_can_chat_test.exs:23: (test)


Finished in 3.7 seconds
1 test, 1 failure

Great. We submit the form, but Wallaby cannot find the message on the page because we are not sending it. We'll do that next.

Going to JavaScript land

Before we start, we want to make sure our tests are rebuilding our JavaScript when we change it. Since Wallaby re-raises JavaScript errors, we'll continue using it even for tests that use a lot of JavaScript.

Watching assets

By default, running our tests does not rebuild our assets. We need something to rebuild our assets after we change JavaScript files so that we can successfully iterate with our tests.

There are two ways we can do this: one is to open a new terminal pane and have a process watching our assets' directory. The downside is that we have to manually do that every time we work on JavaScript files.

The second way is to update our test alias to automatically rebuild assets every time we run mix test. The downside is that we rebuild assets every test run, whether we have changed JavaScript files or not. And rebuilding assets can be slow.

I will show how to set up both, and leave you the choice of which to use. For the rest of this book, I will use the first option: the delay caused by rebuilding assets every test run is too large for my TDD cycle. I like the test feedback to be as fast as possible.

Regardless of which you choose, open up your mix.exs file.

Watching assets in a separate pane

If you choose to rebuild assets manually, add the following alias:

# mix.exs

  defp aliases do
    [
      "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
      "ecto.reset": ["ecto.drop", "ecto.setup"],
      test: ["ecto.create --quiet", "ecto.migrate", "test"],
      "assets.watch": &watch_assets/1    ]
  end

  defp watch_assets(_) do    Mix.shell().cmd(      "cd assets && ./node_modules/webpack/bin/webpack.js --mode development --watch"    )  end

Now open a new terminal pane, and run mix assets.watch. Just remember to do that when you're going to run tests with JavaScript.

Rebuilding when running mix test

If you choose to rebuild assets every test run, modify the test alias: add an "assets.compile" at the beginning of the test list, and define the function:

# mix.exs

  defp aliases do
    [
      "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
      "ecto.reset": ["ecto.drop", "ecto.setup"],
      test: ["assets.compile", "ecto.create --quiet", "ecto.migrate", "test"],      "assets.compile": &compile_assets/1,    ]
  end

  defp compile_assets(_) do    Mix.shell().cmd("cd assets && ./node_modules/webpack/bin/webpack.js --mode development",      quiet: true    )  end

Now every time you run your tests, you will see assets being compiled.

Using Phoenix socket

Now that we recompile assets when running tests, let's move on to using Phoenix's sockets and channels. First, uncomment the import socket statement in app.js:

# assets/js/app.js

  // Import local files
  //
  // Local files can be imported directly using relative paths, for example:

  import socket from "./socket"

If we rerun the feature test, we should see Elixir and JavaScript warnings bring printed:

12:18:19.458 [warn] Ignoring unmatched topic "topic:subtopic" in ChatterWeb.UserSocket
"Unable to join" Object

Open up the socket.js file, where the socket connection is established. At the top of the file, we pass a token for authentication. Let's remove that {token: window.userToken}, since we will not use it. Update the socket instantiation to look like this:

# assets/js/socket.js

let socket = new Socket("/socket", {params: {}})

If you scroll down, you'll see both the "topic:subtopic" that was present in the warning and the "Unable to join" console message JavaScript was sending.

We want to join the topic for the chat room we just joined. So instead of "topic:subtopic" we should have something like "chat_room:elixir". Using string interpolation, update the channel declaration to this:

# assets/js/socket.js

let channel = socket.channel(`chat_room:${chatRoomName}`, {})

There are many ways we could pass the chat room name from Elixir to JavaScript. Since the name isn't a lot of data, I will choose a fairly simple one — passing the name of the chat room through a data attribute. Add the following:

# assets/js/socket.js

let chatRoomTitle = document.getElementById("title")let chatRoomName = chatRoomTitle.dataset.chatRoomNamelet channel = socket.channel(`chat_room:${chatRoomName}`, {})

Now run our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs


  1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
     test/chatter_web/features/user_can_chat_test.exs:4
     ** (Wallaby.JSError) There was an uncaught javascript error:

     webpack-internal:///./js/socket.js 59:33 Uncaught TypeError: Cannot read property 'dataset' of null

     code: |> visit(rooms_index())
     stacktrace:
       (wallaby 0.26.2) lib/wallaby/chrome/logger.ex:8: Wallaby.Chrome.Logger.parse_log/1
       (elixir 1.11.0) lib/enum.ex:786: Enum."-each/2-lists^foreach/1-0-"/2
       (wallaby 0.26.2) lib/wallaby/driver/log_checker.ex:12: Wallaby.Driver.LogChecker.check_logs!/2
       (wallaby 0.26.2) lib/wallaby/browser.ex:963: Wallaby.Browser.visit/2
       test/chatter_web/features/user_can_chat_test.exs:10: (test)



Finished in 0.8 seconds
1 test, 1 failure

This seems like an unexpected error, but let's look at what it's telling us:

  • We ran into a JavaScript error: TypeError: Cannot read property 'dataset' of null
  • The error happens when we are visiting the rooms' index page: code: |> visit(rooms_index())

Why are we getting an error when trying to visit the index page? Why aren't we making it to the chat room's show page, like we used to?

To answer that, we must realize that we are now including socket.js in app.js, and app.js is included in our entire application. So when we visit the chat rooms' index page, we try to get an element by id "title" and access its dataset property, even though that page does not have an element with that id.

To get past this error, we need to wrap the use of the socket in a conditional: we'll only do this if the chatRoomTitle element is found:

let chatRoomTitle = document.getElementById("title")

if (chatRoomTitle) {  let chatRoomName = chatRoomTitle.dataset.chatRoomName  let channel = socket.channel(`chat_room:${chatRoomName}`, {})  channel.join()    .receive("ok", resp => { console.log("Joined successfully", resp) })    .receive("error", resp => { console.log("Unable to join", resp) })}

Now, let's update the chat_rooms/show.html.eex template to have an element with id "title" and a data-role with the chat room's name:

# lib/chatter_web/templates/show.html.eex

- <h1 data-role="room-title"><%= @chat_room.name %></h1>
+ <%= content_tag(:h1, id: "title", data: [role: "room-title", chat_room_name: @chat_room.name]) do %>
+   <%= @chat_room.name %>
+ <% end %>

Now rerun the test:

$ mix test test/chatter_web/features/user_can_chat_test.exs

04:59:16.408 [warn] Ignoring unmatched topic "chat_room:chat room 0" in ChatterWeb.UserSocket
"Unable to join" Object
04:59:16.585 [warn] Ignoring unmatched topic "chat_room:chat room 0" in ChatterWeb.UserSocket
"Unable to join" Object
04:59:17.597 [warn] Ignoring unmatched topic "chat_room:chat room 0" in ChatterWeb.UserSocket
"Unable to join" Object
04:59:19.598 [warn] Ignoring unmatched topic "chat_room:chat room 0" in ChatterWeb.UserSocket
"Unable to join" Object


  1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
     test/chatter_web/features/user_can_chat_test.exs:4
     ** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'message' but 0, visible elements with the attribute were found.

     code: |> assert_has(message("Hi everyone"))
     stacktrace:
       test/chatter_web/features/user_can_chat_test.exs:23: (test)



Finished in 3.8 seconds
1 test, 1 failure

Much better! We're back to seeing the Ignoring unmatched topic warnings, but they now say "chat_room:chat room 0". So, we're providing the correct chat name in JavaScript and submitting the form. But Wallaby cannot find any messages being added to our chat because we're not handling those chat_room:* topics in Phoenix. Let's do that next.

Back to Elixir: Phoenix Socket and Channels

Open up lib/chatter_web/channels/user_socket.ex. We'll uncomment the line right under ## Channels and modify it for our chat rooms:

defmodule ChatterWeb.UserSocket do
  use Phoenix.Socket

  ## Channels
  channel "chat_room:*", ChatterWeb.ChatRoomChannel

Running our test now will give us a wall of red error messages. Really it's the same error message repeated multiple times because Wallaby is trying to connect multiple sessions. So you might see something like this:

$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 1 file (.ex)

04:39:55.828 [error] Ranch listener ChatterWeb.Endpoint.HTTP had connection
process started with :cowboy_clear:start_link/4 at #PID<0.1423.0> exit with
reason: {:undef, [{ChatterWeb.ChatRoomChannel, :child_spec,
[{ChatterWeb.Endpoint, {#PID<0.1423.0>,
#Reference<0.754915750.1123811329.166747>}}], []}, {Phoenix.Channel.Server,
:join, 4, [file: 'lib/phoenix/channel/server.ex', line: 25]}, {Phoenix.Socket,
:handle_in, 4, [file: 'lib/phoenix/socket.ex', line: 617]},
{Phoenix.Endpoint.Cowboy2Handler, :websocket_handle, 2, [file:
'lib/phoenix/endpoint/cowboy2_handler.ex', line: 175]}, {:proc_lib,
:init_p_do_apply, 3, [file: 'proc_lib.erl', line: 226]}]}


  1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
     test/chatter_web/features/user_can_chat_test.exs:4
     ** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'message' but 0, visible elements with the attribute were found.

     code: |> assert_has(message("Hi everyone"))
     stacktrace:
       test/chatter_web/features/user_can_chat_test.exs:23: (test)



Finished in 5.0 seconds
1 test, 1 failure

This error is happening because our ChatterWeb.ChatRoomChannel is undefined. So let's define it:

# lib/chatter_web/channels/chat_room_channel.ex

defmodule ChatterWeb.ChatRoomChannel do
  use ChatterWeb, :channel
end

And rerun the test. You will once again see a large error message, repeated several times. I have removed the duplication in mine below:

$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 1 file (.ex)
warning: function join/3 required by behaviour Phoenix.Channel is not implemented (in module ChatterWeb.ChatRoomChannel)
  lib/chatter_web/channels/chat_room_channel.ex:1: ChatterWeb.ChatRoomChannel (module)

Generated chatter app
04:43:58.058 [error] GenServer #PID<0.586.0> terminating
** (UndefinedFunctionError) function ChatterWeb.ChatRoomChannel.join/3 is undefined or private
    (chatter 0.1.0) ChatterWeb.ChatRoomChannel.join("chat_room:chat room 0", %{}, %Phoenix.Socket{assigns: %{}, channel: ChatterWeb.ChatRoomChannel, channel_pid: #PID<0.586.0>, endpoint: ChatterWeb.Endpoint, handler: ChatterWeb.UserSocket, id: nil, join_ref: "3", joined: false, private: %{log_handle_in: :debug, log_join: :info}, pubsub_server: Chatter.PubSub, ref: nil, serializer: Phoenix.Socket.V2.JSONSerializer, topic: "chat_room:chat room 0", transport: :websocket, transport_pid: #PID<0.584.0>})
    (phoenix 1.5.4) lib/phoenix/channel/server.ex:377: Phoenix.Channel.Server.channel_join/4
    (phoenix 1.5.4) lib/phoenix/channel/server.ex:299: Phoenix.Channel.Server.handle_info/2
    (stdlib 3.13.1) gen_server.erl:680: :gen_server.try_dispatch/4
    (stdlib 3.13.1) gen_server.erl:756: :gen_server.handle_msg/6
    (stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
Last message: {Phoenix.Channel, %{}, {#PID<0.584.0>, #Reference<0.2984856758.2735210500.144000>}, %Phoenix.Socket{assigns: %{}, channel: ChatterWeb.ChatRoomChannel, channel_pid: nil, endpoint: ChatterWeb.Endpoint, handler: ChatterWeb.UserSocket, id: nil, join_ref: "3", joined: false, private: %{}, pubsub_server: Chatter.PubSub, ref: nil, serializer: Phoenix.Socket.V2.JSONSerializer, topic: "chat_room:chat room 0", transport: :websocket, transport_pid: #PID<0.584.0>}}
"Unable to join" Object



  1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
     test/chatter_web/features/user_can_chat_test.exs:4
     ** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'message' but 0, visible elements with the attribute were found.

     code: |> assert_has(message("Hi everyone"))
     stacktrace:
       test/chatter_web/features/user_can_chat_test.exs:23: (test)



Finished in 3.8 seconds
1 test, 1 failure

Both the warning — warning: function join/3 required by behaviour Phoenix.Channel is not implemented (in module ChatterWeb.ChatRoomChannel) — and the exception that was raised — ** (UndefinedFunctionError) function ChatterWeb.ChatRoomChannel.join/3 is undefined or private — show us that we need to define the join/3 function. Let's add a simple join/3:

defmodule ChatterWeb.ChatRoomChannel do
  use ChatterWeb, :channel

  def join("chat_room:" <> _room_name, _msg, socket) do    {:ok, socket}  endend

Now rerun the test:

$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 1 file (.ex)

"Joined successfully" Object
"Joined successfully" Object


  1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
     test/chatter_web/features/user_can_chat_test.exs:4
     ** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'message' but 0, visible elements with the attribute were found.

     code: |> assert_has(message("Hi everyone"))
     stacktrace:
       test/chatter_web/features/user_can_chat_test.exs:23: (test)



Finished in 3.8 seconds
1 test, 1 failure

Good! We're now successfully establishing a socket connection between the front-end and the back-end.

And our feature test now fails in the next step: it cannot find messages that should have been posted in the chat. And that is no surprise. We're not even sending messages from our front-end to our back-end yet. We'll do that next.

Sending messages

Let's update our socket.js file to send messages when we submit the form.

# assets/js/socket.js

let chatRoomTitle = document.getElementById("title")

if (chatRoomTitle) {
  let chatRoomName = chatRoomTitle.dataset.chatRoomName
  let channel = socket.channel(`chat_room:${chatRoomName}`, {})

  let form = document.getElementById("new-message-form")  let messageInput = document.getElementById("message")  form.addEventListener("submit", event => {    event.preventDefault()    channel.push("new_message", {body: messageInput.value})    event.target.reset()  })
  channel.join()
    .receive("ok", resp => { console.log("Joined successfully", resp) })
    .receive("error", resp => { console.log("Unable to join", resp) })
}

We target two elements by id: a "new-message-form", which we'll add next, and a "message", which is already included in our input element. Let's add the "new-message-form" id to our form:

# lib/chatter_web/templates/chat_room/show.html.eex

-<form>
+<form id="new-message-form">
   <label>
     New Message <input id="message" name="message" type="text" />
   </label>

   <button type="submit">Send</button>
 </form>

Now rerun the test:

$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 1 file (.ex)
"Joined successfully" Object
"Joined successfully" Object
04:59:24.492 [error] GenServer #PID<0.588.0> terminating
** (UndefinedFunctionError) function ChatterWeb.ChatRoomChannel.handle_in/3 is undefined or private
    (chatter 0.1.0) ChatterWeb.ChatRoomChannel.handle_in("new_message", %{"body" => "Hi everyone"}, %Phoenix.Socket{assigns: %{}, channel: ChatterWeb.ChatRoomChannel, channel_pid: #PID<0.588.0>, endpoint: ChatterWeb.Endpoint, handler: ChatterWeb.UserSocket, id: nil, join_ref: "3", joined: true, private: %{log_handle_in: :debug, log_join: :info}, pubsub_server: Chatter.PubSub, ref: "4", serializer: Phoenix.Socket.V2.JSONSerializer, topic: "chat_room:chat room 0", transport: :websocket, transport_pid: #PID<0.586.0>})
    (phoenix 1.5.4) lib/phoenix/channel/server.ex:316: Phoenix.Channel.Server.handle_info/2
    (stdlib 3.13.1) gen_server.erl:680: :gen_server.try_dispatch/4
    (stdlib 3.13.1) gen_server.erl:756: :gen_server.handle_msg/6
    (stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
Last message: %Phoenix.Socket.Message{event: "new_message", join_ref: "3", payload: %{"body" => "Hi everyone"}, ref: "4", topic: "chat_room:chat room 0"}


  1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
     test/chatter_web/features/user_can_chat_test.exs:4
     ** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'message' but 0, visible elements with the attribute were found.

     code: |> assert_has(message("Hi everyone"))
     stacktrace:
       test/chatter_web/features/user_can_chat_test.exs:23: (test)



Finished in 4.7 seconds
1 test, 1 failure

Good. We're now sending the message to the back-end, but our channel is not handling it because we have not defined a handle_in/3 function:

** (UndefinedFunctionError) function ChatterWeb.ChatRoomChannel.handle_in/3 is undefined or private
    (chatter 0.1.0) ChatterWeb.ChatRoomChannel.handle_in("new_message", %{"body" => "Hi everyone"}, %Phoenix.Socket{assigns: %{}, channel: ChatterWeb.ChatRoomChannel, channel_pid: #PID<0.588.0>, endpoint: ChatterWeb.Endpoint, handler: ChatterWeb.UserSocket, id: nil, join_ref: "3", joined: true, private: %{log_handle_in: :debug, log_join: :info}, pubsub_server: Chatter.PubSub, ref: "4", serializer: Phoenix.Socket.V2.JSONSerializer, topic: "chat_room:chat room 0", transport: :websocket, transport_pid: #PID<0.586.0>})

Let's go back to Elixir-land to receive those messages.

Stepping in: testing channels

Before starting with the channel implementation, I'd like to "step in" and create a channel test that fails with the same failure we currently see in our feature test. You might wonder, "why step in for the channel tests and not for the controller tests, when both are part of the web namespace?" After all, channels aren't part of the core business logic as defined by the switch from ChatterWeb to Chatter namespace.

The answer is confidence in our code. With a straightforward create/2 action in a controller, I felt confident that the controller logic was tested through the feature test. Channels can be more complex, and I want to make sure they are working correctly. Moreover, though channels aren't part of the Chatter namespace — and thus not part of the core business logic by that criterion — they are essential to our chat application. So it behooves us to ensure their correct working.

Let's create a channel test that fails with handle_in/3 being undefined. Copy the following test, and we'll walk through what we're doing in it:

# test/chatter_web/channels/chat_room_channel_test.exs

defmodule ChatterWeb.ChatRoomChannelTest do
  use ChatterWeb.ChannelCase, async: true

  describe "new_message event" do
    test "broadcasts message to all users" do
      {:ok, _, socket} = join_channel("chat_room:general")
      payload = %{"body" => "hello world!"}

      push(socket, "new_message", payload)

      assert_broadcast "new_message", ^payload
    end

    defp join_channel(topic) do
      ChatterWeb.UserSocket
      |> socket("", %{})
      |> subscribe_and_join(ChatterWeb.ChatRoomChannel, topic)
    end
  end
end
  • We use Phoenix's ChatterWeb.ChannelCase. Like ConnCase and FeatureCase, using this module adds some common setup and checks out a connection with Ecto's SQL sandbox.
  • We create a private function, join_channel/1, to abstracts the steps required to join a channel since those details are irrelevant to the test in question. In it, we use two Phoenix test helpers: socket/3 and subscribe_and_join/3.
  • We create a payload to send. The payload should match what Phoenix will send over the socket.
  • We use the push/3 Phoenix helper to push a new message to our socket, as our front-end client might do.
  • Finally, we test that we're broadcasting the message with Phoenix's aptly-named test helper assert_broadcast/2. For now we expect the broadcast to have the same payload we pushed to the socket.

Now, let's run the test!

$ mix test test/chatter_web/channels/chat_room_channel_test.exs

05:02:05.041 [error] GenServer #PID<0.556.0> terminating
** (UndefinedFunctionError) function ChatterWeb.ChatRoomChannel.handle_in/3 is undefined or private
    (chatter 0.1.0) ChatterWeb.ChatRoomChannel.handle_in("new_message", %{"body" => "hello world!"}, %Phoenix.Socket{assigns: %{}, channel: ChatterWeb.ChatRoomChannel, channel_pid: #PID<0.556.0>, endpoint: ChatterWeb.Endpoint, handler: ChatterWeb.UserSocket, id: "", join_ref: 34, joined: true, private: %{log_handle_in: :debug, log_join: :info}, pubsub_server: Chatter.PubSub, ref: #Reference<0.2091036909.3005480964.255284>, serializer: Phoenix.ChannelTest.NoopSerializer, topic: "chat_room:general", transport: :channel_test, transport_pid: #PID<0.554.0>})
    (phoenix 1.5.4) lib/phoenix/channel/server.ex:316: Phoenix.Channel.Server.handle_info/2
    (stdlib 3.13.1) gen_server.erl:680: :gen_server.try_dispatch/4
    (stdlib 3.13.1) gen_server.erl:756: :gen_server.handle_msg/6
    (stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
Last message: %Phoenix.Socket.Message{event: "new_message", join_ref: nil, payload: %{"body" => "hello world!"}, ref: #Reference<0.2091036909.3005480964.255284>, topic: "chat_room:general"}


  1) test new_message event broadcasts message to all users (ChatterWeb.ChatRoomChannelTest)
     test/chatter_web/channels/chat_room_channel_test.exs:5
     ** (EXIT from #PID<0.554.0>) an exception was raised:
         ** (UndefinedFunctionError) function ChatterWeb.ChatRoomChannel.handle_in/3 is undefined or private
             (chatter 0.1.0) ChatterWeb.ChatRoomChannel.handle_in("new_message", %{"body" => "hello world!"}, %Phoenix.Socket{assigns: %{}, channel: ChatterWeb.ChatRoomChannel, channel_pid: #PID<0.556.0>, endpoint: ChatterWeb.Endpoint, handler: ChatterWeb.UserSocket, id: "", join_ref: 34, joined: true, private: %{log_handle_in: :debug, log_join: :info}, pubsub_server: Chatter.PubSub, ref: #Reference<0.2091036909.3005480964.255284>, serializer: Phoenix.ChannelTest.NoopSerializer, topic: "chat_room:general", transport: :channel_test, transport_pid: #PID<0.554.0>})
             (phoenix 1.5.4) lib/phoenix/channel/server.ex:316: Phoenix.Channel.Server.handle_info/2
             (stdlib 3.13.1) gen_server.erl:680: :gen_server.try_dispatch/4
             (stdlib 3.13.1) gen_server.erl:756: :gen_server.handle_msg/6
             (stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3



Finished in 0.1 seconds
1 test, 1 failure

Good! This is the error we wanted for a successful handover from the feature test. We do not have a handle_in/3 function defined: ** (UndefinedFunctionError) function ChatterWeb.ChatRoomChannel.handle_in/3 is undefined or private. Let's go ahead and define it:

# lib/chatter_web/channels_chat_room_channel.ex

  def join("chat_room:" <> _room_name, _msg, socket) do
    {:ok, socket}
  end

  def handle_in("new_message", payload, socket) do    broadcast(socket, "new_message", payload)    {:noreply, socket}  end

Now rerun the channel test:

$ mix test test/chatter_web/channels/chat_room_channel_test.exs
Compiling 1 file (.ex)

.

Finished in 0.04 seconds
1 test, 0 failures

Great! Now, let's step back out and run our feature test:

$ mix test test/chatter_web/features/user_can_chat_test.exs

"Joined successfully" Object
"Joined successfully" Object


  1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
     test/chatter_web/features/user_can_chat_test.exs:4
     ** (Wallaby.ExpectationNotMetError) Expected to find 1, visible element with the attribute 'data-role' with value 'message' but 0, visible elements with the attribute were found.

     code: |> assert_has(message("Hi everyone"))
     stacktrace:
       test/chatter_web/features/user_can_chat_test.exs:23: (test)



Finished in 3.9 seconds
1 test, 1 failure

Excellent! The message is sent from the front-end to the back-end, and now the back-end is broadcasting the message to all clients. Now the front-end just needs to receive those incoming messages.

Handling new messages in JavaScript

Open the socket.js file, and let's update our JavaScript. The channel will listen for a "new_message" event. When it receives the event, we'll create a new list item with a "message" data-role — the feature test targets that data-role — and we'll append it as a child to a messages container — an HTML element we have yet to create.

# assets/js/socket.js

  let form = document.getElementById("new-message-form")
  let messageInput = document.getElementById("message")
  let messages = document.querySelector("[data-role='messages']")
  form.addEventListener("submit", event => {
    event.preventDefault()

    channel.push("new_message", {body: messageInput.value})

    event.target.reset()
  })

  channel.on("new_message", payload => {    let messageItem = document.createElement("li")    messageItem.dataset.role = "message"    messageItem.innerText = payload.body    messages.appendChild(messageItem)  })
  channel.join()
    .receive("ok", resp => { console.log("Joined successfully", resp) })
    .receive("error", resp => { console.log("Unable to join", resp) })

Now open the chat_room/show.html.eex template, and add the messages container: an unordered list with a "messages" data-role:

# lib/chatter_web/templates/chat_room/show.html.eex

<%= content_tag(:h1, id: "title", data: [role: "room-title", chat_room_name: @chat_room.name]) do %>
  <%= @chat_room.name %>
<% end %>

<div>  <ul data-role="messages">  </ul></div>

Now run the feature test. If everything works as expected, it should pass:

$ mix test test/chatter_web/features/user_can_chat_test.exs

"Joined successfully" Object
"Joined successfully" Object
.

Finished in 1.2 seconds
1 test, 0 failures

Amazing! Two people are chatting in a chat room!

This is a great place to commit our work. Do that, and we'll refactor next.

Refactoring

We will start refactoring our implementation from the outside-in: from the front-end (templates, views, JavaScript), to the glue layer (controllers and channels), to the core business logic (code in Chatter namespace).

The only template we worked with was chat_room/show.html.eex. It is straightforward; no need to spend time there. Our socket.js file, on the other hand, can be improved.

Removing unnecessary output

This seems small, but keeping our test suite clear of unnecessary output is important for ongoing upkeep: unnecessary output can be like a broken window. In our case, the output "Joined successfully" Object was sometimes helpful as we test-drove the feature, but it is unnecessary now. So let's clean it up:

# assets/js/socket.js

   channel.join()
-    .receive("ok", resp => { console.log("Joined successfully", resp) })
-    .receive("error", resp => { console.log("Unable to join", resp) })
 }

Now rerun our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs

.

Finished in 0.9 seconds
1 test, 0 failures

Now that's pristine!

Extracting chat room js logic

During our latest feature, the socket.js file gained a lot of logic unrelated to the socket. Let's move the logic related to chat rooms into a chat_room.js file. First, create a assets/js/chat_room.js file:

touch assets/js/chat_room.js

Now move the chat rooms logic there, leaving the socket.connect() and the exporting of the socket in the socket.js file:

# assets/js/socket.js

 socket.connect()

 // Now that you are connected, you can join channels with a topic:
-let chatRoomTitle = document.getElementById("title")
-
-if (chatRoomTitle) {
-  let chatRoomName = chatRoomTitle.dataset.chatRoomName
-  let channel = socket.channel(`chat_room:${chatRoomName}`, {})
-
-  let form = document.getElementById("new-message-form")
-  let messageInput = document.getElementById("message")
-  let messages = document.querySelector("[data-role='messages']")
-
-  form.addEventListener("submit", event => {
-    event.preventDefault()
-
-    channel.push("new_message", {body: messageInput.value})
-
-    event.target.reset()
-  })
-
-  channel.on("new_message", payload => {
-    let messageItem = document.createElement("li")
-    messageItem.dataset.role = "message"
-    messageItem.innerText = payload.body
-    messages.appendChild(messageItem)
-  })
-
-  channel.join()
-}
-
 export default socket
# assets/js/chat_room.js

let chatRoomTitle = document.getElementById("title")

if (chatRoomTitle) {
  let chatRoomName = chatRoomTitle.dataset.chatRoomName
  let channel = socket.channel(`chat_room:${chatRoomName}`, {})

  let form = document.getElementById("new-message-form")
  let messageInput = document.getElementById("message")
  let messages = document.querySelector("[data-role='messages']")

  form.addEventListener("submit", event => {
    event.preventDefault()

    channel.push("new_message", {body: messageInput.value})

    event.target.reset()
  })

  channel.on("new_message", payload => {
    let messageItem = document.createElement("li")
    messageItem.dataset.role = "message"
    messageItem.innerText = payload.body
    messages.appendChild(messageItem)
  })

  channel.join()
}

We now have to import the socket in chat_room.js to create a channel. Import it:

// assets/js/chat_room.js

import socket from "./socket"
let chatRoomTitle = document.getElementById("title")

Finally, let's import chat_room.js into app.js instead of socket.js:

# assets/js/app.js

-import socket from "./socket"
+import "./chat_room"

Now rerun our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs

.

Finished in 1.0 seconds
1 test, 0 failures

Good! The code in chat_room.js is mostly straightforward. And though we could improve it — for example, separating the DOM-manipulation logic from the chat logic — I think what we have is acceptable. And if, in the future, the logic in chat_room.js needs to change because of new requirements, we can always refactor it then.

A more descriptive id

So far, our chat room JS code executes when an element in the page has an id "title". I'd like to change that id because "title" is too generic; our test could break if other pages use "title" as an id for any HTML element. So, let's change the id to "chat-room-title" to make it explicit that we are in the chat room's page:

# assets/js/chat_room.js

-let chatRoomTitle = document.getElementById("title")
+let chatRoomTitle = document.getElementById("chat-room-title")

 if (chatRoomTitle) {
   let options = {
# lib/chatter_web/templates/chat_room/show.html.eex

-<%= content_tag(:h1, id: "title", data: [role: "room-title", chat_room_name: @chat_room.name]) do %>
+<%= content_tag(:h1, id: "chat-room-title", data: [role: "room-title", chat_room_name: @chat_room.name]) do %>
   <%= @chat_room.name %>
 <% end %>

Run our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs
Compiling 1 file (.ex)

.

Finished in 0.8 seconds
1 test, 0 failures

Great!

Clarifying naming

Lastly, I'd like to look at our chat_room.js file and see if we can improve the naming of variables. Some are fine, but some may be too generic:

  • chatRoomName and channel seem fine.
  • form could be more explicit; I like messageForm.
  • messageInput seems clear, especially if we change the form to be messageForm.
  • messages is vague and possibly confusing, since it could refer to existing messages. A more descriptive name might be messagesContainer.

Let's rename form and messages:

 import socket from "./socket";

 let chatRoomTitle = document.getElementById("chat-room-title")

 if (chatRoomTitle) {
   let chatRoomName = chatRoomTitle.dataset.chatRoomName;
   let channel = socket.channel(`chat_room:${chatRoomName}`, {});
-  let messages = document.querySelector("[data-role='messages']");
+  let messagesContainer = document.querySelector("[data-role='messages']");

-  let form = document.getElementById("new-message-form");
+  let messageForm = document.getElementById("new-message-form");
   let messageInput = document.getElementById("message");
-  form.addEventListener("submit", event => {
+  messageForm.addEventListener("submit", event => {
     event.preventDefault();
     channel.push("new_message", { body: messageInput.value });
     event.target.reset();
   });

   channel.on("new_message", payload => {
     let messageItem = document.createElement("li");
     messageItem.dataset.role = "message";
     messageItem.innerText = payload.body;
-    messages.appendChild(messageItem);
+    messagesContainer.appendChild(messageItem);
   });

   channel.join();
 }

Now rerun our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs

.

Finished in 0.9 seconds
1 test, 0 failures

Great!

Considering other refactoring

You might see other things to refactor in our JavaScript code. For now, since the code is straightforward, I think this is good enough.

The rest of the code we added as part of our feature lives in ChatterWeb.ChatRoomChannel. The channel code is simple and does not have much logic, so I think it's okay to leave it as is. And as I look through the tests, I see no need to clean up anything.

That means we're ready to commit all this work and see what to do next!