Learning
VOL. XIII · NO. 26 · Elixir · 01 JAN 1970

ExUnit / DataCase / Sandbox / Behaviour 替换 / 集成测试

Elixir 编程 · 01 JAN 1970 · 7 min read · 616 words
· · ·

Elixir 的测试生态不只是 ExUnit。Ecto Sandbox 让测试可以并行跑、互不干扰;Mox 让你在测试里替换 Behaviour 实现;Phoenix / Plug 测试让 HTTP 集成变得自然。

本课目标
学完后,你应该能写出分层测试(unit / component / integration);用 DataCase 隔离数据库事务;用 Ecto Sandbox 并行跑测试;用 Mox 替换 Behaviour;用 Plug.Test 测 HTTP 端点;写出可重复、不依赖外网的测试。

一、测试金字塔

Elixir 项目里常见的测试分层:

工具范围
UnitExUnit 纯函数单个函数、单个模块
ComponentDataCase + SandboxEcto / GenServer 行为
IntegrationPlug.Test / PhoenixHTTP 端到端

原则:

  • 大部分测试是 unit(快、稳定)。
  • component 测试覆盖数据库交互、GenServer 行为。
  • integration 测试只覆盖关键路径,避免太重。

二、ExUnit 基础

defmodule MyApp.MathTest do
  use ExUnit.Case, async: true  # async 表示可并行跑

  test "adds two numbers" do
    assert MyApp.Math.add(2, 3) == 5
  end

  test "raises on overflow" do
    assert_raise ArithmeticError, fn ->
      MyApp.Math.add(:inf, 1)
    end
  end

  describe "format/1" do
    test "formats integer" do
      assert MyApp.Math.format(42) == "42"
    end

    test "formats float with 2 decimals" do
      assert MyApp.Math.format(3.14159) == "3.14"
    end
  end
end
async: true
如果测试用例之间不共享可变状态(无全局 ETS、文件系统),标 async: true 让 ExUnit 并行跑,快几倍。但用了 ETS / 文件系统的测试必须 async: false

三、DataCase:测试数据库的标准模板

Phoenix 生成 test/support/data_case.ex,提供:

defmodule MyApp.DataCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      use ExUnit.Case, async: true

      alias MyApp.Repo
      import Ecto
      import Ecto.Changeset
      import Ecto.Query

      import MyApp.DataCase
    end
  end

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

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

使用:

defmodule MyApp.AccountsTest do
  use MyApp.DataCase, async: true

  alias MyApp.Accounts
  alias MyApp.Accounts.User

  describe "register_user/1" do
    test "valid attrs creates user" do
      assert {:ok, %User{} = user} = Accounts.register_user(%{
        email: "alice@example.com",
        password: "verysecret"
      })
      assert user.email == "alice@example.com"
    end

    test "invalid email returns changeset" do
      assert {:error, changeset} = Accounts.register_user(%{email: "bad", password: "x"})
      assert %{email: ["is invalid"]} = errors_on(changeset)
    end
  end
end

四、Ecto Sandbox:测试间数据库隔离

Ecto.Adapters.SQL.Sandbox 让每个测试运行在自己的数据库事务里,互不污染:

  • async: true:每个 test 一个独立事务,并行安全。
  • async: false:所有 test 共享事务,更快但要小心并发。
  • 测试里手动 Repo.checkout/2 可以做更细粒度的事务控制。
Sandbox 边界
  • Sandbox 事务对其他进程可见,只能在当前测试进程读。
  • 如果你启了 GenServer / Task 写数据库,要么传 Repo 引用,要么用 Ecto.Adapters.SQL.Sandbox.allow/3 授权。
  • 测试结束自动回滚。

五、GenServer 测试

测 GenServer 时,标准做法是 start_supervised!

defmodule MyApp.CacheTest do
  use ExUnit.Case, async: false  # 用了 ETS,全局共享

  alias MyApp.Cache

  setup do
    start_supervised!(Cache)
    :ok
  end

  test "put and get" do
    :ok = Cache.put(:a, 1)
    assert [{:a, 1}] = Cache.get(:a)
  end
end

start_supervised!/1 的好处:测试结束自动清理,不需要手动 stop。

六、用 Mox 替换 Behaviour

