TDD Phoenix

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 that user2 is not in the chat room yet.
  • We then create another session, sign in user2, and expect to see the message user1 posted before user2 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:

  1. that they have the correct ids,
  2. that they have the correct text bodys, and
  3. 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 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.