Writing a Blog Engine in Phoenix and Elixir: Part 10, testing channels

Latest Update: 08/02/2016

Hey, I have that same phone!

Previous Post In This Series

Current Versions

  • Elixir: v1.3.1
  • Phoenix: v1.2.0
  • Ecto: v2.0.2

Where We Left Off

When we last left off we had written a pretty cool live commenting system for our blog engine, but much to my dismay, had no time to implement our specs! Let’s take care of that in this tutorial. Since your last post was so lengthy, this one will be nice and quick in comparison!

Cleaning Up Some Cruft

We left a few lose ends that are worth tying up before we get to our specs, too. The first is that our handle_in method for APPROVED_COMMENT, we want to include the approved flag in our broadcast so that we can read that in our tests to make sure the comment was modified to approved.

new_payload = payload
|> Map.merge(%{
insertedAt: comment.inserted_at,
commentId: comment.id,
approved: comment.approved
})
broadcast socket, "APPROVED_COMMENT", new_payload

We also want to modify web/channels/comment_helper.ex to respond to empty/nil data being sent in our socket for approval/deletion requests. After the approve function, add:

def approve(_params, %{}), do: {:error, "User is not authorized"}
def approve(_params, nil), do: {:error, "User is not authorized"}

And after the delete function, add:

def delete(_params, %{}), do: {:error, "User is not authorized"}
def delete(_params, nil), do: {:error, "User is not authorized"}

This will catch our code up in a way that makes it a little simpler, better at handling errors, and more testable!

Testing Comment Helper

We’ll be reusing our Factory that we’ve written in the past with ExMachina. We’ll want to test creating a comment, approving/not approving a comment based on the user’s authorization, and test deleting/not deleting a comment based on the user’s authorization. We’ll start with our setup and creating the file itself. Create test/channels/comment_helper_test.exs, and then at the top, we’ll add a quick shell of a file to start:

defmodule Pxblog.CommentHelperTest do
use Pxblog.ModelCase

alias Pxblog.Comment
alias Pxblog.CommentHelper

import Pxblog.Factory

setup do
user = insert(:user)
post = insert(:post, user: user)
comment = insert(:comment, post: post, approved: false)
fake_socket = %{assigns: %{user: user.id}}

{:ok, user: user, post: post, comment: comment, socket: fake_socket}
end

# Insert our tests after this line

end

We’ll start off using ModelCase so that we can utilize the setup block and our helper here is the closest to a ModelCase. We’ll also alias in Comment, Factory, and CommentHelper so we can call functions from each.

Next, we’ll set up some base data in our setup function to use in each of the other tests. We’ll create a user, post, and comment, which we’ve done in the past. The new thing we’re doing here is creating a fake socket which is just going to contain the assigns key so we can pass that information into CommentHelper and trick it into thinking we passed it a full socket.

Then we return out a tuple with an :ok atom and a dictionary list (just like we do in all of our other tests). Now, let’s start writing our tests!

We’ll start off with our simplest test: creating a comment. Since anyone can create a comment, there’s not really any special logic required. We’ll assert that the comment was indeed created, and that’s…it!

test "creates a comment for a post", %{post: post} do
{:ok, comment} = CommentHelper.create(%{
"postId" => post.id,
"author" => "Some Person",
"body" => "Some Post"
}, %{})
assert comment
assert Repo.get(Comment, comment.id)
end

So we call our create function defined in CommentHelper and pass it in information as if it’d receive the information from our channel! Next, we’ll move on to approving comments, since that has a bit more logic centered around authorization, so it’ll be slightly more complicated:

test "approves a comment when an authorized user", %{post: post, comment: comment, socket: socket} do
{:ok, comment} = CommentHelper.approve(%{"postId" => post.id, "commentId" => comment.id}, socket)
assert comment.approved
end

test "does not approve a comment when not an authorized user", %{post: post, comment: comment} do
{:error, message} = CommentHelper.approve(%{"postId" => post.id, "commentId" => comment.id}, %{})
assert message == "User is not authorized"
end

Similar to the Comment creation function, we invoke the CommentHelper.approve function and pass it information as if we are the channel reaching out. We use our fake socket to pass along so the function has access to that assign value. We test this both with a “valid” socket (keeping a logged in user in the assigns) and with an invalid socket (no user information in the socket’s assigns). Then we just assert out that we get an approved comment in the positive case and an error message in the negative case. Next, the delete tests (which are basically identical):

test "deletes a comment when an authorized user", %{post: post, comment: comment, socket: socket} do
{:ok, comment} = CommentHelper.delete(%{"postId" => post.id, "commentId" => comment.id}, socket)
refute Repo.get(Comment, comment.id)
end

test "does not delete a comment when not an authorized user", %{post: post, comment: comment} do
{:error, message} = CommentHelper.delete(%{"postId" => post.id, "commentId" => comment.id}, %{})
assert message == "User is not authorized"
end