Mox 是 Behaviour 的 mock 库。它要求你提前在 application config 里声明 Behaviour:

# config/config.exs
config :my_app,
  external_clients: [MyApp.Payments.Stripe]

# test/test_helper.exs
Mox.defmock(StripeMock, for: MyApp.Payments.Stripe)

# 你的 Behaviour
defmodule MyApp.Payments.Stripe do
  @callback charge(amount :: integer(), source :: String.t()) ::
              {:ok, String.t()} | {:error, term()}
end

# 真实实现
defmodule MyApp.Payments.Stripe.Real do
  @behaviour MyApp.Payments.Stripe
  @impl true
  def charge(amount, source), do: Stripe.Api.charge(amount, source)
end

在测试里:

defmodule MyApp.PaymentsTest do
  use MyApp.DataCase, async: true
  import Mox

  setup :verify_on_exit!

  test "charges via stripe" do
    StripeMock
    |> expect(:charge, fn 1000, "tok_visa" -> {:ok, "ch_123"} end)

    assert {:ok, "ch_123"} = MyApp.Payments.charge_user(1, 1000)
  end
end

关键点:

  • Mox.defmock/2test_helper.exs 声明一次。
  • 每个测试用 expect/3stub/3 设置行为。
  • setup :verify_on_exit! 确保 expect 都触发,否则测试失败。
Mox 的好处
不依赖行为模块的命名。Mox 检查"调用方有没有正确传参给 Behaviour",而不是"测试时插桩哪个模块"。这让 Behaviour 实现可以随时切换而不破坏测试。

七、Plug.Test:HTTP 集成测试

测 Plug 端点不用真的起 HTTP server:

defmodule MyAppWeb.RouterTest do
  use MyAppWeb.ConnCase, async: true

  test "GET /users returns 200", %{conn: conn} do
    conn = get(conn, "/users")
    assert html_response(conn, 200) =~ "user list"
  end

  test "POST /users creates user", %{conn: conn} do
    attrs = %{email: "alice@example.com", password: "verysecret"}
    conn = post(conn, "/users", user: attrs)
    assert json_response(conn, 201)["data"]["email"] == "alice@example.com"
  end
end

底层是 Plug.Test

conn = :post |> Plug.Test.conn("/users", Jason.encode!(attrs))
         |> Plug.Conn.put_req_header("content-type", "application/json")
         |> MyAppWeb.Router.call([])

八、ConnCase:Phoenix 提供的模板

类似 DataCase,Phoenix 生成 test/support/conn_case.ex,默认 import Plug.Test 助手:

defmodule MyAppWeb.ConnCase do
  use ExUnit.CaseTemplate

  using do
    quote do
      use ExUnit.Case, async: true

      import Plug.Conn
      import Phoenix.ConnTest
      alias MyAppWeb.Router

      @endpoint MyAppWeb.Endpoint
    end
  end

  setup tags do
    pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: not tags[:async])
    on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
    {:ok, conn: Phoenix.ConnTest.build_conn()}
  end
end

用法:

  • get(conn, "/path") / post(conn, "/path", params)
  • html_response(conn, 200) / json_response(conn, 201)
  • conn.assigns / conn.resp_body 直接读

九、测试 Oban Worker

测 Worker 直接调 perform/1:

```
defmodule MyApp.Workers.RebuildIndexTest do
  use MyApp.DataCase, async: true

  alias MyApp.Workers.RebuildIndex

  test "perform rebuilds index" do
    job = %Oban.Job{args: %{"index" => "products"}}
    assert :ok = RebuildIndex.perform(job)
  end

  test "perform returns :discard on bad args" do
    job = %Oban.Job{args: %{}}
    assert {:discard, "missing index"} = RebuildIndex.perform(job)
  end
end
```

  集成测试用 `` Oban.insert/1 + `Oban.drain_queue/1:` ``  ```
``
```
test "oban runs rebuild job end to end" do
  {:ok, _} = RebuildIndex.new(%{index: "products"}) |> Oban.insert()
  assert :ok = Oban.drain_queue(queue: :maintenance)
