檢視原始碼 測試控制器

需求:這份指南預期你已經完成入門指南,且已啟動並執行 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 頁面中的表單提交後,它就變成建立動作的 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_sentPhoenix.ConnTest 提供的測試輔助程序。在本例中,它驗證以下事項

  1. 引發了例外
  2. 例外狀態碼等於 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 內文 <-> 貼文架構非常類似的概念,除了我們使用不同的名稱,因此我們可以孤立地研究這些概念。

在你執行上述命令之後,不要忘記遵循產生器輸出的最後步驟。一旦所有步驟完成,我們應該執行 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 公開它。

有了這些差異在手,讓我們看看控制器測試。

索引動作

開啟 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。我們只需定義 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 回應,以及它含有「資料」金鑰和一個空清單,因為沒有文章要回傳。

那很無聊。讓我們看看一些更有趣的東西。

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 應用程式!