查看源代码 测试控制器
要求:本指南假定您已经阅读过测试简介指南。
在测试简介指南的最后,我们使用以下命令为帖子生成了一个 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
提供的测试助手。在这种情况下,它验证了
- 引发了一个异常
- 该异常的 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 默认生成的实现,因为它忽略了失败的具体细节,而是验证了浏览器实际上会接收到的内容。
对 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
您可能已经注意到,这次脚手架控制器生成的测试更少。以前它生成了 16 个(我们从 5 个增加到 21 个),现在它生成了 14 个(我们从 21 个增加到 35 个)。这是因为 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
路径,断言我们得到了一个状态为 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 应用程序了!