Adding History to Rooms
We almost have our app fully functional: our users can create accounts, sign in, create chat rooms, and chat to their hearts' content. From here, we can now see the finish line.
In this chapter, we want to add history to chat rooms, allowing conversations to persist through time. Currently, when a user joins a channel, they cannot see any previous messages. Just refreshing the browser will cause all messages to be lost. That's not a great user experience for a chat application, so let's store messages in our database.
Writing our feature test
We'll start by writing a new test inside user_can_chat_test.exs
. In the test,
a user will post a message in a chat room, then a different user will log in,
and we should expect the second user to see the first user's message. As we've
done before, we'll write out the test as we want it to exist and let the errors
drive us:
# test/chatter_web/features/user_can_chat_test.exs
test "new user can see previous messages in chat room", %{metadata: metadata} do
room = insert(:chat_room)
user1 = insert(:user)
user2 = insert(:user)
metadata
|> new_session()
|> visit(rooms_index())
|> sign_in(as: user1)
|> join_room(room.name)
|> add_message("Welcome future users")
metadata
|> new_session()
|> visit(rooms_index())
|> sign_in(as: user2)
|> join_room(room.name)
|> assert_has(message("Welcome future users", author: user1))
end
The test probably looks familiar, but let's walk through what we're doing:
- Since we're starting two sessions in the test, we take the
%{metadata: metadata}
as params instead of the%{session: session}
. - We create our chat room and two users.
- We create our first session, sign in
user1
, and immediately post a message. Note thatuser2
is not in the chat room yet. - We then create another session, sign in
user2
, and expect to see the messageuser1
posted beforeuser2
signed in.
Without further ado, let's run our test:
$ mix test test/chatter_web/features/user_can_chat_test.exs:34
Excluding tags: [:test]
Including tags: [line: "34"]
1) test new user can see previous messages in chat room (ChatterWeb.UserCanChatTest)
test/chatter_web/features/user_can_chat_test.exs:34
** (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("Welcome future users", author: user1))
stacktrace:
test/chatter_web/features/user_can_chat_test.exs:51: (test)
Finished in 6.3 seconds
2 tests, 1 failure, 1 excluded
Good. Everything is working (or not working 😁) as expected. The second user cannot see the message sent before they joined. Let's fix that.
Saving messages
To show historical messages, we first have to save messages when users send
them. Open up our ChatRoomChannel
, and update the handle_in/3
callback to
call Chat.new_message/2
:
# lib/chatter_web/channels/chat_room_channel.ex
defmodule ChatterWeb.ChatRoomChannel do
use ChatterWeb, :channel
+ alias Chatter.Chat
+
def join("chat_room:" <> _room_name, _msg, socket) do
{:ok, socket}
end
def handle_in("new_message", payload, socket) do
- author = socket.assigns.email
+ %{room: room, email: author} = socket.assigns
outgoing_payload = Map.put(payload, "author", author)
+
+ Chat.new_message(room, outgoing_payload)
broadcast(socket, "new_message", outgoing_payload)
+
{:noreply, socket}
end
Note that we expect the socket.assigns
to have both a room and an email
(author). Since that's not true yet, we expect that to be a point of failure.
Let's rerun our test:
$ mix test test/chatter_web/features/user_can_chat_test.exs:34
Compiling 1 file (.ex)
warning: Chatter.Chat.new_message/2 is undefined or private
lib/chatter_web/channels/chat_room_channel.ex:14: ChatterWeb.ChatRoomChannel.handle_in/3
Excluding tags: [:test]
Including tags: [line: "34"]
05:17:28.430 [error] GenServer #PID<0.609.0> terminating
** (MatchError) no match of right hand side value: %{email: "super0@example.com"}
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:11: ChatterWeb.ChatRoomChannel.handle_in/3
(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" => "Welcome future users"}, ref: "4", topic: "chat_room:chat room 0"}
1) test new user can see previous messages in chat room (ChatterWeb.UserCanChatTest)
** (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("Welcome future users", author: user1))
stacktrace:
test/chatter_web/features/user_can_chat_test.exs:51: (test)
Finished in 6.3 seconds
2 tests, 1 failure, 1 excluded
Just as we expected, socket.assigns
do not have a room
in it, so we get the
following error: ** (MatchError) no match of right hand side value: %{email:
"super0@example.com"}
.
Before going further, remember that channels are a good place to "step in" from outside to inside testing. So, instead of running our feature test, let's run our chat room channel test, and see what error we get:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
Compiling 1 file (.ex)
warning: Chatter.Chat.new_message/2 is undefined or private
lib/chatter_web/channels/chat_room_channel.ex:14: ChatterWeb.ChatRoomChannel.handle_in/3
05:18:43.270 [error] GenServer #PID<0.574.0> terminating
** (MatchError) no match of right hand side value: %{email: "random@example.com"}
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:11: ChatterWeb.ChatRoomChannel.handle_in/3
(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.853685106.2109472772.3993>, topic: "chat_room:general"}
1) test new_message event broadcasts message to all users (ChatterWeb.ChatRoomChannelTest)
** (EXIT from #PID<0.572.0>) an exception was raised:
** (MatchError) no match of right hand side value: %{email: "random@example.com"}
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:11: ChatterWeb.ChatRoomChannel.handle_in/3
(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. Because we're getting the same match error — ** (MatchError) no
match of right hand side value: %{email: "random@example.com"}
— we can
safely "step in" and use the channel test as our guiding test.
To fix the error, we need to add room
to our socket.assigns
. But where
should we do that?
Well, we have that chat room information when a user first joins a chat room. So
let's do it on join/3
. So far, when joining a channel, we've been ignoring the
subtopic in join("chat_room:" <> _room_name)
.
Let's grab that room name, fetch the room from the database, and set it in the
socket.assigns
. Doing so will also force us to update our channel to use a
persisted chat room in the setup.
Let's dive in. Update the join/3
function as follows:
# lib/chatter_web/channels/chat_room_channel.ex
- def join("chat_room:" <> _room_name, _msg, socket) do
- {:ok, socket}
+ def join("chat_room:" <> room_name, _msg, socket) do
+ room = Chat.find_room_by_name(room_name)
+ {:ok, assign(socket, :room, room)}
end
Since Chat.find_room_by_name/1
doesn't exist yet, we can expect that to fail.
Let's run our test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
Compiling 2 files (.ex)
warning: Chatter.Chat.find_room_by_name/1 is undefined or private. Did you mean one of:
* find_room/1
lib/chatter_web/channels/chat_room_channel.ex:7: ChatterWeb.ChatRoomChannel.join/3
warning: Chatter.Chat.new_message/2 is undefined or private
lib/chatter_web/channels/chat_room_channel.ex:15: ChatterWeb.ChatRoomChannel.handle_in/3
05:27:16.995 [error] GenServer #PID<0.589.0> terminating
** (UndefinedFunctionError) function Chatter.Chat.find_room_by_name/1 is undefined or private
(chatter 0.1.0) Chatter.Chat.find_room_by_name("chat room 0")
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:7: ChatterWeb.ChatRoomChannel.join/3
(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.571.0>, #Reference<0.1844784585.2379481092.32801>}, %Phoenix.Socket{assigns: %{email: "super0@example.com"}, channel: ChatterWeb.ChatRoomChannel, channel_pid: nil, endpoint: ChatterWeb.Endpoint, handler: ChatterWeb.UserSocket, id: nil, join_ref: "6", 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.571.0>}}
05:27:17.042 [error] an exception was raised:
** (UndefinedFunctionError) function Chatter.Chat.find_room_by_name/1 is undefined or private
(chatter 0.1.0) Chatter.Chat.find_room_by_name("chat room 0")
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:7: ChatterWeb.ChatRoomChannel.join/3
(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
05:27:17.063 [error] GenServer #PID<0.597.0> terminating
** (UndefinedFunctionError) function Chatter.Chat.find_room_by_name/1 is undefined or private
(chatter 0.1.0) Chatter.Chat.find_room_by_name("general")
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:7: ChatterWeb.ChatRoomChannel.join/3
(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.595.0>, #Reference<0.1844784585.2379481093.32820>}, %Phoenix.Socket{assigns: %{email: "random@example.com"}, channel: ChatterWeb.ChatRoomChannel, channel_pid: nil, endpoint: ChatterWeb.Endpoint, handler: ChatterWeb.UserSocket, id: "", join_ref: 166, joined: false, private: %{}, pubsub_server: Chatter.PubSub, ref: nil, serializer: Phoenix.ChannelTest.NoopSerializer, topic: "chat_room:general", transport: :channel_test, transport_pid: #PID<0.595.0>}}
05:27:17.070 [error] an exception was raised:
** (UndefinedFunctionError) function Chatter.Chat.find_room_by_name/1 is undefined or private
(chatter 0.1.0) Chatter.Chat.find_room_by_name("general")
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:7: ChatterWeb.ChatRoomChannel.join/3
(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
1) test new_message event broadcasts message to all users (ChatterWeb.ChatRoomChannelTest)
** (MatchError) no match of right hand side value: {:error, %{reason: "join crashed"}}
code: {:ok, _, socket} = join_channel("chat_room:general", as: email)
stacktrace:
test/chatter_web/channels/chat_room_channel_test.exs:7: (test)
Finished in 0.1 seconds
1 test, 1 failure
That is a big error message, and it has several warnings. Though it seems daunting, we expect all of those errors and warnings.
First, Elixir is warning us about two undefined functions (both of which we know
are undefined): Chat.find_room_by_name/1
and Chat.new_message/2
. Then, we
get the error that crashed the process: ** (UndefinedFunctionError) function
Chatter.Chat.find_room_by_name/1 is undefined or private
. That error matches
the first warning we saw.
Interestingly, our channel bubbles up a nice error for our test when we try to
join the channel. Instead of returning the {:ok, _, socket}
tuple we expected,
the error tells us that the join crashed: {:error, %{reason: "join crashed"}}
.
So the warnings and errors are telling us that joining the channel failed
because Chat.find_room_by_name/1
is undefined. Let's fix that next. Since
Chat.find_room_by_name/1
is part of our core business logic, let's "step in"
one level deeper.
Creating Chatter.Chat.find_room_by_name/1
Since we're "stepping in" one more level, let's get a failing error that matches
the undefined function error we saw in our channel test. Open up
test/chatter/chat_test.exs
, and add a new test:
# test/chatter/chat_test.exs
describe "find_room_by_name/1" do
test "retrieves a room by name" do
room = insert(:chat_room)
found_room = Chat.find_room_by_name(room.name)
assert room == found_room
end
end
Now, run the test:
$ mix test test/chatter/chat_test.exs:54
Excluding tags: [:test]
Including tags: [line: "54"]
warning: Chatter.Chat.find_room_by_name/1 is undefined or private. Did you mean one of:
* find_room/1
test/chatter/chat_test.exs:58: Chatter.ChatTest."test find_room_by_name/1 retrieves a room by name"/1
1) test find_room_by_name/1 retrieves a room by name (Chatter.ChatTest)
** (UndefinedFunctionError) function Chatter.Chat.find_room_by_name/1 is undefined or private. Did you mean one of:
* find_room/1
code: found_room = Chat.find_room_by_name(room.name)
stacktrace:
(chatter 0.1.0) Chatter.Chat.find_room_by_name("chat room 0")
test/chatter/chat_test.exs:58: (test)
Finished in 0.1 seconds
6 tests, 1 failure, 5 excluded
Good. A lot of this message is Elixir trying to be helpful and suggest we use a
function that exists. But the core error is what we expect:
Chatter.Chat.find_room_by_name/1 is undefined or private
. Let's add an
implementation:
# lib/chatter/chat.ex
def find_room_by_name(name) do
Chat.Room |> Repo.get_by!(name: name)
end
We use the !
version of Repo.get_by/3
because we don't want to return nil
and assign it as the chat room name in our socket.assigns
. Sometimes raising
an exception is too aggressive, but in our case, we want the process to fail if
a user is trying to join a non-existent chat room.
Let's rerun our test:
$ mix test test/chatter/chat_test.exs:54
Compiling 2 files (.ex)
warning: Chatter.Chat.new_message/2 is undefined or private
lib/chatter_web/channels/chat_room_channel.ex:15: ChatterWeb.ChatRoomChannel.handle_in/3
Excluding tags: [:test]
Including tags: [line: "54"]
.
Finished in 0.1 seconds
6 tests, 0 failures, 5 excluded
Good, our test passes! We still see the warning for Chat.new_message/2
being
undefined, but we'll "step back out" to the channel test and later "step in" to
handle that warning.
Updating ChatRoomChannelTest
Let's run our channel test to see what to do next:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
Compiling 1 file (.ex)
warning: function Chatter.Chat.new_message/2 is undefined or private
lib/chatter_web/channels/chat_room_channel.ex:17
04:11:29.388 [error] an exception was raised:
** (DBConnection.OwnershipError) cannot find ownership process for #PID<0.556.0>.
When using ownership, you must manage connections in one
of the four ways:
* By explicitly checking out a connection
* By explicitly allowing a spawned process
* By running the pool in shared mode
* By using :caller option with allowed process
The first two options require every new process to explicitly
check a connection out or be allowed by calling checkout or
allow respectively.
The third option requires a {:shared, pid} mode to be set.
If using shared mode in tests, make sure your tests are not
async.
The fourth option requires [caller: pid] to be used when
checking out a connection from the pool. The caller process
should already be allowed on a connection.
If you are reading this error, it means you have not done one
of the steps above or that the owner process has crashed.
See Ecto.Adapters.SQL.Sandbox docs for more information.
(ecto_sql 3.4.5) lib/ecto/adapters/sql.ex:590: Ecto.Adapters.SQL.raise_sql_call_error/1
(ecto_sql 3.4.5) lib/ecto/adapters/sql.ex:526: Ecto.Adapters.SQL.execute/5
(ecto 3.4.6) lib/ecto/repo/queryable.ex:192: Ecto.Repo.Queryable.execute/4
(ecto 3.4.6) lib/ecto/repo/queryable.ex:17: Ecto.Repo.Queryable.all/3
(ecto 3.4.6) lib/ecto/repo/queryable.ex:120: Ecto.Repo.Queryable.one!/3
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:7: ChatterWeb.ChatRoomChannel.join/3
(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
1) test new_message event broadcasts message to all users (ChatterWeb.ChatRoomChannelTest)
test/chatter_web/channels/chat_room_channel_test.exs:5
** (MatchError) no match of right hand side value: {:error, %{reason: "join crashed"}}
code: {:ok, _, socket} = join_channel("chat_room:general", as: email)
stacktrace:
test/chatter_web/channels/chat_room_channel_test.exs:7: (test)
Finished in 0.06 seconds
1 test, 1 failure
Whoa! That is an unexpected (and rather large) Ecto error. Thankfully, it comes with a helpful message: we have multiple processes trying to use a database connection, but only the process that checked out the connection (our test process) is allowed to use it.
We can fix that by using the third option in the error's list: running the pool in shared mode. Note what the message says:
The third option requires a {:shared, pid} mode to be set. If using shared mode in tests, make sure your tests are not async.
So we have to set Ecto's SQL Sandbox mode to {:shared, pid}
, but that means we
can no longer run our channel test asynchronously. This trade-off is so common
that if you open ChatterWeb.ChannelCase
, you'll see that the setup sets the
mode to {:shared, pid}
when we run tests as async: false
. So, all we have to
do is change async
to false
in our test, and we're good to go!
# test/chatter_web/channel/chat_room_channel_test.exs
defmodule ChatterWeb.ChatRoomChannelTest do
- use ChatterWeb.ChannelCase, async: true
+ use ChatterWeb.ChannelCase, async: false
Now rerun our test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
05:31:05.591 [error] GenServer #PID<0.560.0> terminating
** (Ecto.NoResultsError) expected at least one result but got none in query:
from r0 in Chatter.Chat.Room,
where: r0.name == ^"general"
(ecto 3.4.6) lib/ecto/repo/queryable.ex:122: Ecto.Repo.Queryable.one!/3
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:7: ChatterWeb.ChatRoomChannel.join/3
(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.558.0>, #Reference<0.2594127925.3722182662.183011>}, %Phoenix.Socket{assigns: %{email: "random@example.com"}, channel: ChatterWeb.ChatRoomChannel, channel_pid: nil, endpoint: ChatterWeb.Endpoint, handler: ChatterWeb.UserSocket, id: "", join_ref: 101, joined: false, private: %{}, pubsub_server: Chatter.PubSub, ref: nil, serializer: Phoenix.ChannelTest.NoopSerializer, topic: "chat_room:general", transport: :channel_test, transport_pid: #PID<0.558.0>}}
05:31:05.603 [error] an exception was raised:
** (Ecto.NoResultsError) expected at least one result but got none in query:
from r0 in Chatter.Chat.Room,
where: r0.name == ^"general"
(ecto 3.4.6) lib/ecto/repo/queryable.ex:122: Ecto.Repo.Queryable.one!/3
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:7: ChatterWeb.ChatRoomChannel.join/3
(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
1) test new_message event broadcasts message to all users (ChatterWeb.ChatRoomChannelTest)
** (MatchError) no match of right hand side value: {:error, %{reason: "join crashed"}}
code: {:ok, _, socket} = join_channel("chat_room:general", as: email)
stacktrace:
test/chatter_web/channels/chat_room_channel_test.exs:7: (test)
Finished in 0.1 seconds
1 test, 1 failure
Since our test doesn't try to join the channel with an existing chat room's name (it simply passes "general"), our application cannot find the room in the database and fails to join the channel. Let's update our test to join a real chat room:
# test/chatter_web/channels/chat_room_channel_test.exs
describe "new_message event" do
test "broadcasts message to all users" do
email = "random@example.com"
- {:ok, _, socket} = join_channel("chat_room:general", as: email)
+ room = insert(:chat_room)
+ {:ok, _, socket} = join_channel("chat_room:#{room.name}", as: email)
payload = %{"body" => "hello world!"}
Rerun our test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
== Compilation error in file test/chatter_web/channels/chat_room_channel_test.exs ==
** (CompileError) test/chatter_web/channels/chat_room_channel_test.exs:7: undefined function insert/1
(elixir 1.11.0) src/elixir_locals.erl:114: anonymous fn/3 in :elixir_locals.ensure_no_undefined_local/3
(stdlib 3.13.1) erl_eval.erl:680: :erl_eval.do_apply/6
(elixir 1.11.0) lib/kernel/parallel_compiler.ex:416: Kernel.ParallelCompiler.require_file/2
(elixir 1.11.0) lib/kernel/parallel_compiler.ex:316: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7
Aha! We do not have an insert/1
function in our channel tests because we did
not import Chatter.Factory
in ChannelCase
. Let's do that:
test/support/channel_case.ex
# Import conveniences for testing with channels
import Phoenix.ChannelTest
import ChatterWeb.ChannelCase
+ import Chatter.Factory
# The default endpoint for testing
@endpoint ChatterWeb.Endpoint
And run the test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
Compiling 1 file (.ex)
05:35:09.426 [error] GenServer #PID<0.574.0> terminating
** (UndefinedFunctionError) function Chatter.Chat.new_message/2 is undefined or private
(chatter 0.1.0) Chatter.Chat.new_message(%Chatter.Chat.Room{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">, id: 339, inserted_at: ~N[2020-10-24 09:35:09], name: "chat room 0", updated_at: ~N[2020-10-24 09:35:09]}, %{"author" => "random@example.com", "body" => "hello world!"})
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:15: ChatterWeb.ChatRoomChannel.handle_in/3
(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.1538420151.501481477.88014>, topic: "chat_room:chat room 0"}
1) test new_message event broadcasts message to all users (ChatterWeb.ChatRoomChannelTest)
** (EXIT from #PID<0.572.0>) an exception was raised:
** (UndefinedFunctionError) function Chatter.Chat.new_message/2 is undefined or private
(chatter 0.1.0) Chatter.Chat.new_message(%Chatter.Chat.Room{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">, id: 339, inserted_at: ~N[2020-10-24 09:35:09], name: "chat room 0", updated_at: ~N[2020-10-24 09:35:09]}, %{"author" => "random@example.com", "body" => "hello world!"})
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:15: ChatterWeb.ChatRoomChannel.handle_in/3
(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
Great. Now, we're getting the error that matches the second warning we've seen:
** (UndefinedFunctionError) function Chatter.Chat.new_message/2 is undefined or
private
. Let's "step in" by getting the same failure in chat_test.exs
.
Creating Chatter.Chat.new_message/2
Our new_message/2
function takes a chat room and a map that includes the
message's body and author. We want our function to save a message with the body,
the author, and the associated chat room. Write the following test:
# test/chatter/chat_test.exs
describe "new_message/2" do
test "inserts message associated to room" do
room = insert(:chat_room)
params = %{"body" => "Hello world", "author" => "random@example.com"}
{:ok, message} = Chat.new_message(room, params)
assert message.chat_room_id == room.id
assert message.body == params["body"]
assert message.author == params["author"]
assert message.id
end
end
Now, let's run the test:
$ mix test test/chatter/chat_test.exs:64
Excluding tags: [:test]
Including tags: [line: "64"]
warning: Chatter.Chat.new_message/2 is undefined or private
test/chatter/chat_test.exs:69: Chatter.ChatTest."test new_message/2 inserts message associated to room"/1
1) test new_message/2 inserts message associated to room (Chatter.ChatTest)
** (UndefinedFunctionError) function Chatter.Chat.new_message/2 is undefined or private
code: {:ok, message} = Chat.new_message(room, params)
stacktrace:
(chatter 0.1.0) Chatter.Chat.new_message(%Chatter.Chat.Room{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">, id: 340, inserted_at: ~N[2020-10-24 09:36:35], name: "chat room 0", updated_at: ~N[2020-10-24 09:36:35]}, %{"author" => "random@example.com", "body" => "Hello world"})
test/chatter/chat_test.exs:69: (test)
Finished in 0.1 seconds
7 tests, 1 failure, 6 excluded
Good. We have successfully "stepped in" by getting the same error as our channel test. Now, let's add a basic but incomplete implementation to move the test forward:
# lib/chatter/chat.ex
def new_message(_room, _params) do
{:ok, %{}}
end
Run the test:
$ mix test test/chatter/chat_test.exs:64
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "64"]
1) test new_message/2 inserts message associated to room (Chatter.ChatTest)
** (KeyError) key :chat_room_id not found in: %{}
code: assert message.chat_room_id == room.id
stacktrace:
test/chatter/chat_test.exs:71: (test)
Finished in 0.1 seconds
7 tests, 1 failure, 6 excluded
The function exists, so we make it one step further. But since we return an
empty map, there's no chat_room_id
key. Let's add a more realistic
implementation. We will create a new message that is associated with the room.
Replace our previous implementation with this:
# lib/chatter/chat.ex
def new_message(room, params) do
room
|> Ecto.build_assoc(:messages)
|> Chat.Room.Message.changeset(params)
|> Repo.insert()
end
We use Ecto's build_assoc/2
to associate the message with the chat room. We
then use a yet-to-be-created Message.changeset/2
function and then insert the
new message into the database. Let's run our test:
$ mix test test/chatter/chat_test.exs:64
Compiling 2 files (.ex)
warning: Chatter.Chat.Room.Message.changeset/2 is undefined (module Chatter.Chat.Room.Message is not available or is yet to be defined)
lib/chatter/chat.ex:30: Chatter.Chat.new_message/2
Excluding tags: [:test]
Including tags: [line: "64"]
1) test new_message/2 inserts message associated to room (Chatter.ChatTest)
** (ArgumentError) schema Chatter.Chat.Room does not have association :messages
code: {:ok, message} = Chat.new_message(room, params)
stacktrace:
(ecto 3.4.6) lib/ecto/association.ex:154: Ecto.Association.association_from_schema!/2
(ecto 3.4.6) lib/ecto.ex:457: Ecto.build_assoc/3
(chatter 0.1.0) lib/chatter/chat.ex:29: Chatter.Chat.new_message/2
test/chatter/chat_test.exs:69: (test)
Finished in 0.1 seconds
7 tests, 1 failure, 6 excluded
It seems our Chat.Room
doesn't have a messages association, and we also get a
warning that Chat.Room.Message
doesn't exist. Let's define the association
first:
# lib/chatter/chat/room.ex
schema "chat_rooms" do
field :name, :string
+ has_many :messages, Chatter.Chat.Room.Message
timestamps()
end
Rerun our test:
$ mix test test/chatter/chat_test.exs:64
Compiling 2 files (.ex)
warning: invalid association `messages` in schema Chatter.Chat.Room: associated schema Chatter.Chat.Room.Message does not exist
lib/chatter/chat/room.ex:1: Chatter.Chat.Room (module)
warning: Chatter.Chat.Room.Message.changeset/2 is undefined (module Chatter.Chat.Room.Message is not available or is yet to be defined)
lib/chatter/chat.ex:30: Chatter.Chat.new_message/2
Excluding tags: [:test]
Including tags: [line: "64"]
1) test new_message/2 inserts message associated to room (Chatter.ChatTest)
** (UndefinedFunctionError) function Chatter.Chat.Room.Message.__schema__/1 is undefined (module Chatter.Chat.Room.Message is not available)
code: room = insert(:chat_room)
stacktrace:
Chatter.Chat.Room.Message.__schema__(:primary_key)
(ecto 3.4.6) lib/ecto/changeset/relation.ex:155: Ecto.Changeset.Relation.change/3
(ecto 3.4.6) lib/ecto/changeset/relation.ex:502: anonymous fn/4 in Ecto.Changeset.Relation.surface_changes/3
(elixir 1.11.0) lib/enum.ex:2181: Enum."-reduce/3-lists^foldl/2-0-"/3
(ecto 3.4.6) lib/ecto/changeset/relation.ex:489: Ecto.Changeset.Relation.surface_changes/3
(ecto 3.4.6) lib/ecto/repo/schema.ex:235: Ecto.Repo.Schema.do_insert/4
(ecto 3.4.6) lib/ecto/repo/schema.ex:164: Ecto.Repo.Schema.insert!/4
test/chatter/chat_test.exs:66: (test)
Finished in 0.1 seconds
7 tests, 1 failure, 6 excluded
Good! We finally get an error that Chat.Room.Message.__schema__/1
is
undefined. It's often surprising how far we can go without introducing a
database table. Now let's add that module, schema, and table.
Creating Chat.Room.Message
We'll use Phoenix's schema generator for this. If you frequently forget the
order of arguments for Phoenix's schema generator — I know I do —
you can always find help with mix help phx.gen.schema
. For now, we'll use the
generator to create the migration and schema files, and we'll modify them by
hand:
$ mix phx.gen.schema Chat.Room.Message chat_room_messages
* creating lib/chatter/chat/room/message.ex
* creating priv/repo/migrations/20201024094212_create_chat_room_messages.exs
Open up the migration to add columns for the chat_room_id
, the body
and the
author
:
# priv/repo/migrations/20201024094212_create_chat_room_messages.exs
defmodule Chatter.Repo.Migrations.CreateChatRoomMessages do
use Ecto.Migration
def change do
create table(:chat_room_messages) do
add :chat_room_id, references(:chat_rooms), null: false
add :body, :text, null: false
add :author, :string, null: false
timestamps()
end
end
end
You may have noticed that we did not reference the users table for the author. Instead, we only keep the email to render the messages in history. Other applications might need that reference. But our application only cares about the historical context. So we keep it simple and skip that association.
Now let's add the corresponding fields to the schema:
# lib/chatter/chat/room/message.ex
defmodule Chatter.Chat.Room.Message do
use Ecto.Schema
import Ecto.Changeset
schema "chat_room_messages" do
field :author, :string
field :body, :string
belongs_to :chat_room, Chatter.Chat.Room
timestamps()
end
@doc false
def changeset(message, attrs) do
message
|> cast(attrs, [])
|> validate_required([])
end
end
The schema generator also created a changeset/2
function for us. Though we're
not using it now, we'll use it very soon, so we'll leave it defined.
Finally, run mix ecto.migrate
to migrate the database, and rerun our chat
test:
$ mix test test/chatter/chat_test.exs:64
Compiling 1 file (.ex)
Generated chatter app
Excluding tags: [:test]
Including tags: [line: "64"]
1) test new_message/2 inserts message associated to room (Chatter.ChatTest)
** (KeyError) key :room_id not found
code: {:ok, message} = Chat.new_message(room, params)
stacktrace:
(ecto 3.4.6) lib/ecto/association.ex:653: Ecto.Association.Has.build/3
(chatter 0.1.0) lib/chatter/chat.ex:29: Chatter.Chat.new_message/2
test/chatter/chat_test.exs:69: (test)
Finished in 0.1 seconds
7 tests, 1 failure, 6 excluded
Our test fails when we try to use Ecto.build_assoc/2
. The function assumes
that Chat.Room.Messages
will have a room_id
instead of a chat_room_id
.
Let's update the foreign_key
option in our Chat.Room
module:
# lib/chatter/chat/room.ex
schema "chat_rooms" do
field :name, :string
- has_many :messages, Chatter.Chat.Room.Message
+ has_many :messages, Chatter.Chat.Room.Message, foreign_key: :chat_room_id
timestamps()
end
Now run the test again:
$ mix test test/chatter/chat_test.exs:64
Compiling 3 files (.ex)
Excluding tags: [:test]
Including tags: [line: "64"]
1) test new_message/2 inserts message associated to room (Chatter.ChatTest)
** (Postgrex.Error) ERROR 23502 (not_null_violation) null value in column "body" violates not-null constraint
table: chat_room_messages
column: body
Failing row contains (1, 344, null, null, 2020-10-24 09:47:29, 2020-10-24 09:47:29).
code: {:ok, message} = Chat.new_message(room, params)
stacktrace:
(ecto_sql 3.4.5) lib/ecto/adapters/sql.ex:593: Ecto.Adapters.SQL.raise_sql_call_error/1
(ecto 3.4.6) lib/ecto/repo/schema.ex:661: Ecto.Repo.Schema.apply/4
(ecto 3.4.6) lib/ecto/repo/schema.ex:263: anonymous fn/15 in Ecto.Repo.Schema.do_insert/4
test/chatter/chat_test.exs:69: (test)
Finished in 0.1 seconds
7 tests, 1 failure, 6 excluded
Good, the association is now set correctly. Our test fails because we tried
inserting a message with an empty body, which is not allowed. But why is the
body empty? Well, our Message.changeset/2
function is not casting or
validating any fields, so let's update that next.
Implementing Chat.Room.Message.changeset/2
Create a new file to test the Chat.Room.Message
module and add the following
test:
# test/chatter/chat/room/message_test.exs
defmodule Chatter.Chat.Room.MessageTest do
use Chatter.DataCase, async: true
alias Chatter.Chat.Room.Message
describe "changeset/2" do
test "validates that an author and body are provided" do
changes = %{}
changeset = Message.changeset(%Message{}, changes)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).body
assert "can't be blank" in errors_on(changeset).author
end
end
end
We first want to test that our Message.changeset/2
validates the presence of
the body
and author
. To do that, we pass an empty map, and then expect the
returning changeset to be invalid, having errors on both the body and author
fields.
Let's run the message_test
:
$ mix test test/chatter/chat/room/message_test.exs
1) test changeset/2 validates that an author and body are provided (Chatter.Chat.Room.MessageTest)
test/chatter/chat/room/message_test.exs:7
Expected false or nil, got true
code: refute changeset.valid?
stacktrace:
test/chatter/chat/room/message_test.exs:12: (test)
Finished in 0.08 seconds
1 test, 1 failure
The changeset came back valid because we didn't cast or validate the author and body fields. Let's update that:
# lib/chatter/chat/room/message.ex
def changeset(message, attrs) do
message
- |> cast(attrs, [])
- |> validate_required([])
+ |> cast(attrs, [:author, :body])
+ |> validate_required([:author, :body])
end
Rerun the test:
$ mix test test/chatter/chat/room/message_test.exs
Compiling 2 files (.ex)
.
Finished in 0.03 seconds
1 test, 0 failures
Good! Let's add one more test to ensure the chat_room_id
is required:
# test/chatter/chat/room/message_test.exs
test "validates that record is associated to a chat room" do
changes = %{"body" => "hello world", "author" => "person@example.com"}
changeset = Message.changeset(%Message{}, changes)
refute changeset.valid?
assert "can't be blank" in errors_on(changeset).chat_room_id
end
Run the new test:
$ mix test test/chatter/chat/room/message_test.exs:17
Excluding tags: [:test]
Including tags: [line: "17"]
1) test changeset/2 validates that record is associated to a chat room (Chatter.Chat.Room.MessageTest)
Expected false or nil, got true
code: refute changeset.valid?
stacktrace:
test/chatter/chat/room/message_test.exs:22: (test)
Finished in 0.09 seconds
2 tests, 1 failure, 1 excluded
The returning changeset is valid, even though it should not be. So let's add
chat_room_id
to our list of cast and required fields:
def changeset(message, attrs) do
message
- |> cast(attrs, [:author, :body])
- |> validate_required([:author, :body])
+ |> cast(attrs, [:author, :body, :chat_room_id])
+ |> validate_required([:author, :body, :chat_room_id])
end
And rerun our test:
$ mix test test/chatter/chat/room/message_test.exs:17
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "17"]
.
Finished in 0.04 seconds
2 tests, 0 failures, 1 excluded
Good! Now run both of our message tests to make sure they pass.
$ mix test test/chatter/chat/room/message_test.exs
..
Finished in 0.05 seconds
2 tests, 0 failures
Nice! Before moving forward, let's complete the red-green-refactor cycle for the
two tests we just added by refactoring Room.Message.changeset/2
. The list of
fields we pass to cast/2
and validate_required/2
is the same, so we can
extract that into a module attribute to avoid the repetition:
# lib/chatter/chat/room/message.ex
+ @valid_fields [:author, :body, :chat_room_id]
@doc false
def changeset(message, attrs) do
message
- |> cast(attrs, [:author, :body, :chat_room_id])
- |> validate_required([:author, :body, :chat_room_id])
+ |> cast(attrs, @valid_fields)
+ |> validate_required(@valid_fields)
end
In the future, we may only want a subset of the fields we cast to be required. If that's the case, we can separate them then. For now, our refactoring is slightly cleaner. Let's rerun our tests one more time to make sure they still pass.
$ mix test test/chatter/chat/room/message_test.exs
..
Finished in 0.05 seconds
2 tests, 0 failures
Good! It's time to step back out to chat_test
.
Stepping out to Chatter.ChatTest
Having implemented the Message.changeset/2
function, let's now step back out
and run our chat_test
:
$ mix test test/chatter/chat_test.exs:64
Excluding tags: [:test]
Including tags: [line: "64"]
.
Finished in 0.1 seconds
7 tests, 0 failures, 6 excluded
Excellent! It seems the changeset was all we needed. Before we step back out
another level, however, I'd like to test the behavior of new_message/2
when we
fail to create a message. On failure, we expect an {:error, changeset}
not an
{:ok, message}
. Let's write that test:
# test/chatter/chat_test.exs
test "returns a changeset if insert fails" do
room = insert(:chat_room)
params = %{}
{:error, changeset} = Chat.new_message(room, params)
assert errors_on(changeset).body
end
To make the test fail, we pass an empty map for params
into
Chat.new_message/2
. Note that we want to test the failure behavior of
Chat.new_message/2
. That includes Chat.new_message/2
returning an error
tuple with a changeset
that has some type of error. But we don't care about
the exact error messages returned (since that's part of the Message
's
responsibility). So, we only assert that errors are present without concerning
ourselves with the actual error message.
Let's run the test:
$ mix test test/chatter/chat_test.exs:77
Excluding tags: [:test]
Including tags: [line: "77"]
.
Finished in 0.1 seconds
8 tests, 0 failures, 7 excluded
Good! Now, let's step back out one more level to our chat room channel test.
Refactoring ChatRoomChannel
Now that we've successfully implemented the Chat.new_message/2
function, we
should expect our ChatRoomChannelTest
to pass. Let's see if that's the case by
running it:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
.
Finished in 0.1 seconds
1 test, 0 failures
Excellent! Now that our chat room channel is passing, we should consider
refactoring our implementation of ChatRoomChannel
.
I am concerned that we create a new message in our database before broadcasting it. That means our chat message will not be broadcast until we've made a round trip to the database.
Depending on the business requirements, the current behavior could be something we want — to ensure the integrity of message history. But I consider broadcasting messages to be more important than storing them. Broadcasting them is essential to our application. A failure to store a message should not prevent that message from being broadcast.
Since we care more about the broadcast than about saving the messages, let's
make it so that saving the chat message doesn't block the broadcast. To do so,
we could use Task.async
to send the message asynchronously, or we can send our
channel process a message that will then save the chat room in the database.
Let's do the latter.
First, let's send our process a new message via send/2
:
# lib/chatter_web/channels/chat_room_channel.ex
def handle_in("new_message", payload, socket) do
%{room: room, email: author} = socket.assigns
outgoing_payload = Map.put(payload, "author", author)
+ send(self(), {:store_new_message, outgoing_payload})
Chat.new_message(room, outgoing_payload)
broadcast(socket, "new_message", outgoing_payload)
{:noreply, socket}
end
Now run our test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
Compiling 1 file (.ex)
.
Finished in 0.08 seconds
1 test, 0 failures
Now, let's move the pattern matching of room
and the call to
Chat.new_message/2
to a new handle_info/2
function that handles the
{:store_new_message, payload}
message:
# lib/chatter_web/channels/chat_room_channel.ex
def handle_in("new_message", payload, socket) do
- %{room: room, email: author} = socket.assigns
+ %{email: author} = socket.assigns
outgoing_payload = Map.put(payload, "author", author)
send(self(), {:store_new_message, outgoing_payload})
- Chat.new_message(room, payload)
broadcast(socket, "new_message", outgoing_payload)
{:noreply, socket}
end
+ def handle_info({:store_new_message, payload}, socket) do
+ %{room: room} = socket.assigns
+ Chat.new_message(room, payload)
+
+ {:noreply, socket}
+ end
And rerun our test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs
Compiling 1 file (.ex)
.
Finished in 0.07 seconds
1 test, 0 failures
Good! The test still passes, but saving the message in the database no longer blocks the broadcast. We're now ready to step back out to our feature test.
Stepping out to our feature test
Let's run our test in test/chatter_web/features/user_can_chat_test.exs
to see
what we need to do next:
$ mix test test/chatter_web/features/user_can_chat_test.exs:34
Excluding tags: [:test]
Including tags: [line: "34"]
06:00:10.561 [error] an exception was raised:
** (DBConnection.OwnershipError) cannot find ownership process for #PID<0.630.0>.
When using ownership, you must manage connections in one
of the four ways:
* By explicitly checking out a connection
* By explicitly allowing a spawned process
* By running the pool in shared mode
* By using :caller option with allowed process
The first two options require every new process to explicitly
check a connection out or be allowed by calling checkout or
allow respectively.
The third option requires a {:shared, pid} mode to be set.
If using shared mode in tests, make sure your tests are not
async.
The fourth option requires [caller: pid] to be used when
checking out a connection from the pool. The caller process
should already be allowed on a connection.
If you are reading this error, it means you have not done one
of the steps above or that the owner process has crashed.
See Ecto.Adapters.SQL.Sandbox docs for more information.
(ecto_sql 3.4.5) lib/ecto/adapters/sql.ex:590: Ecto.Adapters.SQL.raise_sql_call_error/1
(ecto_sql 3.4.5) lib/ecto/adapters/sql.ex:526: Ecto.Adapters.SQL.execute/5
(ecto 3.4.6) lib/ecto/repo/queryable.ex:192: Ecto.Repo.Queryable.execute/4
(ecto 3.4.6) lib/ecto/repo/queryable.ex:17: Ecto.Repo.Queryable.all/3
(ecto 3.4.6) lib/ecto/repo/queryable.ex:120: Ecto.Repo.Queryable.one!/3
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:7: ChatterWeb.ChatRoomChannel.join/3
(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
1) test new user can see previous messages in chat room (ChatterWeb.UserCanChatTest)
test/chatter_web/features/user_can_chat_test.exs:34
** (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("Welcome future users", author: user1))
stacktrace:
test/chatter_web/features/user_can_chat_test.exs:51: (test)
Finished in 6.4 seconds
2 tests, 1 failure, 1 excluded
Once again we see this error, where Ecto requires that we manage the ownership
of processes. Let's set our feature tests async
flag to false
in order to
use the {:shared, pid}
mode:
# test/chatter_web/features/user_can_chat_test.exs
defmodule ChatterWeb.UserCanChatTest do
- use ChatterWeb.FeatureCase, async: true
+ use ChatterWeb.FeatureCase, async: false
Now let's rerun our test:
$ mix test test/chatter_web/features/user_can_chat_test.exs:34
Excluding tags: [:test]
Including tags: [line: "34"]
1) test new user can see previous messages in chat room (ChatterWeb.UserCanChatTest)
** (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("Welcome future users", author: user1))
stacktrace:
test/chatter_web/features/user_can_chat_test.exs:51: (test)
Finished in 6.4 seconds
2 tests, 1 failure, 1 excluded
The first part of our feature is complete: we save messages when they are sent. But Wallaby still can't find the messages because we don't retrieve them when a user first joins a chat room. That's what we'll work on next.
Fetching a chat room's message history
We completed the first part of our feature: saving messages. But now, when users join a chat room, we need to fetch the room's history for them participate in an ongoing conversation.
Let's start by updating our JavaScript code to expect messages in the response
when joining a channel. If you aren't doing so already, run mix assets.watch
in a terminal.
Now, open up chat_room.js
, and update how we respond to a successful
channel.join()
like this:
// assets/js/chat_room.js
channel.join()
.receive("ok", resp => { let messages = resp.messages messages.map(({ author, body }) => { let messageItem = document.createElement("li"); messageItem.dataset.role = "message"; messageItem.innerText = `${author}: ${body}`; messagesContainer.appendChild(messageItem); }); })
We expect a set of messages (with author and body) and iterate over them, creating a list item for each, and appending them to the messages container.
Let's run our test:
$ mix test test/chatter_web/features/user_can_chat_test.exs:34
Excluding tags: [:test]
Including tags: [line: "34"]
1) test new user can see previous messages in chat room (ChatterWeb.UserCanChatTest)
** (Wallaby.JSError) There was an uncaught javascript error:
webpack-internal:///./js/chat_room.js 26:13 Uncaught TypeError: Cannot read property 'map' of undefined
code: |> add_message("Welcome future users")
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:1187: anonymous fn/3 in Wallaby.Browser.execute_query/2
(wallaby 0.26.2) lib/wallaby/browser.ex:148: Wallaby.Browser.retry/2
(wallaby 0.26.2) lib/wallaby/browser.ex:706: Wallaby.Browser.find/2
(wallaby 0.26.2) lib/wallaby/browser.ex:700: Wallaby.Browser.find/3
test/chatter_web/features/user_can_chat_test.exs:67: ChatterWeb.UserCanChatTest.add_message/2
test/chatter_web/features/user_can_chat_test.exs:44: (test)
Finished in 2.2 seconds
2 tests, 1 failure, 1 excluded
Just as it happened in last chapter, we get a webpack-internal
error. But this
time it has a helpful message: Cannot read property 'map' of undefined
. We're
trying to map over resp.messages
, which are undefined
since we haven't
updated our Elixir code to send the messages when joining the channel. Let's go
to the ChatRoomChannel
code to fix that.
Returning a chat room's history on join
At this point, I'd like to "step in" and add a test for ChatRoomChannel
. We
want to test the behavior needed to for our chat_room.js
to render properly,
so we want to make sure messages
are part of the response payload and that
each messages has author
and body
keys. Let's add that test:
# test/chatter_web/channels/chat_room_channel_test.exs
describe "join/3" do
test "returns a list of existing messages" do
email = "random@example.com"
room = insert(:chat_room)
insert_pair(:chat_room_message, chat_room: room)
{:ok, reply, _socket} = join_channel("chat_room:#{room.name}", as: email)
assert [message1, _message2] = reply.messages
assert Map.has_key?(message1, :author)
assert Map.has_key?(message1, :body)
end
end
Now run the test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs:4
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "4"]
1) test join/3 returns a list of existing messages (ChatterWeb.ChatRoomChannelTest)
** (ExMachina.UndefinedFactoryError) No factory defined for :chat_room_message.
Please check for typos or define your factory:
def chat_room_message_factory do
...
end
code: insert_pair(:chat_room_message, chat_room: room)
stacktrace:
(ex_machina 2.4.0) lib/ex_machina.ex:205: ExMachina.build/3
(chatter 0.1.0) test/support/factory.ex:2: Chatter.Factory.insert/2
(elixir 1.11.0) lib/stream.ex:1355: Stream.do_repeatedly/3
(elixir 1.11.0) lib/enum.ex:2859: Enum.take/2
test/chatter_web/channels/chat_room_channel_test.exs:8: (test)
Finished in 0.1 seconds
2 tests, 1 failure, 1 excluded
Aha! We are missing a chat_room_message
factory. Let's define it:
# test/support/factory.ex
def chat_room_message_factory do
%Chatter.Chat.Room.Message{
body: sequence(:body, &"hello there #{&1}"),
author: sequence(:email, &"user#{&1}@example.com"),
chat_room: build(:chat_room)
}
end
And rerun the test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs:4
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "4"]
1) test join/3 returns a list of existing messages (ChatterWeb.ChatRoomChannelTest)
** (KeyError) key :messages not found in: %{}
code: assert [message1, _message2] = reply.messages
stacktrace:
test/chatter_web/channels/chat_room_channel_test.exs:12: (test)
Finished in 0.1 seconds
2 tests, 1 failure, 1 excluded
Now let's add a simple messages response in the payload by adding a second element to our response tuple:
# lib/chatter_web/channels/chat_room_channel.ex
def join("chat_room:" <> room_name, _msg, socket) do
room = Chat.find_room_by_name(room_name)
messages = []
{:ok, %{messages: messages}, assign(socket, :room, room)} end
Just to get the test one step further, we return an empty map of messages in
the reply
portion of our response tuple: {:ok, reply, socket}
. Now, let's
rerun the test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs:4
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "4"]
1) test join/3 returns a list of existing messages (ChatterWeb.ChatRoomChannelTest)
match (=) failed
code: assert [message1, _message2] = reply.messages
left: [message1, _message2]
right: []
stacktrace:
test/chatter_web/channels/chat_room_channel_test.exs:12: (test)
Finished in 0.1 seconds
2 tests, 1 failure, 1 excluded
Excellent. Now we need to get the actual messages. Let's call a non-existent
function Chat.room_messages/1
to get those messages:
# lib/chatter_web/channels/chat_room_channel.ex
def join("chat_room:" <> room_name, _msg, socket) do
room = Chat.find_room_by_name(room_name)
messages = Chat.room_messages(room)
{:ok, %{messages: messages}, assign(socket, :room, room)}
end
Rerun our test:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs:4
Compiling 1 file (.ex)
warning: Chatter.Chat.room_messages/1 is undefined or private
lib/chatter_web/channels/chat_room_channel.ex:8: ChatterWeb.ChatRoomChannel.join/3
Excluding tags: [:test]
Including tags: [line: "4"]
05:14:30.111 [error] GenServer #PID<0.574.0> terminating
** (UndefinedFunctionError) function Chatter.Chat.room_messages/1 is undefined or private
(chatter 0.1.0) Chatter.Chat.room_messages(%Chatter.Chat.Room{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">, id: 380, inserted_at: ~N[2020-10-26 09:14:30], messages: #Ecto.Association.NotLoaded<association :messages is not loaded>, name: "chat room 0", updated_at: ~N[2020-10-26 09:14:30]})
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:8: ChatterWeb.ChatRoomChannel.join/3
(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.572.0>, #Reference<0.3496354261.3092512771.22705>}, %Phoenix.Socket{assigns: %{email: "random@example.com"}, channel: ChatterWeb.ChatRoomChannel, channel_pid: nil, endpoint: ChatterWeb.Endpoint, handler: ChatterWeb.UserSocket, id: "", join_ref: 644, joined: false, private: %{}, pubsub_server: Chatter.PubSub, ref: nil, serializer: Phoenix.ChannelTest.NoopSerializer, topic: "chat_room:chat room 0", transport: :channel_test, transport_pid: #PID<0.572.0>}}
05:14:30.144 [error] an exception was raised:
** (UndefinedFunctionError) function Chatter.Chat.room_messages/1 is undefined or private
(chatter 0.1.0) Chatter.Chat.room_messages(%Chatter.Chat.Room{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">, id: 380, inserted_at: ~N[2020-10-26 09:14:30], messages: #Ecto.Association.NotLoaded<association :messages is not loaded>, name: "chat room 0", updated_at: ~N[2020-10-26 09:14:30]})
(chatter 0.1.0) lib/chatter_web/channels/chat_room_channel.ex:8: ChatterWeb.ChatRoomChannel.join/3
(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
1) test join/3 returns a list of existing messages (ChatterWeb.ChatRoomChannelTest)
** (MatchError) no match of right hand side value: {:error, %{reason: "join crashed"}}
code: {:ok, reply, _socket} = join_channel("chat_room:#{room.name}", as: email)
stacktrace:
test/chatter_web/channels/chat_room_channel_test.exs:10: (test)
Finished in 0.1 seconds
2 tests, 1 failure, 1 excluded
Excellent, our join crashed because we had an UndefinedFunctionError
since our
Chatter.Chat.room_messages/1
is undefined.
Since we're going into our core business logic territory, it's a good time to
"step in" again and write a room_messages/1
test in ChatTest
.
Stepping into Chat
Open up chat_test.exs
, and add the following test:
# test/chatter/chat_test.exs
describe "room_messages/1" do
test "returns all messages associated to given room" do
room = insert(:chat_room)
messages = insert_pair(:chat_room_message, chat_room: room)
_different_room_message = insert(:chat_room_message)
found_messages = Chat.room_messages(room)
assert found_messages == messages
end
end
Note that we add a _different_room_message
to test implicitly that we aren't
returning chat room messages for a different room. Now, let's run it:
$ mix test test/chatter/chat_test.exs:87
Compiling 1 file (.ex)
warning: Chatter.Chat.room_messages/1 is undefined or private
lib/chatter_web/channels/chat_room_channel.ex:8: ChatterWeb.ChatRoomChannel.join/3
Excluding tags: [:test]
Including tags: [line: "87"]
warning: Chatter.Chat.room_messages/1 is undefined or private
test/chatter/chat_test.exs:93: Chatter.ChatTest."test room_messages/1 returns all messages associated to given room"/1
1) test room_messages/1 returns all messages associated to given room (Chatter.ChatTest)
** (UndefinedFunctionError) function Chatter.Chat.room_messages/1 is undefined or private
code: found_messages = Chat.room_messages(room)
stacktrace:
(chatter 0.1.0) Chatter.Chat.room_messages(%Chatter.Chat.Room{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">, id: 381, inserted_at: ~N[2020-10-26 09:18:33], messages: #Ecto.Association.NotLoaded<association :messages is not loaded>, name: "chat room 0", updated_at: ~N[2020-10-26 09:18:33]})
test/chatter/chat_test.exs:93: (test)
Finished in 0.1 seconds
9 tests, 1 failure, 8 excluded
Good. We have the same test failure: ** (UndefinedFunctionError) function
Chatter.Chat.room_messages/1 is undefined or private
. Let's add an empty
function definition:
# lib/chatter/chat.ex
def room_messages(room) do
end
And run the test:
$ mix test test/chatter/chat_test.exs:87
Compiling 2 files (.ex)
warning: variable "room" is unused (if the variable is not meant to be used, prefix it with an underscore)
lib/chatter/chat.ex:34: Chatter.Chat.room_messages/1
Excluding tags: [:test]
Including tags: [line: "87"]
1) test room_messages/1 returns all messages associated to given room (Chatter.ChatTest)
Assertion with == failed
code: assert found_messages == messages
left: nil
right: [%Chatter.Chat.Room.Message{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_room_messages">, author: "user0@example.com", body: "hello there 0", chat_room: %Chatter.Chat.Room{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">, id: 383, inserted_at: ~N[2020-10-26 09:20:35], messages: #Ecto.Association.NotLoaded<association :messages is not loaded>, name: "chat room 0", updated_at: ~N[2020-10-26 09:20:35]}, chat_room_id: 383, id: 20, inserted_at: ~N[2020-10-26 09:20:35], updated_at: ~N[2020-10-26 09:20:35]}, %Chatter.Chat.Room.Message{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_room_messages">, author: "user1@example.com", body: "hello there 1", chat_room: %Chatter.Chat.Room{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">, id: 383, inserted_at: ~N[2020-10-26 09:20:35], messages: #Ecto.Association.NotLoaded<association :messages is not loaded>, name: "chat room 0", updated_at: ~N[2020-10-26 09:20:35]}, chat_room_id: 383, id: 21, inserted_at: ~N[2020-10-26 09:20:35], updated_at: ~N[2020-10-26 09:20:35]}]
stacktrace:
test/chatter/chat_test.exs:95: (test)
Finished in 0.1 seconds
9 tests, 1 failure, 8 excluded
We receive nil
, but our test expects a set of messages. Let's add an
implementation (and we'll need to import Ecto.Query
):
# lib/chatter/chat.ex
import Ecto.Query
# code omitted
def room_messages(room) do Chat.Room.Message |> where([m], m.chat_room_id == ^room.id) |> Repo.all() end
Rerun the test:
$ mix test test/chatter/chat_test.exs:87
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "87"]
1) test room_messages/1 returns all messages associated to given room (Chatter.ChatTest)
test/chatter/chat_test.exs:88
Assertion with == failed
code: assert found_messages == messages
left: [
%Chatter.Chat.Room.Message{
__meta__: #Ecto.Schema.Metadata<:loaded, "chat_room_messages">,
author: "user0@example.com",
body: "hello there 0",
chat_room: #Ecto.Association.NotLoaded<association :chat_room is not loaded>, chat_room_id: 387,
id: 26,
inserted_at: ~N[2020-10-26 09:24:57],
updated_at: ~N[2020-10-26 09:24:57]
},
%Chatter.Chat.Room.Message{
__meta__: #Ecto.Schema.Metadata<:loaded, "chat_room_messages">,
author: "user1@example.com",
body: "hello there 1",
chat_room: #Ecto.Association.NotLoaded<association :chat_room is not loaded>, chat_room_id: 387,
id: 27,
inserted_at: ~N[2020-10-26 09:24:57],
updated_at: ~N[2020-10-26 09:24:57]
}
]
right: [
%Chatter.Chat.Room.Message{
__meta__: #Ecto.Schema.Metadata<:loaded, "chat_room_messages">,
author: "user0@example.com",
body: "hello there 0",
chat_room: %Chatter.Chat.Room{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">, id: 387, inserted_at: ~N[2020-10-26 09:24:57], messages: #Ecto.Association.NotLoaded<association :messages is not loaded>, name: "chat room 0", updated_at: ~N[2020-10-26 09:24:57]}, chat_room_id: 387,
id: 26,
inserted_at: ~N[2020-10-26 09:24:57],
updated_at: ~N[2020-10-26 09:24:57]
},
%Chatter.Chat.Room.Message{
__meta__: #Ecto.Schema.Metadata<:loaded, "chat_room_messages">,
author: "user1@example.com",
body: "hello there 1",
chat_room: %Chatter.Chat.Room{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_rooms">, id: 387, inserted_at: ~N[2020-10-26 09:24:57], messages: #Ecto.Association.NotLoaded<association :messages is not loaded>, name: "chat room 0", updated_at: ~N[2020-10-26 09:24:57]}, chat_room_id: 387,
id: 27,
inserted_at: ~N[2020-10-26 09:24:57],
updated_at: ~N[2020-10-26 09:24:57]
}
]
stacktrace:
test/chatter/chat_test.exs:95: (test)
Finished in 0.2 seconds
9 tests, 1 failure, 8 excluded
At first glance, this might be surprising since those messages look the same. But if you look closely, something is different about them.
Thankfully, ExUnit shows us the difference in colors (though you cannot see it
in my code snippet above): the %Chat.Room.Message{}
structs returned from the
database do not have the chat_room
association loaded, so we see
#Ecto.Assocation.NotLoaded<association :chat_room is not loaded>
. But the
messages built with our testing factory have the chat_room
associations
loaded.
So what to do?
We should ask ourselves, what is the desired behavior? In this case, we don't care whether the associations are loaded or not. So, let's modify our test assertions to check that the messages are the same in essence without caring about all the details.
Let's assert the following about the messages we get from
Chat.room_messages/1
:
- that they have the correct
id
s, - that they have the correct text
body
s, and - that they have the correct text
author
s.
# test/chatter/chat_test.exs
test "returns all messages associated to given room" do
room = insert(:chat_room)
messages = insert_pair(:chat_room_message, chat_room: room)
_different_room_message = insert(:chat_room_message)
found_messages = Chat.room_messages(room)
- assert found_messages == messages
+ assert values_match(found_messages, messages, key: :id)
+ assert values_match(found_messages, messages, key: :body)
+ assert values_match(found_messages, messages, key: :author)
+ end
+
+ defp values_match(found_messages, messages, key: key) do
+ map_values(found_messages, key) == map_values(messages, key)
+ end
+
+ defp map_values(structs, key), do: Enum.map(structs, &Map.get(&1, key))
We create a couple of helper functions to map through the structs, grab each of the properties we care about, and ensure those are the same. It's not a perfect test, but it works for our needs.
Now run the test once more:
$ mix test test/chatter/chat_test.exs:87
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "87"]
.
Finished in 0.1 seconds
9 tests, 0 failures, 8 excluded
Perfect! Let's run our channel test quickly to see if we've satisfied the expectations:
$ mix test test/chatter_web/channels/chat_room_channel_test.exs:4
Excluding tags: [:test]
Including tags: [line: "4"]
.
Finished in 0.1 seconds
2 tests, 0 failures, 1 excluded
Excellent. Now let's go back to our feature test to see what we need to do next.
Back to the future feature test
Let's run our feature test. Be warned: a huge error is about to show up. Here's the pertinent part of the error message:
$ mix test test/chatter_web/features/user_can_chat_test.exs:34
Excluding tags: [:test]
Including tags: [line: "34"]
05:42:36.165 [error] GenServer #PID<0.624.0> terminating
** (Protocol.UndefinedError) protocol Jason.Encoder not implemented for %Chatter.Chat.Room.Message{__meta__: #Ecto.Schema.Metadata<:loaded, "chat_room_messages">, author: "super0@example.com", body: "Welcome future users", chat_room: #Ecto.Association.NotLoaded<association :chat_room is not loaded>, chat_room_id: 414, id: 66, inserted_at: ~N[2020-10-26 09:42:35], updated_at: ~N[2020-10-26 09:42:35]} of type Chatter.Chat.Room.Message (a struct), Jason.Encoder protocol must always be explicitly implemented.
If you own the struct, you can derive the implementation specifying which fields should be encoded to JSON: @derive {Jason.Encoder, only: [....]} defstruct ...
It is also possible to encode all fields, although this should be used carefully to avoid accidentally leaking private information when new fields are added:
@derive Jason.Encoder
defstruct ...
Finally, if you don't own the struct you want to encode to JSON, you may use Protocol.derive/3 placed outside of any module:
Protocol.derive(Jason.Encoder, NameOfTheStruct, only: [...])
Protocol.derive(Jason.Encoder, NameOfTheStruct)
This protocol is implemented for the following type(s): Ecto.Association.NotLoaded, Ecto.Schema.Metadata, Date, BitString, Jason.Fragment, Any, Map, NaiveDateTime, List, Integer, Time, DateTime, Decimal, Atom, Float
(jason 1.2.2) lib/jason.ex:199: Jason.encode_to_iodata!/2
(phoenix 1.5.4) lib/phoenix/socket/serializers/v2_json_serializer.ex:23: Phoenix.Socket.V2.JSONSerializer.encode!/1
(phoenix 1.5.4) lib/phoenix/socket.ex:699: Phoenix.Socket.encode_reply/2
(phoenix 1.5.4) lib/phoenix/socket.ex:621: Phoenix.Socket.handle_in/4
(phoenix 1.5.4) lib/phoenix/endpoint/cowboy2_handler.ex:175: Phoenix.Endpoint.Cowboy2Handler.websocket_handle/2
(stdlib 3.13.1) proc_lib.erl:226: :proc_lib.init_p_do_apply/3
1) test new user can see previous messages in chat room (ChatterWeb.UserCanChatTest)
test/chatter_web/features/user_can_chat_test.exs:34
** (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("Welcome future users", author: user1))
stacktrace:
test/chatter_web/features/user_can_chat_test.exs:51: (test)
Finished in 6.3 seconds
2 tests, 1 failure, 1 excluded
The crucial part is that we have a Protocol.UndefinedError
. We haven't
implemented the Jason.Encoder protocol for %Chatter.Chat.Room.Message{}
.
By attempting to pass our Chat.Room.Message
structs in the payload, Phoenix is
trying to encode them into JSON, but it fails to do so. Thankfully, we have a
helpful error message:
If you own the struct, you can derive the implementation specifying which fields
should be encoded to JSON:
@derive {Jason.Encoder, only: [....]}
defstruct ...
Let's follow that advice and specify the fields that should be encoded to JSON
with the @derive
declaration in our Chat.Room.Message
module:
# lib/chatter/chat/room/message.ex
defmodule Chatter.Chat.Room.Message do
use Ecto.Schema
import Ecto.Changeset
@derive {Jason.Encoder, only: [:author, :body, :chat_room_id]} schema "chat_room_messages" do
Let's run our test again:
$ mix test test/chatter_web/features/user_can_chat_test.exs:34
Compiling 3 files (.ex)
Generated chatter app
Excluding tags: [:test]
Including tags: [line: "34"]
.
Finished in 2.6 seconds
2 tests, 0 failures, 1 excluded
Congratulations! Take a deep breath and give yourself a round of applause. We've done something fantastic. But before we celebrate too much, let's remember to refactor.
Refactoring
I am happy with most of the code we introduced. But I'd like to clean up some
duplication in the chat_room.js
file. It's a small change, but I think it
worth doing.
Currently, we have the same logic for creating messages and appending them to
the messages container: once when we receive a "new_message"
and once when we
iterate through a channel's. Let's extract that logic to have a single way of
creating messages in the DOM.
Refactoring chat_room.js
Let's run both tests in our user_can_chat_test.exs
file:
$ mix test test/chatter_web/features/user_can_chat_test.exs
..
Finished in 4.9 seconds
2 tests, 0 failures
Good. With our baseline set up, let's extract the logic from "new_message"
and
put it in a new function:
# assets/js/chat_room.js
+ const addMessage = (author, body) => {
+ let messageItem = document.createElement("li");
+ messageItem.dataset.role = "message";
+ messageItem.innerText = `${author}: ${body}`;
+ messagesContainer.appendChild(messageItem);
+ }
channel.on("new_message", payload => {
- let messageItem = document.createElement("li");
- messageItem.dataset.role = "message";
- messageItem.innerText = `${payload.author}: ${payload.body}`;
- messagesContainer.appendChild(messageItem);
+ addMessage(payload.author, payload.body);
});
Remember to remove payload
from payload.author
and payload.body
in the
addMessage
function since we pass those directly as arguments.
Now rerun the test:
$ mix test test/chatter_web/features/user_can_chat_test.exs
..
Finished in 6.1 seconds
2 tests, 0 failures
Let's now use that function when we join the channel and get the history of messages:
# assets/js/chat_room.js
channel
.join()
.receive("ok", resp => {
let messages = resp.messages;
messages.map(({ author, body }) => {
- let messageItem = document.createElement("li");
- messageItem.dataset.role = "message";
- messageItem.innerText = `${author}: ${body}`;
- messagesContainer.appendChild(messageItem);
+ addMessage(author, body);
});
})
Run the test one more time:
$ mix test test/chatter_web/features/user_can_chat_test.exs
..
Finished in 6.3 seconds
2 tests, 0 failures
Excellent! I like this version much better.
Wrap up
This concludes the last significant feature of our application. Our users can sign up, sign in, create chat rooms, and chat for days on end without worrying about closing the browser or refreshing the page. And when someone new joins, they can see the thread of the conversation. Yes, I think we have made the world a little bit better.
But this is about test-driven development. What shall we do next about that? How can you sharpen this new tool you have gained? Let's talk about that next.
This is a work in progress.
To find out more, see TDD Phoenix - a work in progress.