TDD Phoenix

Adding Authors to Chat

Our users can sign up, sign in, and chat. But all messages are anonymous. Our next step is to set users' emails as their handles so that messages have an author. After all, it's tough to follow a conversation without knowing who wrote what.

To start, consider which test to run. I don't think we need a new test since the core of the functionality — users chatting — is already in place. Instead, we can update the feature test where two users chat to include authors of messages.

Let's update our existing test to how we want it to work from now on. It will have some test failures that we can follow until the test passes. Let's get started.

Adding authors to UserCanChatTest

Open up your test/chatter_web/features/users_can_chat_test.exs file. If you recall, the test has two users signing in with their unique emails via our sig_in/2 helper and posting messages. All we need to update is the assert_has(session, message(text)) to account for the author's email. We'll go with a straightforward way to do that; we'll expect the message to include the author's email. Change the following:

# test/chatter_web/features/user_can_chat_test.exs

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

     session1 =
       metadata
       |> new_session()
       |> visit(rooms_index())
       |> sign_in(as: user1)
       |> join_room(room.name)

     session2 =
       metadata
       |> new_session()
       |> visit(rooms_index())
       |> sign_in(as: user2)
       |> join_room(room.name)

     session1
     |> add_message("Hi everyone")

     session2
-    |> assert_has(message("Hi everyone"))
+    |> assert_has(message(user1, "Hi everyone"))
     |> add_message("Hi, welcome to #{room.name}")

     session1
-    |> assert_has(message("Hi, welcome to #{room.name}"))
+    |> assert_has(message(user2, "Hi, welcome to #{room.name}"))
   end

# code omitted

-  defp message(text) do
-    Query.data("role", "message", text: text)
+  defp message(author, text) do
+    message = "#{author.email}: #{text}"
+    Query.data("role", "message", text: message)
   end
 end

We change the message/1 function to take an additional argument: it now takes the expected author and message text. We combine those two arguments to form the text for Wallaby's query — think of something like "John: Hi everyone". Keep in mind that each user expects their browser session to show the opposite user's message: session1 asserts that user2 sent the message, and session2 asserts that user1 sent the message.

If you stopped running webpack, start it now. We'll be dealing with JavaScript, so we want those files to recompiled as we make changes.

Now, let's run our test!

$ mix test test/chatter_web/features/user_can_chat_test.exs:10
Compiling 31 files (.ex)
Generated chatter app
Excluding tags: [:test]
Including tags: [line: "10"]



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

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



Finished in 5.3 seconds
1 test, 1 failure

Randomized with seed 402536

Wallaby cannot find the new text with user1's email because we have not changed our implementation. But the error doesn't tell us what we need to do next, unlike errors we might see when a route or a view is missing. Nevertheless, we have a good guess: the implementation where we render new messages. So let's update our JavaScript code to render an author's email along with the message body.

Open up chat_room.js and find where we receive the "new_message" event. We will act as though our payload already has an author key along with the body:

# assets/js/chat_room.js

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

Now rerun our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs:10
Excluding tags: [:test]
Including tags: [line: "10"]



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

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



Finished in 5.5 seconds
1 test, 1 failure

Hmmm, 🤔. Unfortunately, no JavaScript errors were raised. If there were any, Wallaby would have notified us. It's a good opportunity to use Wallaby's take_screenshot/1 helper. Place it on the pipeline right before assert_has(message(user1, "Hi everyone")):

# test/chatter_web/features/user_can_chat_test.exs

     session1
     |> add_message("Hi everyone")

     session2
+    |> take_screenshot()
     |> assert_has(message(user1, "Hi everyone"))
     |> add_message("Hi, welcome to #{room.name}")

