2018年10月15日

Phoenix_6_TestingMVC

ExUnit

ExUnit 有三個主要的 macros

  1. setup

    setup code that runs once before each test

  2. test

    單一 isolated test,每次執行 test 前,都會先執行 setup

  3. assert

    驗證結果

defmodule MyTest do
    use ExUnit.Case, async: true

    setup do
        # run some tedious setup code
        :ok
    end

    test "pass" do
        assert true
    end
    
    test "fail" do
        assert false
    end
end

以 Mix 執行 Phoenix Tests

Phoenix 會產生幾個 test,ex: test/controllers/videocontrollertest.exs,要先修改 /config/test.exs 的 DB 設定,再用 mix test 執行測試

mix test

/test/support/conn_case.ex

defmodule Rumbl.ConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      # Import conveniences for testing with connections
      use Phoenix.ConnTest

      alias Rumbl.Repo
      import Ecto
      import Ecto.Changeset
      import Ecto.Query

      import Rumbl.Router.Helpers

      # The default endpoint for testing
      @endpoint Rumbl.Endpoint
    end
  end

  setup tags do
    :ok = Ecto.Adapters.SQL.Sandbox.checkout(Rumbl.Repo)

    unless tags[:async] do
      Ecto.Adapters.SQL.Sandbox.mode(Rumbl.Repo, {:shared, self()})
    end

    {:ok, conn: Phoenix.ConnTest.build_conn()}
  end
end

注意,他是使用 @endpoint Rumbl.Endpoint


test/controllers/pagecontrollertest.ex

defmodule Rumbl.PageControllerTest do
  use Rumbl.ConnCase

  test "GET /", %{conn: conn} do
    conn = get conn, "/"
    assert html_response(conn, 200) =~ "Welcome to Phoenix!"
  end
end

這個測試會驗證三個部分

  • 檢查 conn 的 response 是否為 200
  • 檢查 response content-type 是否為 text/html
  • 回傳 response body

如果是 json 就改成 assert %{user_id: user.id} = json_response(conn, 200)

修改剛剛的測試

assert html_response(conn, 200) =~ "Welcome to Rumbl.io"

可得到 pass 結果正確

$ mix test test/controllers/page_controller_test.exs
.

Finished in 0.1 seconds
1 test, 0 failures

Randomized with seed 354626

Integration Tests

Creating Test Data

新增 test/support/test_helpers.ex ,新增 user 及 video

defmodule Rumbl.TestHelpers do
  alias Rumbl.Repo

  def insert_user(attrs \\ %{}) do
    changes = Dict.merge(%{
      name: "Some User",
      username: "user#{Base.encode16(:crypto.rand_bytes(8))}",
      password: "supersecret",
    }, attrs)

    %Rumbl.User{}
    |> Rumbl.User.registration_changeset(changes)
    |> Repo.insert!()
  end

  def insert_video(user, attrs \\ %{}) do
    user
    |> Ecto.build_assoc(:videos, attrs)
    |> Repo.insert!()
  end
end
Testing Logged-Out Users

修改 /test/support/conn_case.ex

  using do
    quote do
      # Import conveniences for testing with connections
      use Phoenix.ConnTest

      alias Rumbl.Repo
      import Ecto
      import Ecto.Changeset
      import Ecto.Query, only: [from: 1, from: 2]

      import Rumbl.Router.Helpers
      
      # 增加 TestHelpers
      import Rumbl.TestHelpers 

      # The default endpoint for testing
      @endpoint Rumbl.Endpoint
    end
  end

新增 /test/controllers/videocontrollertest.ex,因為沒有登入,測試所有的連線都是以 302 為結果

defmodule Rumbl.VideoControllerTest do
  use Rumbl.ConnCase

  test "requires user authentication on all actions", %{conn: conn} do
    Enum.each([
      get(conn, video_path(conn, :new)),
      get(conn, video_path(conn, :index)),
      get(conn, video_path(conn, :show, "123")),
      get(conn, video_path(conn, :edit, "123")),
      put(conn, video_path(conn, :update, "123", %{})),
      post(conn, video_path(conn, :create, %{})),
      delete(conn, video_path(conn, :delete, "123")),
    ], fn conn ->
      assert html_response(conn, 302)
      assert conn.halted
    end)
  end