end
```
## 十、单元测试覆盖率  用 `mix test --cover` 生成覆盖率。配合 `excoveralls` 推到 Coveralls。CI 里跑:
```
mix test --cover --include integration
```
注意:覆盖率**不**等于测试质量。100% 覆盖但全是"if 走过就 OK"的测试,仍然可能漏业务 bug。  ## 十一、测试数据工厂  用 `ex_machina` 或 `faker` 写 factory:
```
defmodule MyApp.Factory do
  use ExMachina.Ecto, repo: MyApp.Repo

  def user_factory do
    %MyApp.Accounts.User{
      email: sequence(:email, &"user\#{&1}@example.com"),
      password: "verysecret123"
    }
  end

  def order_factory do
    %MyApp.Orders.Order{
      user: build(:user),
      total: 1000,
      status: :pending
    }
  end
end
```
用法:
```
user = insert(:user, %{email: "alice@example.com"})
```
## 十二、生产清单  -   每个测试独立可重复,**不要依赖外网**。 -   外部 HTTP / Redis / 数据库都用 Mox 或 Sandbox 隔离。 -   关键路径必须有 integration 测试。 -   性能敏感代码(热路径)加 benchmark 或 doctest。 -   CI 里跑 `mix test --warnings-as-errors`。 -   不要在测试里 `sleep`,用 `assert_receive` / `assert eventually`。  ## 十三、测验  

<Quiz n="1" q='1. &lt;code&gt;async: true&lt;/code&gt; 的测试用例要求?' options='["用全局 ETS","不共享可变状态,可独立并行","必须用 GenServer","必须连外网"]' answer="1" />

<Quiz n="2" q='2. Ecto Sandbox 的隔离机制是?' options='["每个测试独立数据库","每个测试一个数据库事务","每个测试独立 schema","每个测试独立连接池"]' answer="1" />

<Quiz n="3" q='3. &lt;code&gt;start_supervised!/1&lt;/code&gt; 的好处?' options='["测试结束自动清理","提高性能","连外网","启动 HTTP server"]' answer="3" />

<Quiz n="4" q='4. Mox 的核心用途?' options='["替换 Behaviour 实现做测试","生成 factory 数据","启动数据库","运行集成测试"]' answer="0" />

<Quiz n="5" q='5. Mox 的 &lt;code&gt;setup :verify_on_exit!&lt;/code&gt; 作用?' options='["提升速度","确保 &lt;code&gt;expect&lt;/code&gt; 设置的 mock 在测试里都被调用","跳过测试","编译失败"]' answer="1" />

<Quiz n="6" q='6. &lt;code&gt;Plug.Test&lt;/code&gt; 的核心作用?' options='["起 HTTP server","在内存里模拟 HTTP 请求,测 Plug 路由","发真请求","读 socket"]' answer="1" />

<Quiz n="7" q='7. &lt;code&gt;html_response(conn, 200)&lt;/code&gt; 断言什么?' options='["状态码是 200 且响应是 HTML","返回 200 个字符","连了 200 次数据库","HTML 长度 200"]' answer="0" />

<Quiz n="8" q='8. Sandbox 事务的可见性?' options='["对所有进程可见","只对当前测试进程可见,需 &lt;code&gt;Sandbox.allow/3&lt;/code&gt; 才能跨进程","对外网可见","不可见"]' answer="1" />

<Quiz n="9" q='9. 测 Oban Worker 最直接的方式?' options='["起完整 Oban 实例","直接调 &lt;code&gt;perform/1&lt;/code&gt; 传 mock &lt;code&gt;%Oban.Job{}&lt;/code&gt;","连数据库查 job 表","用 curl 调 HTTP"]' answer="1" />

<Quiz n="10" q='10. 测试里 &lt;code&gt;Process.sleep&lt;/code&gt; 应该?' options='["经常用","尽量避免,用 &lt;code&gt;assert_receive&lt;/code&gt; / 事件轮询","替换为 GenServer","替换为 Oban"]' answer="1" />  **下一步:**行为 / 协议 / 宏 / GenServer / ETS / 热重载 / Redix 队列 / Oban / 测试——这一整套拼起来就是一个生产级 Elixir 后端。下一步可以搭一个最小但完整的项目:用户、订单、邮件、报表、监控,跑 mix release,部署到一台机器。    ``
``` ````