Now rerun the test again, but this time when it fails, open up the screenshot it saved in screenshots/ (your screenshot's filename will be different than mine):

$ open screenshots/1581068254007662000.png

The screenshot shows our "chat room 0" with one message: undefined: Hi everyone. So payload.author in chat_room.js is undefined. That's expected. Delete the screenshot Wallaby saved, and remove the take_screenshot/1 function from our test. Let's fix the undefined author by adding it to the payload sent from our channel. That's where we'll go next.

Setting the author in outgoing payload

Let's up ChatterWeb.ChatRoomChannel and write the code as we wish it worked already.

Our handle_in/3 function currently takes a message from JavaScript (we call it payload) and broadcasts it without modification. But now we want to augment the broadcast with the author's email. The question is, how should we get the author's email?

We have a couple of options:

  1. We could pass the author's email in the payload from JavaScript, or
  2. We could set the author's email in our socket when a user first connects to the UserSocket

Since a user's email does not vary between messages, the second option seems like an excellent fit for our needs.

With that decided, we can assume that our socket in ChatRoomChannel will contain the author's email. So, update our handle_in/3 function to create a new outoing_payload based on the incoming payload and the email in socket.assigns:

# lib/chatter_web/channels/chat_room_channel.ex

   def handle_in("new_message", payload, socket) do
-    broadcast(socket, "new_message", payload)
+    author = socket.assigns.email
+    outgoing_payload = Map.put(payload, "author", author)
+    broadcast(socket, "new_message", outgoing_payload)
     {:noreply, socket}
   end
 end

Now let's rerun our feature test:

$ mix test test/chatter_web/features/user_can_chat_test.exs:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

05:11:40.842 [error] GenServer #PID<0.582.0> terminating
** (KeyError) key :email not found in: %{}
    (chatter) lib/chatter_web/channels/chat_room_channel.ex:9: ChatterWeb.ChatRoomChannel.handle_in/3
    (phoenix) lib/phoenix/channel/server.ex:284: anonymous fn/4 in Phoenix.Channel.Server.handle_info/2
    (chatter) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.instrument/4
    (stdlib) gen_server.erl:637: :gen_server.try_dispatch/4
    (stdlib) gen_server.erl:711: :gen_server.handle_msg/6
    (stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3
Last message: %Phoenix.Socket.Message{event: "new_message", join_ref: "1", payload: %{"body" => "Hi everyone"}, ref: "2", topic: "chat_room:chat room 0"}


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

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



Finished in 5.3 seconds
1 test, 1 failure

Great. We have a helpful error: ** (KeyError) key :email not found in: %{}. We need that email key in our socket. Let's dive into UserSocket.connect/3 to set the email next.

Putting the email on the socket assigns

As usual, let's write our code as we wish it existed. In this case, we'd like to get the user's email from the front-end:

# lib/chatter_web/channels/user_socket.ex

-  def connect(_params, socket, _connect_info) do
-    {:ok, socket}
+  def connect(%{"email" => email}, socket, _connect_info) do
+    {:ok, assign(socket, :email, email)}
   end

+  def connect(_, _, _), do: :error

We receive an email in the params and assign it to the socket. We also add a second connect/3 function that will return :error if the params do not have an email.

Let's run our test again:

$ mix test test/chatter_web/features/user_can_chat_test.exs:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]



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

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



Finished in 5.3 seconds
1 test, 1 failure

This is both good news and bad news. The good news is that we've made it one step further: we're no longer getting the error (KeyError) key :email not found in: %{} from our ChatRoomChannel. Setting the email in the socket on when connecting worked.

The bad news is that we're back to Wallaby errors that aren't immediately actionable. Nevertheless, we have a good sense of what to do next: we know our JavaScript does not send a user's email when connecting to the socket. Let's update that next.

Connecting to a socket with an email

To send the user's email when connecting to the socket, we have to pass it from Elixir to JavaScript. There are many ways we could accomplish this. I'll take the following approach: if a user is authenticated, we'll set the email in conn.assigns, then we'll grab that value and set it on window.email so that JavaScript has access to it.

Let's work backwards and write the JavaScript code as though window.email was set. Open up socket.js, and update the connection to pass params:

# assets/js/socket.js

-let socket = new Socket("/socket", {params: {}})
+let socket = new Socket("/socket", {params: {email: window.email}})

Now rerun the test to get a cryptic error:

$ mix test test/chatter_web/features/user_can_chat_test.exs:10
Excluding tags: [:test]
Including tags: [line: "10"]



  1) test user can chat with others successfully (ChatterWeb.UserCanChatTest)
     test/chatter_web/features/user_can_chat_test.exs:4
     ** (RuntimeError) Wallaby had an internal issue with HTTPoison:
     %HTTPoison.Error{id: nil, reason: :timeout}
     code: |> visit(rooms_index())
     stacktrace:
       (wallaby) lib/wallaby/httpclient.ex:37: Wallaby.HTTPClient.make_request/5
       (wallaby) lib/wallaby/phantom/driver.ex:159: anonymous fn/2 in Wallaby.Phantom.Driver.visit/2
       (wallaby) lib/wallaby/driver/log_checker.ex:6: Wallaby.Driver.LogChecker.check_logs!/2
       (wallaby) lib/wallaby/browser.ex:710: Wallaby.Browser.visit/2
       test/chatter_web/features/user_can_chat_test.exs:12: (test)



