Elixir 测试工程:ExUnit 深入、属性测试与 Mock 策略

全面讲解 Elixir 测试体系:ExUnit 的异步并发测试与标签机制、StreamData 属性测试的生成与收缩、Mox 契约式 Mock 策略,以及 Ecto Sandbox 隔离下的测试最佳实践。

测试是 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 里现形,而不是在生产中爆炸」。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「erlang」更多文章

  1. 自定义 OTP Behaviour 实战:Callback 规范与行为封装
  2. Phoenix Channels 实时通信实战:WebSocket 与 PubSub 深入
  3. Mix 工具链与 Elixir 工程化实战