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 returnsuser
(which is really thesession
), 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
. LikeConnCase
andFeatureCase
, 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
andsubscribe_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
andchannel
seem fine.form
could be more explicit; I likemessageForm
.messageInput
seems clear, especially if we change the form to bemessageForm
.messages
is vague and possibly confusing, since it could refer to existing messages. A more descriptive name might bemessagesContainer
.
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!
This is a work in progress.
To find out more, see TDD Phoenix - a work in progress.