测试是 Elixir 生态中最被认真对待的实践之一:BEAM 的进程隔离天然适合并发测试,ExUnit 内置异步执行与随机化,Ecto 提供 Sandbox 事务隔离,StreamData 带来基于生成的属性测试。更重要的是,Elixir 的函数式与不可变特性让「注入依赖、验证行为」变得极其自然——Mock 不需要魔法框架,一个参数就能完成。本文将深入 ExUnit 的高级用法、StreamData 属性测试、Mox 契约式 Mock,以及生产级测试的组织与隔离策略。
一、ExUnit 深入
1.1 测试结构与生命周期
defmodule MyApp.BlogTest do
use ExUnit.Case
alias MyApp.Blog
# setup 在每个测试前运行,返回的 map 会合并进测试上下文
setup do
{:ok, post: Blog.create_post!(%{title: "Hello"})}
end
# setup_all 在整个模块运行一次(不能依赖它做数据清理)
setup_all do
{:ok, user: Accounts.create_user!(%{email: "a@ex.com"})}
end
test "post 可以发布", %{post: post} do
assert Blog.publish(post).published
refute Blog.publish(post).featured # refute = 否定断言
assert_raise ArgumentError, fn -> Blog.publish(nil) end
end
test "标题长度校验" do
changeset = Blog.change_post(%{title: "x"})
assert %{title: ["should be at least 3 character(s)"]} = errors_on(changeset)
end
end
1.2 setup 与上下文传递
setup tags do
# tags 包含 :tagged 传递的标签
if tags[:db] do
:ok = Sandbox.checkout(Repo)
end
{:ok, conn: build_conn(), user: create_user()}
end
@tag :db
test "依赖数据库的测试", %{conn: conn, user: user} do
conn = get(conn, "/api/posts")
assert json_response(conn, 200)["data"] != []
end
1.3 断言与 diff
# 常用断言
assert 1 + 1 == 2
assert [1, 2, 3] == [1, 2, 3]
refute "hello" == "world"
assert_raise RuntimeError, "boom", fn -> raise "boom" end
assert_in_delta 0.1 + 0.2, 0.3, 0.0001
assert_receive {:result, ^ref}, 1000 # 异步断言,1 秒超时
refute_receive {:result, _}, 100
# 模式匹配断言
assert {:ok, %{id: id}} = Repo.insert(changeset)
# 浮点与列表比较
assert [3, 1, 2] |> Enum.sort() == [1, 2, 3]
# ExUnit.CaptureIO / CaptureLog
import ExUnit.CaptureIO
output = capture_io(fn -> IO.puts("hi") end)
assert output == "hi\n"
1.4 标签与测试组织
# 打标签
@tag :slow
@tag timeout: 2000
test "慢测试", do: ...
# 按标签运行
mix test --only slow # 只跑 slow
mix test --exclude slow # 跳过 slow
mix test --only db --max-failures 5
# config/test.exs 中配置标签行为
ExUnit.configure(
exclude: [:skip],
include: [db: true], # 默认包含 db 标签
seed: 0, # 固定随机种子
max_cases: 8 # 并发测试进程数
)
二、属性测试与 StreamData
2.1 为什么需要属性测试
传统示例测试只能覆盖「你想到的输入」;属性测试声明不变量(property),让框架生成大量随机输入验证。它能发现你从未想到的边界:
| 维度 | 示例测试 | 属性测试 |
|---|---|---|
| 输入覆盖 | 少数手写案例 | 成百上千随机生成 |
| 关注点 | 具体输出 | 通用不变量 |
| 边界发现 | 靠经验 | 自动发现 |
| 失败定位 | 直接 | 收缩到最小反例 |
2.2 StreamData 基础
# mix.exs 添加依赖
{:stream_data, "~> 1.0", only: [:dev, :test]}
defmodule MyApp.SortTest do
use ExUnit.Case
use ExUnitProperties
property "排序后长度不变,且是有序的" do
check all list <- list_of(integer(), min_length: 0) do
sorted = MyApp.sort(list)
assert length(sorted) == length(list)
assert Enum.chunk_every(sorted, 2, 1, :discard)
|> Enum.all?(fn [a, b] -> a <= b end)
end
end
property "排序保持多重集合(元素相同)" do
check all list <- list_of(integer()) do
sorted = MyApp.sort(list)
assert Enum.sort(list) == sorted # 与参考实现一致
end
end
end
2.3 StreamData 生成器
# 基本生成器
integer()
positive_integer()
float()
string(:alphanumeric, min_length: 1, max_length: 20)
list_of(integer(), length: 1..5)
map_of(string(), integer())
one_of([integer(), float()]) # 随机选一
tuple({integer(), string()})
fixed_map(%{name: string(), age: integer()})
# 组合生成器
defp email_generator do
bind(string(:alphanumeric, min_length: 3), fn name ->
string([:alphanumeric, :punct], min_length: 2)
|> map(&"#{name}@#{&1}.com")
end)
end
property "解析 email 不抛异常" do
check all email <- email_generator() do
assert {:ok, _} = MyApp.Parser.parse_email(email)
end
end
2.4 收缩(Shrinking)与失败重现
属性测试失败时,StreamData 会收缩到最小反例:
property "列表反转两次等于原列表" do
check all list <- list_of(integer()) do
assert Enum.reverse(Enum.reverse(list)) == list
end
end
# 若失败,输出:
# Failed! With generated values: [42, -1, 300]
# ...收缩到: [-1, 300]
固定随机种子让失败可重现:
mix test --seed 0 # 种子 0 可复现某次失败
mix test --seed <n> # 复现上次失败的种子
2.5 属性测试实战:Find(状态机属性测试)
对状态机,属性测试可以建模「操作序列」:
defmodule MyApp.StackTest do
use ExUnit.Case
use ExUnitProperties
property "push/pop 序列后栈内元素守恒" do
check all ops <- list_of(one_of([:push, :pop]), max_length: 100) do
stack = Enum.reduce(ops, [], fn
:push, acc -> [:pushed | acc]
:pop, [] -> []
:pop, [_ | rest] -> rest
end)
assert length(stack) >= 0
# 不变量:pop 不会让栈长度小于 0
assert length(stack) ==
length(ops) - Enum.count(ops, &(&1 == :pop) and true)
end
end
end
三、Mock 策略
3.1 依赖注入:最朴素的 Mock
Elixir 的函数都是模块调用,把依赖作为参数传入即可测试,无需任何框架:
defmodule MyApp.EmailService do
# 依赖作为可选参数,默认使用真实实现
def send(user, adapter \\ RealAdapter) do
adapter.send_email(user.email, "Welcome")
end
end
# 测试中传入 Fake
defmodule FakeAdapter do
def send_email(email, body), do: {:ok, email}
end
test "发送欢迎邮件" do
assert {:ok, email} = MyApp.EmailService.send(user, FakeAdapter)
assert email == user.email
end
3.2 Mox:契约式 Mock
Mox 是官方推荐的 Mock 库,核心思想:Mock 行为必须先定义行为,测试中显式设置,禁止隐式替换。
# 定义行为(契约)
defmodule MyApp.PaymentGateway do
@callback charge(String.t(), integer()) :: {:ok, String.t()} | {:error, term()}
end
# lib/my_app/payment_client.ex —— 真实实现
defmodule MyApp.PaymentClient do
@behaviour MyApp.PaymentGateway
@impl true
def charge(token, amount) do
HTTPoison.post("https://pay.example.com", %{token: token, amount: amount})
end
end
# config/test.exs
config :my_app, MyApp.PaymentGateway, MyApp.PaymentClient.Mock
# test/support/mocks.ex
Mox.defmock(MyApp.PaymentClient.Mock,
for: MyApp.PaymentGateway,
# 可指定额外行为(如文档)
extra: [real_client: &MyApp.PaymentClient/0]
)
# test 中设置 mock
import Mox
setup :verify_on_exit! # 测试结束后验证所有 expect 都被调用
test "成功扣款" do
expect(MyApp.PaymentClient.Mock, :charge, fn "tok_123", 100 ->
{:ok, "ch_abc"}
end)
assert {:ok, "ch_abc"} = MyApp.Billing.charge("tok_123", 100)
end
3.3 Mox 的高级用法
# 行为序列
expect(Mock, :charge, 2, fn _t, _a -> {:ok, "ch_1"} end)
# 匹配具体参数
expect(Mock, :charge, fn "tok_1", _ -> {:ok, "ch_1"}; _,_ -> {:error, :bad_token} end)
# 全局 stub(不校验次数)
stub(Mock, :charge, fn _, _ -> {:ok, "stub"} end)
# allow(跨进程测试,如 Task / GenServer 内调用)
allow(Mock, self(), fn -> {:ok, "allowed"} end)
3.4 何时避免 Mock
| 场景 | 建议 |
|---|---|
| 纯函数 | 绝不 Mock,直接断言 |
| 外部 HTTP / 第三方 API | 用 Mox 契约 + 集成测试少量真调用 |
| 数据库(Ecto) | 用真实数据库 + Sandbox,不 Mock Repo |
| 时间 / 随机数 | 注入时钟或用参数传入 |
| GenServer 内部依赖 | 构造时注入,避免全局替换 |
原则:Mock 边界应该放在「你无法控制的系统边界」(网络、磁盘、时间),而不是业务逻辑内部。Mock 太多会让测试失去检验价值。
四、测试并发与隔离
4.1 async 并发测试
ExUnit 默认串行;async: true 让测试模块并行运行:
defmodule MyApp.UnitTest do
use ExUnit.Case, async: true # 无共享状态 → 可并行
test "纯逻辑" do
assert MyApp.square(2) == 4
end
end
defmodule MyApp.DbTest do
use ExUnit.Case, async: false # 依赖数据库 → 串行(或使用 Sandbox)
test "写库" do
assert {:ok, _} = Repo.insert(%Post{title: "t"})
end
end
4.2 Ecto.Adapters.SQL.Sandbox
数据库测试的并发隔离标准做法:每个测试在独立事务中运行,结束时回滚:
# test/support/conn_case.ex 或 data_case.ex
defmodule MyApp.DataCase do
use ExUnit.CaseTemplate
using do
quote do
alias Ecto.Adapters.SQL.Sandbox
import Ecto.Query
import MyApp.DataCase
setup tags do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(MyApp.Repo, shared: tags[:shared] != true)
on_exit(fn -> Sandbox.stop_owner(pid) end)
:ok
end
end
end
end
关键:使用 Sandbox 后数据库测试可以 async: true,每个测试独占一个事务,互不干扰。若测试需要跨进程共享连接(如 LiveView / GenServer 中访问 Repo),用 shared: true:
@tag shared: true
test "GenServer 中的 Repo 访问" do
# shared 模式复用同一连接池,让其他进程也能访问
end
4.3 随机化与确定性
ExUnit 默认随机化测试顺序,避免测试间隐式依赖:
mix test --seed 0 # 固定顺序,便于复现
mix test --repeat-until-failure # 反复运行直到失败(排查 flaky)
配合 on_exit 清理副作用,让测试可重复:
setup do
tmp_dir = Path.join(System.tmp_dir!(), "my_app_#{System.unique_integer([:positive])}")
File.mkdir!(tmp_dir)
on_exit(fn -> File.rm_rf!(tmp_dir) end)
{:ok, tmp_dir: tmp_dir}
end
4.4 测试金字塔实践
| 层级 | 比例 | 示例 | 隔离 |
|---|---|---|---|
| 单元测试 | 70% | 纯函数、Changeset 校验 | async: true |
| 集成测试 | 20% | Context 操作、GenServer 行为 | Sandbox |
| 契约/属性测试 | 5% | StreamData 不变量 | async |
| 端到端测试 | 5% | Phoenix 控制器 + LiveView | 单独环境 |
五、测试覆盖与最佳实践
5.1 覆盖率
mix test --cover
# 覆盖率报告: cover/ 目录
# 也可以在 mix.exs 中配置:
test_coverage: [summary: [threshold: 90], tool: ExCoveralls]
覆盖率是「找盲区」的信号而非目标:核心业务逻辑(状态机、Changeset、并发控制)应重点覆盖,模板与样板代码不必强求。
5.2 异步与随机化的雷区
- 全局状态(
Application.put_env、ETS 命名表)会污染并发测试——尽量注入而非全局; - 时间依赖:用
:sys.get_state+ 注入时钟替代sleep; - 进程泄露:测试启动的 GenServer 要在
on_exit中停止; - 端口/网络:用
{:ok, port} = :gen_tcp.listen(0)随机端口避免冲突。
5.3 测试即文档
好的测试是对系统的第二份文档。命名遵循「被测行为」而非「被测实现」:
# 不推荐
test "user_test 1" do
assert User.changeset(%User{}, %{name: "x"}) != nil
end
# 推荐
test "用户姓名为空时返回校验错误" do
changeset = User.changeset(%User{}, %{name: ""})
assert changeset.valid? == false
assert %{name: ["can't be blank"]} = errors_on(changeset)
end
六、总结
Elixir 的测试生态把「测试」从开发流程的附属品提升为一等工程能力。ExUnit 的并发模型、StreamData 的属性生成、Mox 的契约式 Mock,加上 Ecto Sandbox 的事务隔离,构成了一个既快又可靠、既确定又覆盖广泛的测试体系。
核心要点:
- 测试优先并发:无共享状态的测试
async: true,数据库测试用 Sandbox 获得并发; - 属性测试补足盲区:为不可变纯函数声明不变量,让随机输入替你「考古」;
- Mock 只放在系统边界:用依赖注入 + Mox 替换外部依赖,绝不在业务逻辑内部打桩;
- 失败可复现:固定 seed、随机化顺序、
on_exit清理,让每个失败都能本地重现。
当测试本身成为设计约束——纯函数可测、边界清晰、无全局状态——Elixir 代码的质量就有了系统性保障。这套方法论与 Erlang 侧的进程测试、属性测试一脉相承(可参考 https://plumephp.com/erlang-concurrency-actors/ 的并发测试思路),最终导向的是「让 bug 在 CI 里现形,而不是在生产中爆炸」。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。