Finished in 25.9 seconds
1 test, 1 failure

Though the error is not helpful, it's safe to assume it is happening because of our change — after all, Wallaby was throwing a different error before. So let's set window.email to see if we can move past this error.

In our app layout, add the following script tag before our javascript tag loading the app.js file:

# lib/chatter_web/templates/layout/app.html.eex

       <p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>
       <%= render @view_module, @view_template, assigns %>
     </main>
+    <script>window.email = "<%= assigns[:email] %>";</script>
     <script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>
   </body>
 </html>

Now rerun our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]



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

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



Finished in 5.4 seconds
1 test, 1 failure

We're past the cryptic error, but this error doesn't point us in any direction. Nevertheless, if we're following our train of thought correctly, we should suspect the new error happens because we never set the email in the assigns. My theory is that assigns[:email] is nil, and that's what we set on window.email.

Let's test that theory by inspecting the email being passed to UserSocket.connect/3. Drop an IO.inspect/2 in our connection:

# lib/chatter_web/channels/user_socket.ex

   def connect(%{"email" => email}, socket, _connect_info) do
+    IO.inspect(email, label: "email")
     {:ok, assign(socket, :email, email)}
   end

Now let's rerun our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs:10
Compiling 1 file (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

email: ""
email: ""
email: ""
email: ""
email: ""
email: ""
email: ""
email: ""


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

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



Finished in 5.4 seconds
1 test, 1 failure

Good! That confirms the theory that assigns[:email] is not set, so window.email is sending an empty string when trying to connect. Remove that IO.inspect/2, and let's set the email in our conn.assigns next.

Passing the email from Elixir to JavaScript

We want to put the user's email as an assign when the user is authenticated. We can do that easily by creating a plug in our router. Open up ChatterWeb.Router, and add a :put_user_email plug at the end of the :browser pipeline:

# lib/chatter_web/router.ex

   pipeline :browser do
     plug :accepts, ["html"]
     plug :fetch_session
     plug :fetch_flash
     plug :protect_from_forgery
     plug :put_secure_browser_headers
     plug Doorman.Login.Session
+    plug :put_user_email
   end

+
+  defp put_user_email(conn, _) do
+    if current_user = conn.assigns[:current_user] do
+      assign(conn, :email, current_user.email)
+    else
+      conn
+    end
+  end

Since we set the current_user for authenticated users, we can use it as a proxy for an authentication check and as the user's email source. So we'll set the email if we have a current_user in our conn.assigns. Otherwise, we'll pass the connection struct as is.

Let's rerun our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs:10
Compiling 4 files (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

.

Finished in 2.5 seconds
1 test, 0 failures

Well done!

Before diving into refactoring, consider that our channel payload and socket connection changes likely broke our channel test. Let's fix that next.

Updating the ChatRoomChannelTest

First, run the ChatRoomChannelTest to confirm it is failing:

$ mix test test/chatter_web/channels/chat_room_channel_test.exs:5
Excluding tags: [:test]
Including tags: [line: "5"]

05:04:57.976 [error] GenServer #PID<0.547.0> terminating
** (KeyError) key :email not found in: %{}
    (chatter) lib/chatter_web/channels/chat_room_channel.ex:9: ChatterWeb.ChatRoomChannel.handle_in/3
    (phoenix) lib/phoenix/channel/server.ex:284: anonymous fn/4 in Phoenix.Channel.Server.handle_info/2
    (chatter) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.instrument/4
    (stdlib) gen_server.erl:637: :gen_server.try_dispatch/4
    (stdlib) gen_server.erl:711: :gen_server.handle_msg/6
    (stdlib) proc_lib.erl:249: :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.2377491105.3953655814.150016>, topic: "chat_room:general"}


  1) test new_message event broadcasts message to all users (ChatterWeb.ChatRoomChannelTest)
     test/chatter_web/channels/chat_room_channel_test.exs:5
     ** (EXIT from #PID<0.545.0>) an exception was raised:
         ** (KeyError) key :email not found in: %{}
             (chatter) lib/chatter_web/channels/chat_room_channel.ex:9: ChatterWeb.ChatRoomChannel.handle_in/3
             (phoenix) lib/phoenix/channel/server.ex:284: anonymous fn/4 in Phoenix.Channel.Server.handle_info/2
             (chatter) lib/chatter_web/endpoint.ex:1: ChatterWeb.Endpoint.instrument/4
             (stdlib) gen_server.erl:637: :gen_server.try_dispatch/4
             (stdlib) gen_server.erl:711: :gen_server.handle_msg/6
             (stdlib) proc_lib.erl:249: :proc_lib.init_p_do_apply/3



Finished in 0.06 seconds
1 test, 1 failure

Our channel implementation is trying to get a socket.assigns.email, but our test does not set it in the connection setup. Let's update the test to do so:

# test/chatter_web/channels/chat_room_channel_test.exs

   describe "new_message event" do
     test "broadcasts message to all users" do
-      {:ok, _, socket} = join_channel("chat_room:general")
+      email = "random@example.com"
+      {:ok, _, socket} = join_channel("chat_room:general", as: email)
       payload = %{"body" => "hello world!"}

       push(socket, "new_message", payload)

       assert_broadcast "new_message", ^payload
     end

-    defp join_channel(topic) do
+    defp join_channel(topic, as: email) do
       ChatterWeb.UserSocket
-      |> socket("", %{})
+      |> socket("", %{email: email})
       |> subscribe_and_join(ChatterWeb.ChatRoomChannel, topic)
     end
   end

We modify our join_channel/1 helper function to take a second argument for the email. Then, we set that email in the socket assigns when calling socket/3.

Now let's rerun our channel test:

$ mix test test/chatter_web/channels/chat_room_channel_test.exs:5
Excluding tags: [:test]
Including tags: [line: "5"]



  1) test new_message event broadcasts message to all users (ChatterWeb.ChatRoomChannelTest)
     test/chatter_web/channels/chat_room_channel_test.exs:5
     No message matching %Phoenix.Socket.Broadcast{event: "new_message", payload: ^payload} after 100ms.
     The following variables were pinned:
       payload = %{"body" => "hello world!"}
     Process mailbox:
       %Phoenix.Socket.Broadcast{event: "new_message", payload: %{"author" => "random@example.com", "body" => "hello world!"}, topic: "chat_room:general"}
       %Phoenix.Socket.Message{event: "new_message", join_ref: nil, payload: %{"author" => "random@example.com", "body" => "hello world!"}, ref: nil, topic: "chat_room:general"}
     code: assert_broadcast "new_message", ^payload
     stacktrace:
       test/chatter_web/channels/chat_room_channel_test.exs:12: (test)



