Spec-Zone.ru › Phoenix

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

Исходный код

Требование: предполагается, что вы ознакомились с вводными руководствами и запустили приложение Phoenix, которое работает.

Требование: предполагается, что вы ознакомились с руководством «Введение в тестирование».

В конце руководства «Введение в тестирование» мы создали HTML-ресурс для публикаций с помощью следующей команды:

$ mix phx.gen.html Blog Post posts title body:text

В результате мы бесплатно получили несколько модулей, в том числе PostController и связанные с ним тесты. Мы изучим эти тесты, чтобы узнать больше о тестировании контроллеров в целом. В конце руководства мы создадим JSON-ресурс и рассмотрим, как выглядят наши тесты API.

Тесты HTML-контроллера

Если открыть test/hello_web/controllers/post_controller_test.exs, вы увидите следующее:

defmodule HelloWeb.PostControllerTest do
  use HelloWeb.ConnCase

  import Hello.BlogFixtures

  @create_attrs %{body: "some body", title: "some title"}
  @update_attrs %{body: "some updated body", title: "some updated title"}
  @invalid_attrs %{body: nil, title: nil}
  
  describe "index" do
    test "lists all posts", %{conn: conn} do
      conn = get(conn, ~p"/posts")
      assert html_response(conn, 200) =~ "Listing Posts"
    end
  end

  ...

Как и PageControllerTest, входящий в состав нашего приложения, этот тест контроллера использует use HelloWeb.ConnCase для настройки структуры тестирования. Затем, как обычно, он объявляет несколько псевдонимов и атрибутов модуля, которые используются во время тестирования, а после этого запускает ряд блоков describe, каждый из которых проверяет отдельное действие контроллера.

Действие index

Первый блок describe предназначен для действия index. Само действие реализовано в lib/hello_web/controllers/post_controller.ex следующим образом:

def index(conn, _params) do
  posts = Blog.list_posts()
  render(conn, :index, posts: posts)
end

Оно получает все публикации и отображает шаблон "index.html". Шаблон находится в lib/hello_web/controllers/post_html/index.html.heex.

Тест выглядит так:

describe "index" do
  test "lists all posts", %{conn: conn} do
    conn = get(conn, ~p"/posts")
    assert html_response(conn, 200) =~ "Listing Posts"
  end
end

Тест страницы index довольно прост. Он использует вспомогательную функцию get/2, чтобы отправить запрос к странице "/posts", маршрут которой проверяется нашим роутером благодаря ~p. Затем мы проверяем, что получили успешный HTML-ответ, и сопоставляем его содержимое.

Действие create

Следующий тест, который мы рассмотрим, предназначен для действия create. Реализация действия create выглядит так:

def create(conn, %{"post" => post_params}) do
  case Blog.create_post(post_params) do
    {:ok, post} ->
      conn
      |> put_flash(:info, "Post created successfully.")
      |> redirect(to: ~p"/posts/#{post}")

    {:error, %Ecto.Changeset{} = changeset} ->
      render(conn, :new, changeset: changeset)
  end
end

Для create возможны два результата, поэтому нам понадобится как минимум два теста:

describe "create post" do
  test "redirects to show when data is valid", %{conn: conn} do
    conn = post(conn, ~p"/posts", post: @create_attrs)

    assert %{id: id} = redirected_params(conn)
    assert redirected_to(conn) == ~p"/posts/#{id}"

    conn = get(conn, ~p"/posts/#{id}")
    assert html_response(conn, 200) =~ "Post #{id}"
  end

  test "renders errors when data is invalid", %{conn: conn} do
    conn = post(conn, ~p"/posts", post: @invalid_attrs)
    assert html_response(conn, 200) =~ "New Post"
  end
end

Первый тест начинается с запроса post/2. Это связано с тем, что после отправки формы на странице /posts/new запрос POST направляется к действию create. Поскольку мы передали допустимые атрибуты, публикация должна быть успешно создана, а затем мы должны перенаправиться к действию show для новой публикации. Адрес этой новой страницы будет иметь вид /posts/ID, где ID — идентификатор публикации в базе данных.

