查看源代码 测试控制器

要求:本指南假定您已经阅读过入门指南并成功运行了 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/templates/page/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 页面中的表单提交,它就会变成对 create 操作的 POST 请求。因为我们提供了有效的属性,所以应该成功创建了帖子,并且我们应该重定向到新帖子的 show 操作。这个新页面将有一个像 /posts/ID 这样的地址,其中 ID 是数据库中帖子的标识符。

然后我们使用 redirected_params(conn) 获取帖子的 ID,然后匹配我们确实重定向到了 show 操作。最后,我们对重定向到的页面进行 get 请求,以便验证帖子确实已创建。

对于第二个测试,我们只需测试失败的情况。如果给出了任何无效属性,它应该重新渲染 "新建帖子" 页面。

一个常见的问题是:您在控制器级别测试多少个失败情况?例如,在测试上下文指南中,我们向帖子的 title 字段引入了验证

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

换句话说,创建帖子可能会因以下原因失败

  • 标题丢失
  • 正文丢失
  • 标题存在,但少于 2 个字符

我们应该在我们的控制器测试中测试所有这些可能的结果吗?

答案是否定的。所有不同的规则和结果应该在您的上下文和模式测试中进行验证。控制器充当集成层。在控制器测试中,我们只是想大致验证我们处理了成功和失败情况。

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 删除帖子,然后断言我们重定向到了 index 页面。最后,我们检查是否不再能够访问已删除帖子的 show 页面

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

assert_error_sent 是由 Phoenix.ConnTest 提供的测试助手。在这种情况下,它验证了

  1. 引发了一个异常
  2. 该异常的 HTTP 状态码等效于 404(表示未找到)

这几乎模拟了 Phoenix 如何处理异常。例如,当我们访问 /posts/12345(其中 12345 是一个不存在的 ID)时,我们将调用我们的 show 操作

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

当一个未知的帖子 ID 被传递给 Blog.get_post!/1 时,它会抛出一个 Ecto.NotFoundError。如果您的应用程序在 Web 请求期间抛出任何异常,Phoenix 会将这些请求转换为正确的 HTTP 响应代码。在这种情况下,为 404。

例如,我们可以将该测试写成如下

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

但是,您可能更喜欢 Phoenix 默认生成的实现,因为它忽略了失败的具体细节,而是验证了浏览器实际上会接收到的内容。

neweditshow 操作的测试是迄今为止我们看到的测试的更简单的变体。您可以自己检查操作实现及其相应的测试。现在我们准备转向 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

您可能已经注意到,这次脚手架控制器生成的测试更少。以前它生成了 16 个(我们从 5 个增加到 21 个),现在它生成了 14 个(我们从 21 个增加到 35 个)。这是因为 JSON API 不需要公开 newedit 操作。我们可以看到,在我们在 mix phx.gen.json 命令末尾添加的资源中,情况就是如此

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

newedit 对于 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。我们只需为我们的 indexshow 操作定义函数,这些函数返回文章的 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 路径,断言我们得到了一个状态为 200 的 JSON 响应,并且它包含一个具有空列表的 "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

如我们所见,它检查是否可以创建文章。如果是,它将 HTTP 状态码设置为 :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。然后我们对 show 路径执行 get/2 请求,并验证文章是否已成功创建。

describe "create article" 内部,我们将找到另一个测试,它处理失败情况。你能在 create 操作中发现失败情况吗?让我们回顾一下

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

作为 Elixir 的一部分提供的 with 特殊形式允许我们明确检查正常路径。在这种情况下,我们只对 News.create_article(article_params) 返回 {:ok, article} 的情况感兴趣,如果它返回任何其他内容,则会直接返回另一个值,并且 do/end 块内部的内容都不会被执行。换句话说,如果 News.create_article/1 返回 {:error, changeset},我们只会从该操作中返回 {:error, changeset}

但是,这引入了一个问题。我们的操作默认情况下不知道如何处理 {:error, changeset} 结果。幸运的是,我们可以使用 Action Fallback 控制器教会 Phoenix 控制器如何处理它。在 ArticleController 的顶部,您将找到

  action_fallback HelloWeb.FallbackController

这一行表示:如果任何动作没有返回一个 %Plug.Conn{},我们希望用结果调用 FallbackController。你可以在 lib/hello_web/controllers/fallback_controller.ex 中找到 HelloWeb.FallbackController,它看起来像这样

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),然后使用失败的 changeset 从 changeset 视图渲染 "error.json"。

考虑到这一点,让我们看看我们对 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

它只是使用无效参数发布到 create 路径。这使得它返回一个 JSON 响应,状态码为 422,以及包含一个非空 "errors" 键的响应。

action_fallback 在设计 API 时可以极大地减少样板代码。你可以在 控制器指南 中了解更多关于 "动作回退" 的信息。

The delete action

最后,我们将要研究的最后一个动作是 JSON 的 delete 动作。它的实现看起来像这样

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。然后它验证我们是否不能再访问该文章。

就这样!

现在我们理解了脚手架代码及其测试如何针对 HTML 和 JSON API 工作,我们已经准备好继续构建和维护我们的 Web 应用程序了!