ExUnit / DataCase / Sandbox / Behaviour 替换 / 集成测试
· · ·
Elixir 的测试生态不只是 ExUnit。Ecto Sandbox 让测试可以并行跑、互不干扰;Mox 让你在测试里替换 Behaviour 实现;Phoenix / Plug 测试让 HTTP 集成变得自然。
题目进度 0 / 0 ✓ 0
本课目标
学完后,你应该能写出分层测试(unit / component / integration);用 DataCase 隔离数据库事务;用 Ecto Sandbox 并行跑测试;用 Mox 替换 Behaviour;用
Plug.Test 测 HTTP 端点;写出可重复、不依赖外网的测试。一、测试金字塔
Elixir 项目里常见的测试分层:
| 层 | 工具 | 范围 |
|---|---|---|
| Unit | ExUnit 纯函数 | 单个函数、单个模块 |
| Component | DataCase + Sandbox | Ecto / GenServer 行为 |
| Integration | Plug.Test / Phoenix | HTTP 端到端 |
原则:
- 大部分测试是 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/2在test_helper.exs声明一次。- 每个测试用
expect/3或stub/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. <code>async: true</code> 的测试用例要求?' options='["用全局 ETS","不共享可变状态,可独立并行","必须用 GenServer","必须连外网"]' answer="1" />
<Quiz n="2" q='2. Ecto Sandbox 的隔离机制是?' options='["每个测试独立数据库","每个测试一个数据库事务","每个测试独立 schema","每个测试独立连接池"]' answer="1" />
<Quiz n="3" q='3. <code>start_supervised!/1</code> 的好处?' options='["测试结束自动清理","提高性能","连外网","启动 HTTP server"]' answer="3" />
<Quiz n="4" q='4. Mox 的核心用途?' options='["替换 Behaviour 实现做测试","生成 factory 数据","启动数据库","运行集成测试"]' answer="0" />
<Quiz n="5" q='5. Mox 的 <code>setup :verify_on_exit!</code> 作用?' options='["提升速度","确保 <code>expect</code> 设置的 mock 在测试里都被调用","跳过测试","编译失败"]' answer="1" />
<Quiz n="6" q='6. <code>Plug.Test</code> 的核心作用?' options='["起 HTTP server","在内存里模拟 HTTP 请求,测 Plug 路由","发真请求","读 socket"]' answer="1" />
<Quiz n="7" q='7. <code>html_response(conn, 200)</code> 断言什么?' options='["状态码是 200 且响应是 HTML","返回 200 个字符","连了 200 次数据库","HTML 长度 200"]' answer="0" />
<Quiz n="8" q='8. Sandbox 事务的可见性?' options='["对所有进程可见","只对当前测试进程可见,需 <code>Sandbox.allow/3</code> 才能跨进程","对外网可见","不可见"]' answer="1" />
<Quiz n="9" q='9. 测 Oban Worker 最直接的方式?' options='["起完整 Oban 实例","直接调 <code>perform/1</code> 传 mock <code>%Oban.Job{}</code>","连数据库查 job 表","用 curl 调 HTTP"]' answer="1" />
<Quiz n="10" q='10. 测试里 <code>Process.sleep</code> 应该?' options='["经常用","尽量避免,用 <code>assert_receive</code> / 事件轮询","替换为 GenServer","替换为 Oban"]' answer="1" /> **下一步:**行为 / 协议 / 宏 / GenServer / ETS / 热重载 / Redix 队列 / Oban / 测试——这一整套拼起来就是一个生产级 Elixir 后端。下一步可以搭一个最小但完整的项目:用户、订单、邮件、报表、监控,跑 mix release,部署到一台机器。 ``
``` ````