Затем мы используем redirected_params(conn), чтобы получить ID публикации, и проверяем, что действительно перенаправились к действию show. Наконец, мы отправляем запрос get на страницу, куда нас перенаправили, чтобы проверить, что публикация действительно создана.

Во втором тесте мы проверяем сценарий неудачи. Если передан недопустимый атрибут, должна повторно отобразиться страница "New Post".

Часто задают вопрос: сколько сценариев неудачи нужно проверять на уровне контроллера? Например, в руководстве «Тестирование контекстов» мы добавили валидацию поля title публикации:

def changeset(post, attrs) do
  post
  |> cast(attrs, [:title, :body])
  |> validate_required([:title, :body])
  |> validate_length(:title, min: 2)
end

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

  • не указано название
  • не указано содержание
  • название указано, но содержит меньше двух символов

Нужно ли проверять все эти возможные результаты в тестах контроллера?

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

Тест для update устроен аналогично create, поэтому перейдём сразу к тесту delete.

Действие delete

Действие delete выглядит так:

def delete(conn, %{"id" => id}) do
  post = Blog.get_post!(id)
  {:ok, _post} = Blog.delete_post(post)

  conn
  |> put_flash(:info, "Post deleted successfully.")
  |> redirect(to: ~p"/posts")
end

Тест написан следующим образом:

  describe "delete post" do
    setup [:create_post]

    test "deletes chosen post", %{conn: conn, post: post} do
      conn = delete(conn, ~p"/posts/#{post}")
      assert redirected_to(conn) == ~p"/posts"

      assert_error_sent 404, fn ->
        get(conn, ~p"/posts/#{post}")
      end
    end
  end

  defp create_post(_) do
    post = post_fixture()
    %{post: post}
  end

Прежде всего, setup используется, чтобы указать, что функция create_post должна запускаться перед каждым тестом в этом блоке describe. Функция create_post просто создаёт публикацию и сохраняет её в метаданных теста. Благодаря этому в первой строке теста мы можем сопоставить и публикацию, и соединение:

test "deletes chosen post", %{conn: conn, post: post} do

Тест использует delete/2, чтобы удалить публикацию, а затем проверяет, что мы перенаправились на главную страницу списка. Наконец, мы проверяем, что страницу просмотра удалённой публикации больше нельзя открыть:

assert_error_sent 404, fn ->
  get(conn, ~p"/posts/#{post}")
end

assert_error_sent — это вспомогательная функция для тестирования, предоставляемая Phoenix.ConnTest. В данном случае она проверяет следующее:

  1. Было выброшено исключение
  2. Код состояния исключения соответствует 404 (что означает «Не найдено»)

Это в целом имитирует обработку исключений в Phoenix. Например, при обращении к /posts/12345, где 12345 — несуществующий идентификатор, будет вызвано наше действие show:

def show(conn, %{"id" => id}) do
  post = Blog.get_post!(id)
  render(conn, :show, post: post)
end

Если в Blog.get_post!/1 передать неизвестный идентификатор публикации, будет выброшено исключение Ecto.NotFoundError. Если во время веб-запроса приложение выбрасывает исключение, Phoenix преобразует его в соответствующий код HTTP-ответа. В данном случае — 404.

Например, мы могли бы написать этот тест так:

assert_raise Ecto.NotFoundError, fn ->
  get(conn, ~p"/posts/#{post}")
end

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

Тесты для действий new, edit и show — это более простые варианты тестов, которые мы уже рассмотрели. Вы можете самостоятельно изучить реализацию этих действий и соответствующие тесты. Теперь можно перейти к тестам JSON-контроллера.

Тесты JSON-контроллера

До сих пор мы работали с созданным HTML-ресурсом. Теперь посмотрим, как выглядят наши тесты при создании JSON-ресурса.

Для начала выполните эту команду:

$ mix phx.gen.json News Article articles title body

Мы выбрали концепцию, очень похожую на связь контекста Blog и схемы Post, но использовали другое название, чтобы изучить эти концепции отдельно.

После выполнения команды не забудьте пройти последние шаги, которые выводит генератор. Когда всё будет готово, запустите mix test — теперь у нас будет 35 успешно пройденных тестов:

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

Finished in 0.6 seconds
35 tests, 0 failures