As I mentioned, our tests are borderline identical, except in the positive case we just make sure that the comment we deleted is not still hanging around in the DB!

Let’s make sure we’re covering our code appropriately. We’re going to run the following command:

$ mix test test/channels/comment_helper_test.exs --cover

This will generate a coverage report into [project root]/cover that will tell us if we’ve missed any coverage with our tests. After the command runs (and it should be all green), open up ./cover/Elixir.Pxblog.CommentHelper.html. If you see any red, that means you’re missing coverage, but if there is no red then you have 100% coverage!

Our full final comment helper test file should look like this:

defmodule Pxblog.CommentHelperTest do
use Pxblog.ModelCase

alias Pxblog.Comment
alias Pxblog.CommentHelper

import Pxblog.Factory

setup do
user = insert(:user)
post = insert(:post, user: user)
comment = insert(:comment, post: post, approved: false)
fake_socket = %{assigns: %{user: user.id}}

{:ok, user: user, post: post, comment: comment, socket: fake_socket}
end

# Insert our tests after this line
test "creates a comment for a post", %{post: post} do
{:ok, comment} = CommentHelper.create(%{
"postId" => post.id,
"author" => "Some Person",
"body" => "Some Post"
}, %{})
assert comment
assert Repo.get(Comment, comment.id)
end

test "approves a comment when an authorized user", %{post: post, comment: comment, socket: socket} do
{:ok, comment} = CommentHelper.approve(%{"postId" => post.id, "commentId" => comment.id}, socket)
assert comment.approved
end

test "does not approve a comment when not an authorized user", %{post: post, comment: comment} do
{:error, message} = CommentHelper.approve(%{"postId" => post.id, "commentId" => comment.id}, %{})
assert message == "User is not authorized"
end

test "deletes a comment when an authorized user", %{post: post, comment: comment, socket: socket} do
{:ok, comment} = CommentHelper.delete(%{"postId" => post.id, "commentId" => comment.id}, socket)
refute Repo.get(Comment, comment.id)
end

test "does not delete a comment when not an authorized user", %{post: post, comment: comment} do
{:error, message} = CommentHelper.delete(%{"postId" => post.id, "commentId" => comment.id}, %{})
assert message == "User is not authorized"
end
end

Testing The Comment Channel

Our tests for the comment channel have already been started by the generator, but we need to intervene and flesh them out a little more. We’ll start by aliasing Pxblog.Factory so we can use our generators, and in the setup block we’ll create a simple user, post, and comment. We’ll also set up our socket so that we’re logged in as the current user and we’ll just the comments channel for the sample post we created. We’re also going to leave the ping and broadcast tests in place, but we’ll delete the test related to the shout broadcast since we don’t have a handler for that anymore. In test/channels/comment_channel_test.exs:

defmodule Pxblog.CommentChannelTest do
use Pxblog.ChannelCase

alias Pxblog.CommentChannel
alias Pxblog.Factory

setup do
user = Factory.create(:user)
post = Factory.create(:post, user: user)
comment = Factory.create(:comment, post: post, approved: false)

{:ok, _, socket} =
socket("user_id", %{user: user.id})
|> subscribe_and_join(CommentChannel, "comments:#{post.id}")

{:ok, socket: socket, post: post, comment: comment}
end

test "ping replies with status ok", %{socket: socket} do
ref = push socket, "ping", %{"hello" => "there"}
assert_reply ref, :ok, %{"hello" => "there"}
end

test "broadcasts are pushed to the client", %{socket: socket} do
broadcast_from! socket, "broadcast", %{"some" => "data"}
assert_push "broadcast", %{"some" => "data"}
end
end

Now, we’ve also already written pretty comprehensive tests for our CommentHelper utility module, so I’m going to keep these tests specific to channel functionality. I’ll create a test for our three messages: CREATED_COMMENT, APPROVED_COMMENT, and DELETED_COMMENT.

test "CREATED_COMMENT broadcasts to comments:*", %{socket: socket, post: post} do
push socket, "CREATED_COMMENT", %{"body" => "Test Post", "author" => "Test Author", "postId" => post.id}
expected = %{"body" => "Test Post", "author" => "Test Author"}
assert_broadcast "CREATED_COMMENT", expected
end

If you’ve never seen channel tests before this whole thing will likely be new, so we’ll step through it line by line. We start off by passing in the socket to our test (that we created in our setup block) and the factory-built post.

In the next line, we push out to our socket a “CREATED_COMMENT” event, and we pass in the payload a map similar to what the client would push to our socket.

We then set up our expectation. As of right now, you cannot declare a map that references any other variables inside of an assert_broadcast function, so we’re going to get into the habit of setting up our expected value separately and passing in the expected variable to our assert_broadcast call. We expect the body and author values to match what we passed in.

