《Elixir 测试入门:ExUnit 与 doctest》
要点
Elixir 把测试框架直接焊进了发行版:ExUnit 随语言而来,不用装任何依赖,mix new 出来的项目天生带 test/ 目录。这篇做四件事——跑通第一条用例、把 setup 与 context 的传值用顺、吃透 async: true 的进程隔离模型、让 doctest 把文档变成测试,最后把 OTP 篇里那个 GenServer 计数器补上像样的测试。口径为 Elixir 1.18 线(2024 年 12 月 GA,运行时对应 Erlang/OTP 27),ExUnit 的 API 十几年没有大改,学一次用很久。
本文不做:Mox 等替身库(Elixir 的依赖注入习惯是”传函数传模块”,替身库属于后续专题)、property-based testing、Phoenix 的 ConnCase 与 LiveViewTest(那是 实战篇那一套工程之后的进阶)。本文全程纯 Mix 项目,零第三方依赖。
第一条用例与运行模型
mix new demo –sup 生成的结构里,test/ 有两样东西:test_helper.exs 只有一行 ExUnit.start(),负责启动测试框架;demo_test.exs 是向导送的第一条用例。看它长什么样:
defmodule DemoTest do
use ExUnit.Case, async: true
test "greets the world" do
assert Demo.hello() == :world
end
end
mix test
预期输出(seed 每次不同):
..
Finished in 0.03 seconds (0.02s async, 0.00s sync)
1 doctest, 1 test, 0 failures
Randomized with seed 512345
三件事值得先说死。其一,用例不是方法而是普通函数,test 宏是语法糖,底层每个 test 会变成一个独立的 Erlang 进程——这个事实后面讲 async 时全是用处。其二,ExUnit 默认随机顺序执行用例,两次运行顺序不同,这是框架在替你揪出”用例之间有隐式依赖”的坏味道。其三,输出里那个 1 doctest 来自向导生成的 @doc 示例——文档即测试是 Elixir 的默认配置,不是插件。老规矩,先撞红:把 :world 改成 :word 跑一遍,ExUnit 的失败报告会打印 == Compilation error 之外最常见的一种——Assertion failed,带左右两边的值和 diff。看过一次红的,后面排查才有底气。
setup 与 context:传值靠映射表
ExUnit 没有”测试类实例属性”这种东西,共享数据的载体是 context——一个 Map。setup 在每条用例前跑,返回的键值对自动合并进 context,test 的第一个参数模式匹配取用:
defmodule CounterTest do
use ExUnit.Case, async: true
setup do
pid = start_supervised!({Counter, 0})
{:ok, counter: pid}
end
test "bump 后 get 反映新值", %{counter: pid} do
GenServer.cast(pid, :bump)
assert GenServer.call(pid, :get) == 1
end
test "初始值可控", %{counter: pid} do
assert GenServer.call(pid, :get) == 0
end
end
两个语法点:setup 返回 {:ok, keyword},键名是原子;test 里用 %{counter: pid} 精确解构,取不到会 FunctionClauseError 直接报——比”在 undefined 里翻字段”诚实得多。还有 setup_all,整个模块只跑一次,供”读起来很贵的固定数据”用,但写进 setup_all 的可变状态会被所有用例共享,配合随机顺序就是事故现场——我的默认是全用 setup,贵就贵点,正确性优先。start_supervised! 先按下不表,它是本篇的主角之一,下一节讲完进程模型再回来。
给用例打标记用 @tag:慢的、依赖外部条件的用例标上 @tag :slow,平时 mix test –exclude slow 跳过,想跑时 mix test –only slow 反向点名。标签是元数据不是逻辑,别把业务分支塞进标签判断里——它的正确定位是”筛选哪些用例进这一轮”,与断言无关。
async: true 的进程隔离
async: true 意味着:这个模块的每条用例跑在自己的进程里,且 ExUnit 会同时调度多个模块的用例并行执行,各占一个 CPU 核。隔离的物理基础是每个用例进程下面挂着自己的监督树——start_supervised! 启动的进程登记在”本用例专属”的测试监督者名下,用例一结束,监督者关闭,进程被连带杀掉,不串台。
排查工具先补齐。跑单个文件:mix test test/counter_test.exs;跑文件里的某一条用例:mix test test/counter_test.exs:12(行号对准 test 行,这是定位失败最常用的姿势);随机失败要复现:ExUnit 输出里带 seed,mix test –seed 512345 用同一个种子重放当时那条随机顺序。三个命令记住,排查效率差一个量级——随机顺序执行的框架里,”复现”的能力就靠 seed 保住。
这里埋着 Elixir 测试最经典的一个坑:进程名是节点级的,async 并行下会撞名。OTP 篇的 Counter 为了 iex 里好用,start_link 里写死了 name: __MODULE__。到了测试里,两条用例并行各 start_supervised!(Counter) 一次,第二次拿到 {:error, {:already_started, pid}}——同一个名字节点上只能有一个进程持有。修法是把名字做成可选参数,生产用法不变、测试传 nil:
def start_link(initial, name \ nil) do
GenServer.start_link(__MODULE__, initial, name: name)
end
测试里用 start_supervised! 返回的 pid 通信,不再依赖注册名。这条改造的价值超出测试本身:名字从”必需品”降级为”可选项”,进程才真正变成可复用的组件。验证隔离性有没有做干净的办法朴素而有效——mix test 连跑十次,随机顺序下每次都绿才算稳;时隐时现的失败几乎都是共享状态没清干净。
cast 是异步的,call 是天然栅栏
上面 “bump 后 get 反映新值” 这条用例看着有竞态:cast 投递完并不等待处理,紧跟着的 assert 怎么保证读到的是 +1 之后的状态?答案在信箱的顺序语义里——call 消息排在它之前投递的 cast 消息后面,进程按序消费,call 返回时前面的 cast 必然已处理完。所以”cast 后跟一次 call”是 Elixir 里不用 sleep 等异步结果的标准姿势,测试和业务代码里都这么写。反过来,谁在测试里写 Process.sleep(100) 等状态落定,谁就该把这页重读一遍。
doctest:让文档替你站岗
doctest 的机制一句话:@doc 里的 iex> 示例会被 doctest 宏编译成真用例,示例与函数行为不一致时测试失败。把一个纯函数写成带示例的文档:
defmodule MathUtils do
@doc """
金额(元)转分,绕开浮点精度坑。
iex> MathUtils.to_cents(29.99)
2999
iex> MathUtils.to_cents(-1)
** (ArgumentError) 金额不能为负
"""
def to_cents(amount) when amount < 0, do: raise(ArgumentError, "金额不能为负")
def to_cents(amount), do: round(amount * 100)
end
defmodule MathUtilsTest do
use ExUnit.Case, async: true
doctest MathUtils
end
两条 iex 示例变成两条独立用例,异常示例也能测。实现里有个真实的坑值得点名:转分用 round 不用 trunc——29.99 * 100 在浮点下是 2998.9999…,trunc 会得出 2998,doctest 会立刻把这种错钉在报告里,这正是它最锋利的用法。doctest 的边界:只适合无副作用的纯函数表达式,GenServer 状态、文件 IO、时间敏感逻辑写不进去;文档示例占了模块测试的三成、单测占七成,是我用下来比较舒服的配比。Elixir 1.18 内置的 JSON 模块这类标准库 API,随手在 doc 里写一行 JSON.encode!(%{a: 1}) 的示例,doctest 就替你盯住了跨版本的兼容。
把 OTP 篇的进程补上测试
回到 Counter。名字问题解决后,完整测试模块收敛成这样:
defmodule CounterTest do
use ExUnit.Case, async: true
setup do
{:ok, counter: start_supervised!({Counter, 0})}
end
test "bump 两次加到 2", %{counter: pid} do
GenServer.cast(pid, :bump)
GenServer.cast(pid, :bump)
assert GenServer.call(pid, :get) == 2
end
test "get 不改变状态", %{counter: pid} do
GenServer.cast(pid, :bump)
assert GenServer.call(pid, :get) == 1
assert GenServer.call(pid, :get) == 1
end
end
预期输出:mix test 打印 2 tests, 0 failures,async 时间列非零说明确实在并行跑。比”测试绿了”更重要的是这两条测试反向校验了设计:如果 handle_call 里写了什么慢操作,第二条用例的两次 call 会把它放大出来;如果 init 的初始值参数没接住,第一条直接红。实战篇的聚合器 GenServer 更复杂——每秒有 send_after 定时器、有 PubSub 广播——它的测试策略是把窗口聚合抽成纯函数先测纯逻辑,进程壳与广播行为留给 LiveView 集成口径,这一层取舍放在实战篇的语境下更完整,本文不展开。
常见坑
- 测试模块忘了 async: true:不写也能跑,但默认串行单核,全量测试时间成倍涨;反之写了 async 却撞共享名字,会随机性 already_started。
- 裸 GenServer.start_link 不进监督树:用例结束进程还活着,下一条用例读到脏状态。测试里一律 start_supervised!,让框架替你回收。
- 用 Process.sleep 等异步:慢且不稳。cast 后面接一次 call 当栅栏,或者干脆把”通知完成”做成显式消息。
- setup_all 里塞可变状态:随机顺序下用例互相污染,现象是”单独跑绿、全量跑红”。
- doctest 示例带了时间戳或随机值:文档示例每次跑结果都不同,doctest 永远红。示例只放确定性表达式。
验收清单
- test/ 下至少两个测试模块,均 async: true,mix test 输出里 async 时间非零且 0 failures。
- setup 返回 keyword、test 参数解构取值的链路至少出现一次;setup_all 仅用于只读固定数据。
- Counter 经名字参数改造后,mix test 连跑十次全绿;把 start_link 里的锁名逻辑改回写死名字,用例必须报 already_started。
- 至少一个模块挂 doctest,其中包含一条异常示例;故意把实现改成 trunc 跑一遍确认 doctest 变红。
- 测试代码 grep 不到 Process.sleep;所有异步等待都通过 call 栅栏或显式消息完成。
路线位置:测试排在语法与 OTP 之后,整条线的阶段排布看Elixir 学习路线的对应阶段。被 GenServer 回调卡住时回 OTP 篇对照四个回调的职责表;这篇之后的下一站是实战篇的 LiveView 工程怎么在真实项目里组织 test 目录,以及排在这之后的 Ecto 数据层。
本文数据核对时间:2025-03-21:Elixir 1.18(1.18.0 于 2024-12-19 GA,对应 Erlang/OTP 27 线)+ ExUnit/doctest/mix test 均为语言内置,无独立版本号;start_supervised!、async 隔离语义以官方 ExUnit 文档为准。