Creating a blog engine using Phoenix and Elixir / Part 10. Testing channels

Original author: Brandon Richey
  • 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 approvedin 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.exso that it responds to empty data sent to the socket by requests for approval / removal of comments. After the function, approveadd:


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 ExMachinaearlier. 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.exsand 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 ModelCaseto add the ability to use a block setup. The aliases for the modules are added below Comment, Factoryand CommentHelperso 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 CommentHelperso that he thinks of him as a real socket.


Then the tuple consisting of the atom :okand 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  createfrom the module  CommentHelperand 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.approveand 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]/coverreport 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.Factoryto 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 pingand broadcaston 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_COMMENTand 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_COMMENTtogether 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 expectedto the call assert_broadcast. Here we expect the values bodyand to authormatch what we passed inward.


Finally, we verify that the message CREATED_COMMENTwas 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!


Other articles in the series


  1. Introduction
  2. Login
  3. Add Roles
  4. We process roles in controllers
  5. We connect ExMachina
  6. Markdown Support
  7. Add comments
  8. Finish with comments
  9. Channels
  10. Channel testing
  11. Conclusion

Also popular now: