檢視原始碼 測試控制器
需求:這份指南預期你已經完成測試指南簡介。
在測試指南簡介的最後,我們使用以下指令產生文章的 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
頁面中的表單提交後,它就變成建立動作的 POST 請求。因為我們提供了有效的屬性,所以該文章應該已成功建立,而且我們應該已被重新導向到新文章的顯示動作。這個新頁面的地址看起來像 /posts/ID
,其中 ID 是資料庫中文章的識別碼。
接著我們使用 redirected_params(conn)
取得文章的 ID,然後比對我們是否的確被重新導向到顯示動作。最後,我們向我們被重新導向到的頁面請求 get
請求,讓我們驗證該文章是否的確已建立。
對於第二個測試,我們僅測試失敗場景。如果給定任何無效的屬性,它都應重新呈現「New Post」頁面。
一個常見的問題是:你會在控制器層面測試多少失敗場景?例如,在 測試脈絡 指南中,我們針對文章的 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
動作看起來像這樣
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
提供的測試輔助程序。在本例中,它驗證以下事項
- 引發了例外
- 例外狀態碼等於 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 內文 <-> 貼文架構非常類似的概念,除了我們使用不同的名稱,因此我們可以孤立地研究這些概念。
在你執行上述命令之後,不要忘記遵循產生器輸出的最後步驟。一旦所有步驟完成,我們應該執行 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 公開它。
有了這些差異在手,讓我們看看控制器測試。
索引動作
開啟 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
動作取得所有文章並呈現索引範本。由於我們討論的是 JSON,因此沒有 index.json.heex
範本。轉而直接在 ArticleJSON 模組中尋找 將 articles
轉成 JSON 的程式碼,它定義在 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 回應,以及它含有「資料」金鑰和一個空清單,因為沒有文章要回傳。
那很無聊。讓我們看看一些更有趣的東西。
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),它將「位置」標頭設定為文章位置,然後以文章呈現「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,且其中具有「資料」金鑰。我們對「資料」的樣式進行比對,為 %{"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}
結果。幸運的是,我們可以透過動作回退控制器來教導 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),然後使用有錯誤的變更集從變更集檢視呈現「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」金鑰。
在設計 API 時,action_fallback
可以非常有用的減少樣本程式碼。您可以在 控制器指南 中進一步瞭解「動作回退」。
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 應用程式!