Finished in 0.1 seconds
1 test, 1 failure

Our broadcast payload has changed, so our test is failing. Phoenix's error is thankfully very helpful: it shows us the process's mailbox, and we see the payload we should be expecting in the broadcast struct (which includes the author):

%Phoenix.Socket.Broadcast{
  event: "new_message",
  payload: %{"author" => "random@example.com", "body" => "hello world!"},
  topic: "chat_room:general"
}

Let's update our assertion to include the author in the expected payload:

# test/chatter_web/channels/chat_room_channel_test.exs

     test "broadcasts message to all users" do
       email = "random@example.com"
       {:ok, _, socket} = join_channel("chat_room:general", as: email)
       payload = %{"body" => "hello world!"}

       push(socket, "new_message", payload)

-      assert_broadcast "new_message", ^payload
+      expected_payload = Map.put(payload, "author", email)
+      assert_broadcast "new_message", ^expected_payload
     end

Now rerun our test:

$ mix test test/chatter_web/channels/chat_room_channel_test.exs:5
Excluding tags: [:test]
Including tags: [line: "5"]

.

Finished in 0.04 seconds
1 test, 0 failures

Excellent! Our channel test is passing again.

Now that we have fixed our ChatRoomChannel, let's run our test suite to make sure we haven't broken anything unexpectedly.

