测试与部署 #

一、测试概述 #

1.1 测试类型 #

类型 说明
单元测试 测试单个函数或模块
集成测试 测试多个组件协作
控制器测试 测试HTTP请求
LiveView测试 测试实时视图

1.2 测试目录结构 #

text
test/
├── hello/              # 业务逻辑测试
│   └── accounts_test.exs
├── hello_web/          # Web层测试
│   ├── controllers/
│   └── live/
├── support/            # 测试辅助
│   ├── fixtures/
│   ├── conn_case.ex
│   └── data_case.ex
└── test_helper.exs

二、单元测试 #

2.1 Context测试 #

elixir
defmodule Hello.AccountsTest do
  use Hello.DataCase

  alias Hello.Accounts

  describe "users" do
    alias Hello.Accounts.User

    test "list_users/0 returns all users" do
      user = user_fixture()
      assert Accounts.list_users() == [user]
    end

    test "get_user!/1 returns the user with given id" do
      user = user_fixture()
      assert Accounts.get_user!(user.id) == user
    end

    test "create_user/1 with valid data creates a user" do
      attrs = %{email: "test@example.com", name: "Test User"}

      assert {:ok, %User{} = user} = Accounts.create_user(attrs)
      assert user.email == "test@example.com"
      assert user.name == "Test User"
    end

    test "create_user/1 with invalid data returns error changeset" do
      attrs = %{email: nil, name: nil}
      assert {:error, %Ecto.Changeset{}} = Accounts.create_user(attrs)
    end

    test "update_user/2 with valid data updates the user" do
      user = user_fixture()
      attrs = %{name: "Updated Name"}

      assert {:ok, %User{} = user} = Accounts.update_user(user, attrs)
      assert user.name == "Updated Name"
    end

    test "delete_user/1 deletes the user" do
      user = user_fixture()
      assert {:ok, %User{}} = Accounts.delete_user(user)
      assert_raise Ecto.NoResultsError, fn -> Accounts.get_user!(user.id) end
    end
  end

  defp user_fixture(attrs \\ %{}) do
    attrs = Enum.into(attrs, %{email: "test@example.com", name: "Test User"})
    {:ok, user} = Accounts.create_user(attrs)
    user
  end
end

2.2 测试辅助 #

elixir
defmodule Hello.DataCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      alias Hello.Repo

      import Ecto
      import Ecto.Changeset
      import Ecto.Query
      import Hello.DataCase
    end
  end

  setup tags do
    Hello.DataCase.setup_sandbox(tags)
    :ok
  end

  def setup_sandbox(tags) do
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Hello.Repo, shared: not tags[:async])
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
  end

  def errors_on(changeset) do
    Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
      Regex.replace(~r"%{(\w+)}", message, fn _, key ->
        opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
      end)
    end)
  end
end

三、控制器测试 #

3.1 控制器测试 #

elixir
defmodule HelloWeb.UserControllerTest do
  use HelloWeb.ConnCase

  describe "index" do
    test "lists all users", %{conn: conn} do
      user = user_fixture()

      conn = get(conn, ~p"/users")

      assert html_response(conn, 200) =~ user.name
    end
  end

  describe "show" do
    test "shows user", %{conn: conn} do
      user = user_fixture()

      conn = get(conn, ~p"/users/#{user.id}")

      assert html_response(conn, 200) =~ user.name
    end
  end

  describe "create" do
    test "redirects when data is valid", %{conn: conn} do
      attrs = %{email: "test@example.com", name: "Test User"}

      conn = post(conn, ~p"/users", user: attrs)

      assert redirected_to(conn) =~ ~p"/users/"
    end

    test "renders errors when data is invalid", %{conn: conn} do
      attrs = %{email: nil, name: nil}

      conn = post(conn, ~p"/users", user: attrs)

      assert html_response(conn, 200) =~ "can't be blank"
    end
  end
end

3.2 ConnCase #

elixir
defmodule HelloWeb.ConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      use HelloWeb, :verified_routes

      import Plug.Conn
      import Phoenix.ConnTest
      import HelloWeb.ConnCase
    end
  end

  setup tags do
    Hello.DataCase.setup_sandbox(tags)
    {:ok, conn: Phoenix.ConnTest.build_conn()}
  end
end

四、LiveView测试 #

4.1 LiveView测试 #