Randomized with seed 618478

Возможно, вы заметили, что на этот раз контроллер, созданный с помощью scaffold, сгенерировал меньше тестов. Ранее он сгенерировал 16 тестов (их стало 21 вместо 5), а теперь сгенерировал 14 (их стало 35 вместо 21). Это связано с тем, что JSON API не нужно предоставлять действия new и edit. Это видно по ресурсу, который мы добавили в роутер в конце выполнения команды mix phx.gen.json:

resources "/articles", ArticleController, except: [:new, :edit]

new и edit нужны только для HTML, поскольку они предназначены главным образом для помощи пользователям при создании и обновлении ресурсов. Помимо меньшего числа действий, мы заметим, что тесты и реализации контроллеров и представлений для JSON сильно отличаются от вариантов для HTML.

Единственное, что практически не меняется между HTML и JSON, — это контексты и схема. Если задуматься, это вполне логично: бизнес-логика должна оставаться неизменной независимо от того, предоставляете ли вы её в формате HTML или JSON.

Учитывая эти различия, давайте рассмотрим тесты контроллера.

Действие index

Откройте test/hello_web/controllers/article_controller_test.exs. Начальная структура довольно похожа на post_controller_test.exs. Поэтому рассмотрим тесты для действия index. Само действие index реализовано в lib/hello_web/controllers/article_controller.ex следующим образом:

def index(conn, _params) do
  articles = News.list_articles()
  render(conn, :index, articles: articles)
end

Действие получает все статьи и отображает шаблон index. Поскольку мы говорим о JSON, у нас нет шаблона index.json.heex. Вместо этого код преобразования articles в JSON находится непосредственно в модуле ArticleJSON, определённом в lib/hello_web/controllers/article_json.ex следующим образом:

defmodule HelloWeb.ArticleJSON do
  alias Hello.News.Article

  def index(%{articles: articles}) do
    %{data: for(article <- articles, do: data(article))}
  end

  def show(%{article: article}) do
    %{data: data(article)}
  end

  defp data(%Article{} = article) do
    %{
      id: article.id,
      title: article.title,
      body: article.body
    }
  end
end

Поскольку отображение в контроллере — это обычный вызов функции, для отображения JSON нам не нужны дополнительные возможности. Мы просто определяем функции для действий index и show, которые возвращают карту с данными статей в формате JSON.

Теперь посмотрим на тест для действия index:

describe "index" do
  test "lists all articles", %{conn: conn} do
    conn = get(conn, ~p"/api/articles")
    assert json_response(conn, 200)["data"] == []
  end
end

Он просто обращается к пути index и проверяет, что мы получили ответ JSON со статусом 200 и ключом "data", содержащим пустой список, поскольку статей для возврата нет.

Это было довольно скучно. Давайте рассмотрим что-нибудь поинтереснее.

Действие create

Действие create определено следующим образом:

def create(conn, %{"article" => article_params}) do
  with {:ok, %Article{} = article} <- News.create_article(article_params) do
    conn
    |> put_status(:created)
    |> put_resp_header("location", ~p"/api/articles/#{article}")
    |> render(:show, article: article)
  end
end

Как видим, оно проверяет, удалось ли создать статью. Если да, устанавливает код состояния :created (что соответствует 201), добавляет заголовок "location" с адресом статьи, а затем отображает "show.json" с этой статьёй.

Именно это проверяет первый тест действия create:

describe "create article" do
  test "renders article when data is valid", %{conn: conn} do
    conn = post(conn, ~p"/articles", article: @create_attrs)
    assert %{"id" => id} = json_response(conn, 201)["data"]

    conn = get(conn, ~p"/api/articles/#{id}")

    assert %{
             "id" => ^id,
             "body" => "some body",
             "title" => "some title"
           } = json_response(conn, 200)["data"]
  end

Тест использует post/2, чтобы создать новую статью, а затем проверяет, что статья вернулась в ответе JSON со статусом 201 и ключом "data". Мы сопоставляем значение "data" с %{"id" => id}, что позволяет извлечь ID новой статьи. Затем отправляем запрос get/2 к маршруту show и проверяем, что статья была успешно создана.