$ mix test
........................

Finished in 5.9 seconds
24 tests, 0 failures

Ah, nothing like a screen full of green dots. Perfect! This is a great place to commit our work. We'll consider what to refactor next.

Refactoring

Most of the code we introduced is straightforward and does not need refactoring. But there is one thing we can do: I prefer having custom plugs outside of the Router module to keep it focused on routes. So let's extract the :put_user_email function into a module plug. As usual, we'll keep a test running as we refactor to ensure our application's behavior doesn't change.

Let's start! Run our user_can_chat feature test:

$ mix test test/chatter_web/features/user_can_chat_test.exs:10
Excluding tags: [:test]
Including tags: [line: "10"]

.

Finished in 2.8 seconds
1 test, 0 failures

Open lib/chatter_web/router.ex and copy put_user_email/2. Now create a new file under lib/chatter_web/plugs/ called put_user_email.ex, and paste the body of the function we copied into a call/2 function. We'll import Plug.Conn to get helpers like assign/3, and we'll define an init/1 function since module plugs need to have one:

# lib/chatter_web/plugs/put_user_email.ex

defmodule ChatterWeb.Plugs.PutUserEmail do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    if current_user = conn.assigns[:current_user] do
      assign(conn, :email, current_user.email)
    else
      conn
    end
  end
end

Now let's set that plug as part of our pipeline right after the :put_user_email plug:

# lib/chatter_web/router.ex

     plug :put_secure_browser_headers
     plug Doorman.Login.Session
     plug :put_user_email
+    plug Plugs.PutUserEmail
   end

And now rerun our test:

$ mix test test/chatter_web/features/user_can_chat_test.exs:10
Compiling 3 files (.ex)
Generated chatter app
Excluding tags: [:test]
Including tags: [line: "10"]

.

Finished in 2.6 seconds
1 test, 0 failures

Great. Since our new module plug works, we can safely remove the :put_user_email function plug:

# lib/chatter_web/router.ex

     plug :protect_from_forgery
     plug :put_secure_browser_headers
     plug Doorman.Login.Session
-    plug :put_user_email
     plug Plugs.PutUserEmail
   end

# code omitted
-
-  defp put_user_email(conn, _) do
-    if current_user = conn.assigns[:current_user] do
-      assign(conn, :email, current_user.email)
-    else
-      conn
-    end
-  end
 end

And run our test once more:

$ mix test test/chatter_web/features/user_can_chat_test.exs:10
Compiling 2 files (.ex)
Excluding tags: [:test]
Including tags: [line: "10"]

.

Finished in 2.5 seconds
1 test, 0 failures

Great! Now is a good time to commit our changes.

Wrap up

In this chapter, we saw that test-driving with JavaScript can be difficult when errors don't directly inform us of the next step. But we also saw some strategies to keep us moving forward: Wallaby's take_screenshot/1 and IO.inspect/2 both unblocked us when errors were confusing. Whatever the strategy, we aimed to confirm why an error happened and only then move forward.

Up next, we'll add some history to our chat rooms. Currently, users only see those messages that come after they join a channel. That's not a great experience, so let's fix that in our next chapter.