Creating a blog engine using Phoenix and Elixir / Part 10. Testing channels
- Transfer
- Tutorial
From a translator: “Elixir and Phoenix are a great example of where modern web development is going. Already, these tools provide quality access to real-time technology for web applications. Sites with increased interactivity, multi-user browser games, microservices are those areas in which these technologies will serve well. The following is a translation of a series of 11 articles that describe in detail the development aspects of the Phoenix framework, which would seem like a trivial thing like a blog engine. But don’t rush to stumble, it will be really interesting, especially if the articles prompt you to pay attention to Elixir or become his followers. ”
In this part, we will learn how to test channels.
Where did we leave off
At the end of the last part, we completed the cool system of "live" comments for the blog. But to my horror, there was not enough time for tests! Take care of them today. This post will be clear and short, in contrast to the too long previous one.
We pick up trash
Before moving on to the tests, we need to tighten a few places. First, let's include the
flag approved
in the call broadcast
. In this way, we will be able to check in tests the change in the status of confirmation comments.
new_payload = payload
|> Map.merge(%{
insertedAt: comment.inserted_at,
commentId: comment.id,
approved: comment.approved
})
broadcast socket, "APPROVED_COMMENT", new_payload
You also need to modify the file web/channels/comment_helper.ex
so that it responds to empty data sent to the socket by requests for approval / removal of comments. After the function, approve
add:
def approve(_params, %{}), do: {:error, "User is not authorized"}
def approve(_params, nil), do: {:error, "User is not authorized"}
And after the function delete
:
def delete(_params, %{}), do: {:error, "User is not authorized"}
def delete(_params, nil), do: {:error, "User is not authorized"}
This will make the code simpler, error handling is better, and testing easier.
Testing the comment helper
We will use factories that we wrote with ExMachina
earlier. We need to test the creation of a comment, as well as approving / rejecting / deleting a comment based on user authorization. Create a file test/channels/comment_helper_test.exs
and then add the preparatory code to the beginning:
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
A module is used here ModelCase
to add the ability to use a block setup
. The aliases for the modules are added below Comment
, Factory
and CommentHelper
so that it is easier to call their functions.
Then comes the setup of some basic data that can be used in each test. As before, a user, post and comment are created here. But pay attention to the creation of a "fake socket", which includes only the key assigns
. We can pass it in CommentHelper
so that he thinks of him as a real socket.
Then the tuple consisting of the atom :ok
and the dictionary list are returned (as well as in other tests). Let's write the tests ourselves!
Let's start with the simplest test for creating a comment. Since any user can write a comment, no special logic is required here. We verify 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
To do this, we call the function create
from the module CommentHelper
and pass information to it, as if this information was received from the channel.
We proceed to the approval of the comments. Since a bit more authorization logic is used here, the test will be a little 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 creating a comment, we call the function CommentHelper.approve
and pass the information "from the channel" into it. We pass the "fake socket" to the function and it gets access to the value assign
. We test both of them with a valid socket (with a logged-in user) and an invalid socket (with an empty one assign
). Then we just make sure that we get a comment in a positive outcome and an error message in a negative.
Now about the removal tests (which are essentially 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 earlier, our tests are almost identical, except for a positive outcome, in which we make sure that the comment has been deleted and is no longer presented in the database.
Let's check that we cover the code with tests properly. To do this, run the following command:
$ mix test test/channels/comment_helper_test.exs --cover
She will create a [project root]/cover
report in the directory that tells us which code is not covered by the tests. If all tests are green, open the file in a browser ./cover/Elixir.Pxblog.CommentHelper.html
. If you see red, then this code is not covered by tests. The absence of red color means 100% coverage.
The complete comment helper test file is as follows:
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
The generator has already created for us the basis of channel tests, it remains to fill them with meat. Let's start by adding an alias Pxblog.Factory
to use the factories in the block setup
. Actually, everything is as before. Then you need to configure the socket, namely, to introduce yourself as a created user and connect to the comment channel of the created post. Leave the test ping
and broadcast
on the spot, but will remove the tests associated with shout
, because we no longer have that handler. In file 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
We have already written quite complete tests for the module CommentHelper
, so here we leave the tests directly related to the functionality of the channels. Create a test for the three posts 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, then everything will seem new here. Let's step through the steps.
We start by passing the socket and post created in the block to the test setup
. The next line, we send an event to the socket CREATED_COMMENT
together with an associative array, similar to what the client actually sends to the socket.
Next, we describe our "expectations." So far, you cannot define a list that refers to any other variables within the functionassert_broadcast
, so you should develop the habit of defining the expected values separately and passing the variable expected
to the call assert_broadcast
. Here we expect the values body
and to author
match what we passed inward.
Finally, we verify that the message CREATED_COMMENT
was broadcast along with the expected associative array.
Now go to the event APPROVED_COMMENT
:
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
Этот тест в значительной степени похож на предыдущий, за исключением того, что мы передаём в сокет значение approved
равное false
и ожидаем увидеть после выполнения значение approved
равное true
. Обратите внимание, что в переменной expected
мы используем commentId
и postId
как указатели на comment.id
и post.id
. Это выражения вызовут ошибку, поэтому нужно использовать разделение ожидаемой переменной в функции assert_broadcast
.
Наконец, взглянем на тест для сообщения 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
Ничего особо интересного. Передаём стандартные данные в сокет и проверяем, что транслируем событие об удалении комментария.
Подобно тому, как мы поступали с CommentHelper
, запустим тесты конкретно для этого файла с опцией --cover
:
$ mix test test/channels/comment_channel_test.exs --cover
Вы получите предупреждения, что переменная expected
не используется, которые можно благополучно проигнорировать.
test/channels/comment_channel_test.exs:31: warning: variable expected is unused
test/channels/comment_channel_test.exs:37: warning: variable expected is unused
Если вы открыли файл ./cover/Elixir.Pxblog.CommentChannel.html
и не видите ничего красного, то можете кричать "Ура!". Полное покрытие!
Финальная версия теста CommentChannel
полностью должна выглядеть так:
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
Финальные штрихи
Так как отчёт о покрытии тестами можно легко создать с помощью Mix, то не имеет смысла включать его в историю Git, так что откройте файл .gitignore
и добавьте в него следующую строчку:
/cover
That's all! Now we have the channel code completely covered by tests (with the exception of Javascript tests, which are a separate world that does not fit into this series of lessons). In the next part, we will move on to work on the UI, make it a little prettier and more functional, and also replace the standard styles, logos, etc., so that the project looks more professional. In addition, the usability of our site is now absolutely nonexistent. We will fix this too so that people would like to use our blogging platform!