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))
endThe 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 thatuser2is not in the chat room yet. - We then create another session, sign in
user2, and expect to see the messageuser1posted beforeuser2signed 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 excludedGood. 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}
endNote 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 excludedJust 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 failureGood. 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)}
endSince 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 failureThat 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
endNow, 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 excludedGood. 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)
endWe 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 excludedGood, 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 failureWhoa! 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: falseNow 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 failureSince 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/7Aha! 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.EndpointAnd 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 failureGreat. 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
endNow, 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 excludedGood. 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, %{}}
endRun 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 excludedThe 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()
endWe 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 excludedIt 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()
endRerun 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 excludedGood! 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.exsOpen 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
endYou 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
endThe 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 excludedOur 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()
endNow 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 excludedGood, 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
endWe 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 failureThe 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])
endRerun the test:
$ mix test test/chatter/chat/room/message_test.exs
Compiling 2 files (.ex)
.
Finished in 0.03 seconds
1 test, 0 failuresGood! 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
endRun 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 excludedThe 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])
endAnd 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 excludedGood! 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 failuresNice! 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)
endIn 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 failuresGood! 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 excludedExcellent! 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
endTo 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 excludedGood! 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 failuresExcellent! 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}
endNow 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 failuresNow, 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}
+ endAnd 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 failuresGood! 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 excludedOnce 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: falseNow 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 excludedThe 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 excludedJust 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
endNow 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 excludedAha! 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)
}
endAnd 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 excludedNow 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)} endJust 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 excludedExcellent. 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)}
endRerun 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 excludedExcellent, 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
endNote 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 excludedGood. 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
endAnd 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 excludedWe 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() endRerun 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 excludedAt 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
ids, - that they have the correct text
bodys, and - that they have the correct text
authors.
# 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 excludedPerfect! 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 excludedExcellent. 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 excludedThe 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" doLet'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 excludedCongratulations! 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 failuresGood. 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 failuresLet'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 failuresExcellent! 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.