end
Preparing for Logged-In Users

修改 /web/controllers/auth.ex, 使用 cond 檢查多個條件

  def call(conn, repo) do
    user_id = get_session(conn, :user_id)

    cond do
      user = conn.assigns[:current_user] ->
        conn
      user = user_id && repo.get(Rumbl.User, user_id) ->
        assign(conn, :current_user, user)
      true ->
        assign(conn, :current_user, nil)
    end
  end

舊版本為

  # 收到 init 的 repository
  def call(conn, repo) do
    # 檢查 session 是否有存在 :user_id
    user_id = get_session(conn, :user_id)
    # 如果有 user_id 且 User DB 有這個 user_id
    # 利用 assign 把這個 user 資料存放在 conn.assigns
    user    = user_id && repo.get(Rumbl.User, user_id)
    # 後面可以用 :current_user 取得 User 資料
    assign(conn, :current_user, user)
  end
Testing Logged-In Users

/test/controllers/videocontrollertest.ex

  setup do
    user = insert_user(username: "max")
    conn = assign(conn(), :current_user, user)
    {:ok, conn: conn, user: user}
  end

  test "lists all user's videos on index", %{conn: conn, user: user} do
    user_video  = insert_video(user, title: "funny cats")
    other_video = insert_video(insert_user(username: "other"), title: "another video")

    conn = get conn, video_path(conn, :index)
    assert html_response(conn, 200) =~ ~r/Listing videos/
    assert String.contains?(conn.resp_body, user_video.title)
    refute String.contains?(conn.resp_body, other_video.title)
  end
Controlling Duplication with Tagging

因有些測試需要登入,有些不要,setup 要區分這兩種狀況。可使用 ExUnit tags 解決此問題

  setup %{conn: conn} = config do
    if username = config[:login_as] do
      user = insert_user(username: username)
      conn = assign(conn, :current_user, user)
      {:ok, conn: conn, user: user}
    else
      :ok
    end
  end

  @tag login_as: "max" 
  test "lists all user's videos on index", %{conn: conn, user: user} do
    user_video  = insert_video(user, title: "funny cats")
    other_video = insert_video(insert_user(username: "other"), title: "another video")

    conn = get conn, video_path(conn, :index)
    assert html_response(conn, 200) =~ ~r/Listing videos/
    assert String.contains?(conn.resp_body, user_video.title)
    refute String.contains?(conn.resp_body, other_video.title)
  end

測試

$ mix test test/controllers --only login_as
Compiling 13 files (.ex)
warning: function authenticate/2 is unused
  web/controllers/user_controller.ex:40

Including tags: [:login_as]
Excluding tags: [:test]

.

Finished in 0.8 seconds
3 tests, 0 failures, 2 skipped

Randomized with seed 158230

增加 create a video 的測試

  alias Rumbl.Video
  @valid_attrs %{url: "http://youtu.be", title: "vid", description: "a vid"}
  @invalid_attrs %{title: "invalid"}

  defp video_count(query), do: Repo.one(from v in query, select: count(v.id))

  @tag login_as: "max"
  test "creates user video and redirects", %{conn: conn, user: user} do
    conn = post conn, video_path(conn, :create), video: @valid_attrs
    assert redirected_to(conn) == video_path(conn, :index)
    assert Repo.get_by!(Video, @valid_attrs).user_id == user.id
  end

  @tag login_as: "max"
  test "does not create video and renders errors when invalid", %{conn: conn} do
    count_before = video_count(Video)
    conn = post conn, video_path(conn, :create), video: @invalid_attrs
    assert html_response(conn, 302) =~ "redirected"
