Pull to refresh

Создание движка для блога с помощью Phoenix и Elixir / Часть 10. Тестирование каналов

Reading time 9 min
Views 3K
Original author: Brandon Richey


От переводчика: «Elixir и Phoenix — прекрасный пример того, куда движется современная веб-разработка. Уже сейчас эти инструменты предоставляют качественный доступ к технологиям реального времени для веб-приложений. Сайты с повышенной интерактивностью, многопользовательские браузерные игры, микросервисы — те направления, в которых данные технологии сослужат хорошую службу. Далее представлен перевод серии из 11 статей, подробно описывающих аспекты разработки на фреймворке Феникс казалось бы такой тривиальной вещи, как блоговый движок. Но не спешите кукситься, будет действительно интересно, особенно если статьи побудят вас обратить внимание на Эликсир либо стать его последователями.»


В этой части мы научимся тестировать каналы.


На чём мы остановились


В конце прошлой части мы доделали классную систему «живых» комментариев для блога. Но к ужасу, на тесты не хватило времени! Займёмся ими сегодня. Этот пост будет понятным и коротким, в отличие от чересчур длинного предыдущего.


Прибираем хлам


Прежде, чем перейти к тестам, нам нужно подтянуть несколько мест. Во-первых, давайте включим
флаг approved в вызов broadcast. Таким образом мы сможем проверять в тестах изменение состояния подтверждения комментариев.


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

broadcast socket, "APPROVED_COMMENT", new_payload

Также нужно изменить файл web/channels/comment_helper.ex, чтобы он реагировал на пустые данные, отправляемые в сокет запросами на одобрение/удаление комментариев. После функции approve добавьте:


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

А после функции delete:


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

Это позволит сделать код проще, обработку ошибок – лучше, а тестирование – легче.


Тестируем хелпер комментариев


Будем использовать фабрики, которые написали с помощью ExMachina ранее. Нам нужно протестировать создание комментария, а также одобрение/отклонение/удаление комментария на основе авторизации пользователя. Создадим файл test/channels/comment_helper_test.exs, а затем добавим подготовительный код в начало:


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

Здесь используется модуль ModelCase для добавления возможности использования блока setup. Ниже добавляются алиасы для модулей Comment, Factory и CommentHelper, чтобы можно было проще вызывать их функции.


Затем идёт настройка некоторых основных данных, которые можно будет использовать в каждом тесте. Также как и раньше, здесь создаются пользователь, пост и комментарий. Но обратите внимание на создание "фальшивого сокета", который включает в себя лишь ключ assigns. Мы можем передать его в CommentHelper, чтобы тот думал о нём как о настоящем сокете.


Затем возвращается кортеж, состоящий из атома :ok и словарь-список (также как и в других тестах). Давайте уже напишем сами тесты!


Начнём с простейшего теста на создание комментария. Так как комментарий может написать любой пользователь, здесь не требуется никакой специальной логики. Мы проверяем, что комментарий действительно был создан и… всё!


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

Для этого вызываем функцию create из модуля CommentHelper и передаём в неё информацию, как будто это информация была получена из канала.


Переходим к одобрению комментариев. Так как здесь используется немного больше логики, связанной с авторизацией, тест будет чуть более сложным:


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

Схожим с созданием комментария образом, мы вызываем функцию CommentHelper.approve и передаём в неё информацию "из канала". Мы передаём "фальшивый сокет" в функцию и она получает доступ к значению assign. Мы тестируем их оба с помощью валидного сокета (с вошедшим в систему пользователем) и невалидного сокета (с пустым assign). Затем просто проверяем, что получаем комментарий в положительном исходе и сообщение об ошибке в отрицательном.


Теперь о тестах на удаление (которые по сути идентичны):


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

Как я упоминал ранее, наши тесты практически идентичны, за исключением положительного исхода, в котором мы убеждаемся что комментарий был удалён и больше не представлен в базе данных.


Давайте проверим, что мы покрываем код тестами должным образом. Для этого запустите следующую команду:


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

Она создаст в директории [project root]/cover отчёт, который скажет нам какой код не покрыт тестами. Если все тесты зелёные, откройте файл в браузере ./cover/Elixir.Pxblog.CommentHelper.html. Если вы видите красный цвет, значит этот код не покрыт тестами. Отсутствие красного цвета означает 100% покрытие.


Полностью файл с тестами хелпера комментариев выглядит следующим образом:


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

Тестируем канал комментариев


Генератор уже создал для нас основу тестов каналов, осталось наполнить их мясом. Начнём с добавления алиаса Pxblog.Factory для использования фабрик в блоке setup. Собственно, всё как и раньше. Затем необходимо настроить сокет, а именно, представиться созданным пользователем и подключиться к каналу комментариев созданного поста. Оставим тесты ping и broadcast на месте, но удалим тесты, связанные с shout, поскольку у нас больше нет этого обработчика. В файле 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

У нас уже написаны довольно полноценные тесты для модуля CommentHelper, так что здесь оставим тесты, непосредственно связанные с функциональностью каналов. Создадим тест для трёх сообщений: CREATED_COMMENT, APPROVED_COMMENT и 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

Если вы никогда раньше не видели тесты каналов, то здесь всё покажется в новинку. Давайте разбираться по шагам.


Начинаем с передачи в тест сокета и поста, созданных в блоке setup. Следующей строкой мы отправляем в сокет событие CREATED_COMMENT вместе с ассоциативным массивом, схожим с тем, что клиент на самом деле отправляет в сокет.


Далее описываем наши "ожидания". Пока что вы не можете определить список, ссылающийся на любые другие переменные внутри функции assert_broadcast, так что следует выработать привычку по определению ожидаемых значений отдельно и передачу переменной expected в вызов assert_broadcast. Здесь мы ожидаем, что значения body и author совпадут с тем, что мы передали внутрь.


Наконец, проверяем, что сообщение CREATED_COMMENT было транслировано вместе с ожидаемым ассоциативным массивом.


Теперь переходим к событию 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

Вот и всё! Теперь у нас есть полностью покрытый тестами код каналов (за исключением Javascript-тестов, которые представляют собой отдельный мир, не вписывающийся в эту серию уроков). В следующей части мы перейдём к работе над UI, сделаем его немного симпатичнее и более функциональнее, а также заменим стандартные стили, логотипы и т.п., чтобы проект выглядел более профессионально. В дополнение, удобство использования нашего сайта сейчас абсолютно никакое. Мы поправим и это, чтобы людям хотелось использовать нашу блоговую платформу!


Другие статьи серии


  1. Вступление
  2. Авторизация
  3. Добавляем роли
  4. Обрабатываем роли в контроллерах
  5. Подключаем ExMachina
  6. Поддержка Markdown
  7. Добавляем комментарии
  8. Заканчиваем с комментариями
  9. Каналы
  10. Тестирование каналов
  11. Заключение
Tags:
Hubs:
+8
Comments 0
Comments Leave a comment

Articles