Elixir 测试入门:ExUnit 与 doctest

《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 文档为准。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