#    assert video_count(Video) == count_before
  end
  
    @tag login_as: "max"
  test "authorizes actions against access by other users",
       %{user: owner, conn: conn} do

    video = insert_video(owner, @valid_attrs)
    non_owner = insert_user(username: "sneaky")
    conn = assign(conn, :current_user, non_owner)

    assert_error_sent :not_found, fn ->
      get(conn, video_path(conn, :show, video))
    end
    assert_error_sent :not_found, fn ->
      get(conn, video_path(conn, :edit, video))
    end
    assert_error_sent :not_found, fn ->
      put(conn, video_path(conn, :update, video, video: @valid_attrs))
    end
    assert_error_sent :not_found, fn ->
      delete(conn, video_path(conn, :delete, video))
    end
  end
Unit-Testing Plugs

/test/controllers/auth_test.exs


defmodule Rumbl.AuthTest do
  use Rumbl.ConnCase
  alias Rumbl.Auth

  setup %{conn: conn} do
    conn =
      conn
      |> bypass_through(Rumbl.Router, :browser)
      |> get("/")

    {:ok, %{conn: conn}}
  end

  test "authenticate_user halts when no current_user exists",
       %{conn: conn} do

    conn = Auth.authenticate_user(conn, [])
    assert conn.halted
  end

  test "authenticate_user continues when the current_user exists",
       %{conn: conn} do

    conn =
      conn
      |> assign(:current_user, %Rumbl.User{})
      |> Auth.authenticate_user([])

    refute conn.halted
  end
end

測試 login logout

  test "login puts the user in the session", %{conn: conn} do
     # 新的 connection
    login_conn =
      conn
      |> Auth.login(%Rumbl.User{id: 123})
      |> send_resp(:ok, "")

    next_conn = get(login_conn, "/")
    # 檢查是否在 session 裡面
    assert get_session(next_conn, :user_id) == 123
  end

  test "logout drops the session", %{conn: conn} do
    logout_conn =
      conn
      |> put_session(:user_id, 123)
      |> Auth.logout()
      |> send_resp(:ok, "")

    next_conn = get(logout_conn, "/")
    refute get_session(next_conn, :user_id)
  end

檢查 assigns 裡面的 current_user

  test "call places user from session into assigns", %{conn: conn} do
    user = insert_user()
    conn =
      conn
      |> put_session(:user_id, user.id)
      |> Auth.call(Repo)

    assert conn.assigns.current_user.id == user.id
  end

  test "call with no session sets current_user assign to nil", %{conn: conn} do
    conn = Auth.call(conn, Repo)
    assert conn.assigns.current_user == nil
  end
  test "login with a valid username and pass", %{conn: conn} do 
    user = insert_user(username: "me", password: "secret")
    {:ok, conn} =
      Auth.login_by_username_and_pass(conn, "me", "secret", repo: Repo)

    assert conn.assigns.current_user.id == user.id
  end

  test "login with a not found user", %{conn: conn} do 
    assert {:error, :not_found, _conn} =
      Auth.login_by_username_and_pass(conn, "me", "secret", repo: Repo)
  end

  test "login with password mismatch", %{conn: conn} do 
    _ = insert_user(username: "me", password: "secret")
    assert {:error, :unauthorized, _conn} =
      Auth.login_by_username_and_pass(conn, "me", "wrong", repo: Repo)
  end

Testing Views and Templates

defmodule Rumbl.VideoViewTest do
  use Rumbl.ConnCase, async: true
  import Phoenix.View

  test "renders index.html", %{conn: conn} do
    # 以 videos render 頁面
    videos = [%Rumbl.Video{id: "1", title: "dogs"},
      %Rumbl.Video{id: "2", title: "cats"}]
    content = render_to_string(Rumbl.VideoView, "index.html",
      conn: conn, videos: videos)

    assert String.contains?(content, "Listing videos")
    for video <- videos do
      assert String.contains?(content, video.title)
    end
  end


  test "renders new.html", %{conn: conn} do
    # 以 changeset 及 categories assigns 去 render 頁面
    changeset = Rumbl.Video.changeset(%Rumbl.Video{})
    categories = [{"cats", 123}]

    content = render_to_string(Rumbl.VideoView, "new.html",
      conn: conn, changeset: changeset, categories: categories)

    assert String.contains?(content, "New video")
  end