elixir
defmodule HelloWeb.UserLiveTest do
  use HelloWeb.ConnCase

  import Phoenix.LiveViewTest

  describe "Index" do
    test "lists all users", %{conn: conn} do
      user = user_fixture()

      {:ok, _index_live, html} = live(conn, ~p"/users")

      assert html =~ user.name
    end

    test "saves new user", %{conn: conn} do
      {:ok, index_live, _html} = live(conn, ~p"/users")

      assert index_live |> element("a", "New User") |> render_click() =~
               "New User"

      assert_patch(index_live, ~p"/users/new")

      assert index_live
             |> form("#user-form", user: %{email: "test@example.com", name: "Test"})
             |> render_submit()

      assert_patch(index_live, ~p"/users")

      html = render(index_live)
      assert html =~ "Test"
    end

    test "updates user", %{conn: conn} do
      user = user_fixture()

      {:ok, index_live, _html} = live(conn, ~p"/users")

      assert index_live |> element("#user-#{user.id} a", "Edit") |> render_click() =~
               "Edit User"

      assert_patch(index_live, ~p"/users/#{user.id}/edit")

      assert index_live
             |> form("#user-form", user: %{name: "Updated"})
             |> render_submit()

      assert_patch(index_live, ~p"/users")

      html = render(index_live)
      assert html =~ "Updated"
    end

    test "deletes user", %{conn: conn} do
      user = user_fixture()

      {:ok, index_live, _html} = live(conn, ~p"/users")

      assert index_live |> element("#user-#{user.id} a", "Delete") |> render_click()
      refute has_element?(index_live, "#user-#{user.id}")
    end
  end
end

五、运行测试 #

5.1 测试命令 #

bash
mix test
mix test --trace
mix test --only tag
mix test --exclude tag
mix test --stale
mix test --cover

5.2 测试配置 #

elixir
config :hello, Hello.Repo,
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  database: "hello_test#{System.get_env("MIX_TEST_PARTITION")}",
  pool: Ecto.Adapters.SQL.Sandbox,
  pool_size: 10

六、生产部署 #

6.1 创建Release #

bash
mix phx.gen.release
MIX_ENV=prod mix release

6.2 Release配置 #

elixir
config :hello, HelloWeb.Endpoint,
  url: [host: System.get_env("HOST") || "example.com", port: 443, scheme: "https"],
  http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")],
  secret_key_base: System.get_env("SECRET_KEY_BASE"),
  server: true

6.3 运行Release #

bash
_build/prod/rel/hello/bin/hello start
_build/prod/rel/hello/bin/hello stop
_build/prod/rel/hello/bin/hello restart
_build/prod/rel/hello/bin/hello eval "Hello.Release.migrate()"

七、Docker部署 #

7.1 Dockerfile #

dockerfile
FROM elixir:1.15-alpine AS build

WORKDIR /app

RUN apk add --no-cache build-base git

RUN mix local.hex --force && \
    mix local.rebar --force

COPY mix.exs mix.lock ./
RUN mix deps.get --only prod

COPY lib lib
COPY priv priv
COPY assets assets

RUN mix assets.deploy
RUN mix compile

RUN mix release

FROM alpine:3.18

WORKDIR /app

RUN apk add --no-cache openssl ncurses-libs

COPY --from=build /app/_build/prod/rel/hello ./

ENV HOME=/app

CMD ["bin/hello", "start"]

7.2 docker-compose.yml #

yaml
version: '3.8'

services:
  web:
    build: .
    ports:
      - "4000:4000"
    environment:
      - SECRET_KEY_BASE=${SECRET_KEY_BASE}
      - DATABASE_URL=${DATABASE_URL}
      - HOST=localhost
    depends_on:
      - db

  db:
    image: postgres:15
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=hello_prod
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

八、环境变量 #

8.1 必需的环境变量 #

bash
export SECRET_KEY_BASE="your_secret_key_base"
export DATABASE_URL="ecto://user:pass@host/database"
export HOST="example.com"
export PORT="4000"

8.2 生成Secret #

bash
mix phx.gen.secret

九、数据库迁移 #

9.1 生产迁移 #

elixir
defmodule Hello.Release do
  @app :hello

  def migrate do
    load_app()

    for repo <- repos() do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end

  def rollback(repo, version) do
    load_app()
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
  end

  defp repos do
    Application.fetch_env!(@app, :ecto_repos)
  end

  defp load_app do
    Application.load(@app)
  end
end

9.2 运行迁移 #

bash
_build/prod/rel/hello/bin/hello eval "Hello.Release.migrate()"

十、监控 #

10.1 日志配置 #

elixir
config :logger, :console,
  format: "$time $metadata[$level] $message\n",
  metadata: [:request_id]

10.2 健康检查 #

elixir
defmodule HelloWeb.HealthController do
  use HelloWeb, :controller

  def check(conn, _params) do
    json(conn, %{status: "ok", timestamp: DateTime.utc_now()})
  end
end

十一、总结 #

11.1 测试核心 #

类型 说明
单元测试 测试业务逻辑
控制器测试 测试HTTP请求
LiveView测试 测试实时视图

11.2 部署核心 #

步骤 说明
编译资源 mix assets.deploy
创建Release mix release
运行迁移 Release.migrate()
启动服务 bin/hello start

11.3 完成 #

恭喜你完成了Phoenix完全指南的学习!现在你已经掌握了从基础到进阶的Phoenix开发技能,可以开始构建自己的Phoenix应用了!

最后更新:2026-03-28