Finally, we assert that “CREATED_COMMENT” has been broadcasted out with our expected map. Now, on to our APPROVED_COMMENT event:

test "APPROVED_COMMENT broadcasts to comments:*", %{socket: socket, post: post, comment: comment} do
push socket, "APPROVED_COMMENT", %{"commentId" => comment.id, "postId" => post.id, approved: false}
expected = %{"commentId" => comment.id, "postId" => post.id, approved: true}
assert_broadcast "APPROVED_COMMENT", expected
end

This test is pretty much identical, except we start off pushing to the socket an “approved” value of false, and we expect to see an “approved” value of true when it is done! Note that in the expected variable, we reference commentId and postId as pointing to comment.id and post.id. These are the statements that will fail and why we have to use a separate expected variable in our assert_broadcast function. Finally, we’ll look at the test for DELETED_COMMENT:

test "DELETED_COMMENT broadcasts to comments:*", %{socket: socket, post: post, comment: comment} do
payload = %{"commentId" => comment.id, "postId" => post.id}
push socket, "DELETED_COMMENT", payload
assert_broadcast "DELETED_COMMENT", payload
end

Nothing very exciting here by this point! We pass a standard payload, push it onto our socket, and assert that we’re broadcasting out that a comment has been deleted!

Similar to what we did with the CommentHelper, we’ll run our tests for this file specifically and invoke the cover option again:

$ mix test test/channels/comment_channel_test.exs --cover

You’ll likely get some warnings about variable expected is unused which you can safely ignore. They have to do with how the assert_broadcast macros play out (also related to the code we have to deal with to keep assert_broadcast from dying on us)

test/channels/comment_channel_test.exs:31: warning: variable expected is unused
test/channels/comment_channel_test.exs:37: warning: variable expected is unused

If you open up ./cover/Elixir.Pxblog.CommentChannel.html and see no red, then hurray! Full coverage!

Our full final Comment Channel test should look like this:

defmodule Pxblog.CommentChannelTest do
use Pxblog.ChannelCase

alias Pxblog.CommentChannel
import Pxblog.Factory

setup do
user = insert(:user)
post = insert(:post, user: user)
comment = insert(:comment, post: post, approved: false)

{:ok, _, socket} =
socket("user_id", %{user: user.id})
|> subscribe_and_join(CommentChannel, "comments:#{post.id}")

{:ok, socket: socket, post: post, comment: comment}
end

test "ping replies with status ok", %{socket: socket} do
ref = push socket, "ping", %{"hello" => "there"}
assert_reply ref, :ok, %{"hello" => "there"}
end

test "broadcasts are pushed to the client", %{socket: socket} do
broadcast_from! socket, "broadcast", %{"some" => "data"}
assert_push "broadcast", %{"some" => "data"}
end

test "CREATED_COMMENT broadcasts to comments:*", %{socket: socket, post: post} do
push socket, "CREATED_COMMENT", %{"body" => "Test Post", "author" => "Test Author", "postId" => post.id}
expected = %{"body" => "Test Post", "author" => "Test Author"}
assert_broadcast "CREATED_COMMENT", expected
end

test "APPROVED_COMMENT broadcasts to comments:*", %{socket: socket, post: post, comment: comment} do
push socket, "APPROVED_COMMENT", %{"commentId" => comment.id, "postId" => post.id, approved: false}
expected = %{"commentId" => comment.id, "postId" => post.id, approved: true}
assert_broadcast "APPROVED_COMMENT", expected
end

test "DELETED_COMMENT broadcasts to comments:*", %{socket: socket, post: post, comment: comment} do
payload = %{"commentId" => comment.id, "postId" => post.id}
push socket, "DELETED_COMMENT", payload
assert_broadcast "DELETED_COMMENT", payload
end
end

Final Tweaks

Since we’re using coverage with our mix test command, we probably don’t want to include coverage reports in our git history, so open up .gitignore and add the following line:

/cover

And that’s it! Now we have full coverage for all of our channel-related code that we introduced (except for Javascript tests, which is a whole realm of things that I won’t be dealing with in this tutorial series). We’ll move on in the next post in this series to cleaning up the UI, making it all a little nicer and more functional, and including/replacing the default Phoenix UI styling/logos/etc to make our project look more professional! In addition, the usability of our site is pretty bad right now, so we’ll clean that up too and make people want to use our blogging platform!

Next Post in this Series

Check out my new book!

Hey everyone! If you liked what you read here and want to learn more with me, check out my new book on Elixir and Phoenix web development:

I’m really excited to finally be bringing this project to the world! It’s written in the same style as my other tutorials where we will be building the scaffold of a full project from start to finish, even covering some of the trickier topics like file uploads, Twitter/Google OAuth logins, and APIs!