end

Spliting Side Effects in Model Tests

Testing Side Effect-Free Model Code

在 test/support/modelcase.ex 中 import TestHelpers,修改 errorson function

    using do
    quote do
      alias Rumbl.Repo

      import Ecto
      import Ecto.Changeset
      import Ecto.Query, only: [from: 1, from: 2]
      import Rumbl.TestHelpers 
      import Rumbl.ModelCase
    end
  end
  
  
  def errors_on(model, data) do
    model.__struct__.changeset(model, data).errors
  end

新增 /test/models/user_test.exs

defmodule Rumbl.UserTest do

  # 設定為 async,因為目標是要區隔每個 test,可平行處理
  use Rumbl.ModelCase, async: true
  alias Rumbl.User

  @valid_attrs %{name: "A User", username: "eva", password: "secret"}
  @invalid_attrs %{}

  test "changeset with valid attributes" do
    changeset = User.changeset(%User{}, @valid_attrs)
    assert changeset.valid?
  end

  test "changeset with invalid attributes" do
    changeset = User.changeset(%User{}, @invalid_attrs)
#    refute changeset.valid?
    assert changeset.valid?
  end

  test "changeset does not accept long usernames" do
    attrs = Map.put(@valid_attrs, :username, String.duplicate("a", 30))
    # 使用 ModelCase 定義的 error_on function,快速取得 changeset 裡面的 errors
#    assert {:username, {"should be at most %{count} character(s)", [count: 20]}} in
#             errors_on(%User{}, attrs)

    assert [username: {"should be at most %{count} character(s)", [count: 20, validation: :length, max: 20]}] ==
             errors_on(%User{}, attrs)
  end
  
  
  test "registration_changeset password must be at least 6 chars long" do
    attrs = Map.put(@valid_attrs, :password, "12345")
    changeset = User.registration_changeset(%User{}, attrs)
#    assert {:password, {"should be at least %{count} character(s)", count: 6}}
#           in changeset.errors

    assert [password: {"should be at least %{count} character(s)",
              [count: 6, validation: :length, min: 6]}]
           == changeset.errors
  end

  test "registration_changeset with valid attributes hashes password" do
    attrs = Map.put(@valid_attrs, :password, "123456")
    changeset = User.registration_changeset(%User{}, attrs)
    %{password: pass, password_hash: pass_hash} = changeset.changes

    assert changeset.valid?
    assert pass_hash
    assert Comeonin.Bcrypt.checkpw(pass, pass_hash)
  end
end
Testing Code with Side Effect

新增 /test/models/userrepotest.exs


defmodule Rumbl.UserRepoTest do
  use Rumbl.ModelCase
  alias Rumbl.User

  @valid_attrs %{name: "A User", username: "eva"}

  test "converts unique_constraint on username to error" do
    insert_user(username: "eric")
    attrs = Map.put(@valid_attrs, :username, "eric")
    changeset = User.changeset(%User{}, attrs)

    assert {:error, changeset} = Repo.insert(changeset) 
#    assert {:username, "has already been taken"} in changeset.errors
    assert [username: {"has already been taken", []}] == changeset.errors
  end
end
$ mix test test/models/user_repo_test.exs
.

Finished in 0.4 seconds
1 test, 0 failures

defmodule Rumbl.CategoryRepoTest do
  use Rumbl.ModelCase
  alias Rumbl.Category

  test "alphabetical/1 orders by name" do
    Repo.insert!(%Category{name: "c"})
    Repo.insert!(%Category{name: "a"})
    Repo.insert!(%Category{name: "b"})

    query = Category |> Category.alphabetical()
    query = from c in query, select: c.name
#    assert ~w(a b c) == Repo.all(query)
    assert ~w(a Action b c Comedy Drama Romance Sci-fi) == Repo.all(query)
  end
end
$ mix test test/models/category_repo_test.exs
.

Finished in 0.08 seconds
1 test, 0 failures

References

Programming Phoenix