В describe "create article" находится ещё один тест, проверяющий сценарий неудачи. Можете определить, какой сценарий неудачи предусмотрен в действии create? Давайте вспомним его:

def create(conn, %{"article" => article_params}) do
  with {:ok, %Article{} = article} <- News.create_article(article_params) do

Специальная форма with, входящая в состав Elixir, позволяет явно проверять успешные варианты. В данном случае нас интересуют только сценарии, в которых News.create_article(article_params) возвращает {:ok, article}. Если возвращается что-либо другое, это значение просто возвращается напрямую, а содержимое блока do/end не выполняется. Другими словами, если News.create_article/1 возвращает {:error, changeset}, мы просто вернём {:error, changeset} из действия.

Однако это создаёт проблему. По умолчанию наши действия не умеют обрабатывать результат {:error, changeset}. К счастью, мы можем научить контроллеры Phoenix обрабатывать его с помощью контроллера Action Fallback. В начале ArticleController вы найдёте следующее:

  action_fallback HelloWeb.FallbackController

Эта строка означает: если какое-либо действие возвращает не %Plug.Conn{}, нужно вызвать FallbackController и передать ему результат. HelloWeb.FallbackController находится в lib/hello_web/controllers/fallback_controller.ex и выглядит так:

defmodule HelloWeb.FallbackController do
  use HelloWeb, :controller

  def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
    conn
    |> put_status(:unprocessable_entity)
    |> put_view(json: HelloWeb.ChangesetJSON)
    |> render(:error, changeset: changeset)
  end

  def call(conn, {:error, :not_found}) do
    conn
    |> put_status(:not_found)
    |> put_view(html: HelloWeb.ErrorHTML, json: HelloWeb.ErrorJSON)
    |> render(:"404")
  end
end

Видно, как первая ветвь функции call/2 обрабатывает случай {:error, changeset}: устанавливает код состояния «необрабатываемая сущность» (422), а затем отображает "error.json" из представления changeset, передавая ему changeset с ошибкой.

Учитывая это, рассмотрим второй тест для create:

test "renders errors when data is invalid", %{conn: conn} do
  conn = post(conn, ~p"/api/articles", article: @invalid_attrs)
  assert json_response(conn, 422)["errors"] != %{}
end

Он просто отправляет POST-запрос по пути create с недопустимыми параметрами. В результате возвращается ответ JSON со статусом 422 и непустым ключом "errors".

action_fallback может быть очень полезен для сокращения шаблонного кода при разработке API. Подробнее о "Action Fallback" можно узнать в руководстве «Контроллеры».

Действие delete

Наконец, последнее действие, которое мы изучим, — действие delete для JSON. Его реализация выглядит так:

def delete(conn, %{"id" => id}) do
  article = News.get_article!(id)

  with {:ok, %Article{}} <- News.delete_article(article) do
    send_resp(conn, :no_content, "")
  end
end

Новое действие пытается удалить статью и, если это удаётся, возвращает пустой ответ со статусом :no_content (204).

Тест выглядит так:

describe "delete article" do
  setup [:create_article]

  test "deletes chosen article", %{conn: conn, article: article} do
    conn = delete(conn, ~p"/api/articles/#{article}")
    assert response(conn, 204)

    assert_error_sent 404, fn ->
      get(conn, ~p"/api/articles/#{article}")
    end
  end
end

defp create_article(_) do
  article = article_fixture()
  %{article: article}
end

Он создаёт новую статью, а затем в тесте обращается к пути delete, чтобы удалить её, и проверяет ответ со статусом 204, который не является ни JSON, ни HTML. После этого тест проверяет, что получить доступ к этой статье больше нельзя.

Вот и всё!

Теперь, когда мы разобрались, как работают созданный с помощью scaffold код и тесты для HTML- и JSON API, мы готовы перейти к созданию и поддержке веб-приложений!

← Предыдущая страница Тестирование контекстов
Следующая страница → Тестирование каналов

Пакет Hex Предпросмотр Hex (текущий файл) Скачать версию ePub

Создано с помощью ExDoc (v0.40.3) для языка программирования Elixir

© 2014 Chris McCord
Licensed under the MIT License.
https://phoenix.hexdocs.pm/testing_controllers.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API