如何编写Elixir测试?
摘要
博客文章提供编写Elixir测试的指南,包括使用@subject模块属性、describe块,以及避免模块模拟而采用依赖注入。
<p><a href="https://lobste.rs/s/4xgljh/how_do_i_write_elixir_tests">评论</a></p>
查看缓存全文
缓存时间: 2026/05/14 14:31
# Hauleth 的博客 - 如何编写 Elixir 测试?
来源:https://hauleth.dev/post/writing-tests/
我写了这篇文章来整理自己在编写测试时遵循的一些基本指南。亲爱的读者,如果你也想读一读,请记住重要的一点:
这些是**指南**,不是**规则**。每个代码库都不同,例外情况很常见,而且*一定*会出现。请运用自己的判断力来编写代码。
## `@subject` 模块属性表示被测模块
(https://hauleth.dev/post/writing-tests/#subject-module-attribute-for-module-under-test)
阅读 ExUnit 测试时,我经常发现很难记住哪个模块是被测试的。想象一个这样的测试:
``
test "foo 应在 bar 时进行 frobnicate" do
bar = pick_bar()
assert :ok == MyBehaviour.foo(MyImplementation, bar)
end
``
一眼看去并不清楚这里测试的是什么。这还是一个相当简化的例子。在真实世界中,要一眼看出哪个是被测模块(MUT)就更难了。
为了解决这个问题,我想出了一个简单的方案。我创建一个名为 `@subject` 的模块属性,指向被测模块:
``
@subject MyImplementation
test "foo 应在 bar 时进行 frobnicate" do
bar = pick_bar()
assert :ok == MyBehaviour.foo(@subject, bar)
end
``
现在谁是被测模块,谁只是包装代码就一目了然了。
过去我曾使用 `alias` 搭配 `:as` 选项,比如:
``
alias MyImplementation, as: Subject
``
不过,我觉得模块属性在视觉上更突出,这让我更容易注意到 `@subject` 而不是 `Subject`。但你的效果可能会有所不同。
## `describe` 块中使用函数名
(https://hauleth.dev/post/writing-tests/#describe-with-function-name)
这一点很基础。我看到很多人这样做:当你为模块函数编写测试时,将它们分组到 `describe` 块中,块名称包含函数名(和元数)。例如:
``
# 被测模块
defmodule Foo do
def a(x, y, z) do
# 一些代码
end
end
# 测试
defmodule FooTest do
use ExUnit.Case, async: true
@subject Foo
describe "a/3" do
# 一些测试在这里
end
end
``
这样我能一目了然地看到正在测试的功能。
当然,这不适用于 Phoenix 控制器,因为我们测试的不是函数,而是 `{method, path}` 这样的元组。我会将其写为 `METHOD path`,例如 `POST /users`。但思路依然成立——`describe` 块提供了关于测试内容的即时上下文。
## 避免模块模拟
(https://hauleth.dev/post/writing-tests/#avoid-module-mocking)
在 Elixir 中我们有很多模拟库,但大多数对我来说有个相当大的问题——它们让我无法在测试中使用 `async: true`。这通常会导致严重的性能损失,因为它阻止不同模块并行运行(不是单个测试,而是*模块*,但这可能是另一篇文章的内容了)。
我更喜欢使用依赖注入来代替模拟。有些人可能会争辩说“Elixir 是函数式编程,不是面向对象编程,不需要依赖注入”。他们大错特错了。DI 与 OOP 无关,它只是有不同的形式——函数参数。例如,如果我们有一个需要时间,特别是当前时间的函数,那么与其写成:
``
def my_function(a, b) do
do_foo(a, b, DateTime.utc_now())
end
``
这将要求我为 `DateTime` 使用模拟或其他变通方法来使测试不依赖于时间。我会这样做:
``
def my_function(a, b, now \\ DateTime.utc_now()) do
do_foo(a, b, now)
end
``
这仍然提供了与上面 `my_function/2` 相同的易用性,但测试起来更容易,因为我可以将日期直接传递给函数。现在我可以并行运行这个测试,它不会因为 `DateTime` 行为被篡改而导致其他测试出现奇怪的问题。
我在编写向外部服务发送 HTTP(S) 请求的函数时经常使用这种方法。我使用一个可选的关键字列表参数,名字叫 `opts`(非常有创意)。通过它,我可以传递诸如 `:host` 这样的选项,从而可以使用像 `test_server`(https://github.com/danschultzer/test_server)这样的工具,这很棒,而且在我看来比任何模拟都好得多。
## 避免使用 `ex_machina` 工厂
(https://hauleth.dev/post/writing-tests/#avoid-ex-machina-factories)
我对 `ex_machina` 或类似工具的使用体验不太好。它们通常会把整个香蕉大猩猩丛林问题(https://softwareengineering.stackexchange.com/q/368797)带回来,只是换了个形式——现在不是仅仅传递数据,而是为了测试创建所有不必要的结构,即使它们根本不需要。
我们从 ExMachina README(https://github.com/beam-community/ex_machina#overview)中的例子开始:
``
defmodule MyApp.Factory do
# 使用 Ecto
use ExMachina.Ecto, repo: MyApp.Repo
# 不使用 Ecto
use ExMachina
def user_factory do
%MyApp.User{
name: "Jane Smith",
email: sequence(:email, &"email-#{&1}@example.com"),
role: sequence(:role, ["admin", "user", "other"]),
}
end
def article_factory do
title = sequence(:title, &"Use ExMachina! (Part #{&1})")
# 派生属性
slug = MyApp.Article.title_to_slug(title)
%MyApp.Article{
title: title,
slug: slug,
# 当你调用 `insert` 时,关联会被插入
author: build(:user),
}
end
# 派生工厂
def featured_article_factory do
struct!(
article_factory(),
%{
featured: true,
}
)
end
def comment_factory do
%MyApp.Comment{
text: "It's great!",
article: build(:article),
author: build(:user)
}
end
end
``
首先,我们可以看到一个单一的问题——我们没有针对模式变更集验证工厂。如果没有额外的测试,比如:
``
@subject MyApp.Article
test "工厂符合变更集" do
changeset = @subject.changeset(%@subject{}, params_for(:article))
assert changeset.valid?
end
``
我们就无法确定测试是否测试了我们想要的内容。如果我们还在某些测试中传递自定义属性值,情况就更糟了,因为我们无法确定这些属性值是否也符合变更集。
这意味着我们的测试可能是徒劳的,因为我们没有针对真实情况进行测试,而是针对某些预定义的状态进行测试。
另一个问题是,如果我们需要改变工厂的行为,可能会变得相当复杂。想象一下,我们要测试文章作者的评论是否具有特殊行为(例如它有额外的 CSS 类以便在 CSS 中标记)。这需要我们在传递自定义属性方面做一些花哨的操作:
``
test "作者的评论是特殊的" do
post = insert(:post)
comment = insert(:comment, post: post, author: post.author)
# 测试的其余部分
end
``
这还是一个简化的例子。过去我需要处理这样的情况:我需要创建大量数据,通过自定义属性传递,以使测试合理。
相反,我更喜欢直接在代码中处理。与其依赖某些“神奇”的外部库宏提供的“神奇”函数,不如使用我已经拥有的东西——应用程序中的函数。
与其写成:
``
test "作者的评论是特殊的" do
post = insert(:post)
comment = insert(:comment, post: post, author: post.author)
# 测试的其余部分
end
``
不如写成:
``
test "作者的评论是特殊的" do
author = MyApp.Users.create(%{
name: "John Doe",
email: "[email protected]"
})
post = MyApp.Blog.create_article(%{
author: author,
content: "Foo bar",
title: "Foo bar"
})
comment = MyApp.Blog.create_comment_for(article, %{
author: author,
content: "Foo bar"
})
# 测试的其余部分
end
``
这可能会稍微冗长一些,但我认为它使测试更具可读性。所有细节都一目了然,你知道该期待什么。如果你在模块或 `describe` 块的所有(或几乎所有)测试中都需要某块数据,那么你可以随时使用 `setup/1` 块。或者,你可以为每个模块创建一个生成数据的函数。只要你的测试模块是自包含的,并且不凭空接收“神奇”的数据,我就觉得没问题。但我认为 `ex_machina` 是一个来自 Rails 世界的糟糕想法,在 Elixir 中几乎没有意义。
如果你*真的*需要这样的工厂,那么请编写你自己的函数,使用你的上下文,而不是依赖另一个库。例如:
``
import ExUnit.Assertions
def create_user(name, email \\ nil, attrs \\ %{}) do
email = email || "#{String.replace(name, " ", ".")}@example.com"
attrs = Map.merge(attrs, %{name: name, email: email})
assert {:ok, user} = MyApp.Users.create(attrs)
user
end
# 依此类推...
``
这样你就不再需要检查所有测试是否使用了正确的验证,因为你的系统会替你完成。不再有与“不可能的数据”打交道的意外情况。
## 属性测试非常棒
(https://hauleth.dev/post/writing-tests/#property-testing-is-awesome)
基于属性的测试(https://en.wikipedia.org/wiki/Property_testing)是一个非常广泛的话题,这篇文章不是用来描述这种方法的所有可能性的。已经有书籍(https://propertesting.com/)专门讨论这个主题。然而,我认为记住这种方法的存在是有用的,它在很多地方都非常有用(不是所有地方,不要试图把方木桩塞进圆孔)。
我可以展示的一个快速示例是:
``
property "有效名称以字母数字开头,并由字母数字、下划线和破折号组成" do
check all(
prefix <- string(:alphanumeric, length: 1),
suffix <- string([?a..?z, ?A..?Z, ?-, ?_], max_length: 24)
) do
changeset =
@subject.register_changeset(%@subject{}, %{name: prefix <> suffix})
assert nil == changeset.errors[:name]
end
end
``
它检查所有以字母数字字符开头、后跟最多 24 个字符(包含字母数字、破折号和下划线)的用户名是否都能通过验证。
这个测试会尝试生成*随机*的用户名集合,然后检查它们是否全部通过测试。如果某些失败,系统会尝试缩减找到的例子,以创建最小化的失败例子。这不是完全确定性的测试(输出可能取决于随机选择的种子),但如果与其他测试正确结合,它将极大地提高对测试的信心。
## 结语
(https://hauleth.dev/post/writing-tests/#parting-words)
测试应该是可读的,通常甚至比代码本身更可读。一个好的测试套件可以帮助你对更改充满信心。在当今充满智能体编程的世界中,测试变得更加重要,因为有了好的测试套件,你可以给智能体更多的自主权。
此外,我认为模拟必须被摧毁。
∎
相似文章
上下文使测试更具可重用性
作者分享了在Guile中设计测试框架的经验,重点探讨了向测试定义添加上下文如何使测试更可重用并改善开发者体验。
Elixir 应用优化之旅
一位开发者分享了优化 Elixir 应用的经验与教训,重点介绍了针对 Postgres 连接池工具 Ultravisor 的性能改进。文章涵盖了使用火焰图、调用追踪等性能分析技术,以及 eFlambè 和 tprof 等工具。
基于Markdown的测试套件
作者解释了为EndBASIC的编译器和虚拟机切换到基于Markdown的测试套件的原因,目的是让这些测试作为LLM学习该语言独特特性的权威文档。
我(至今)在使用 Elixir 和 Swift 构建在线小游戏中学到的东西
一位开发者反思了使用 Elixir 与 Phoenix 以及 Swift 与 SpriteKit 构建在线小游戏应用 Migo Games 的经历,强调了 AI 编码辅助的作用以及 Elixir 进程模型的可扩展性优势。
@kettanaito:越来越多的人向我询问测试资源,所以我把写过的所有内容汇总在一篇文章里。收藏、…
作者将一系列关于软件测试基础的文章进行了汇总,涵盖了测试的目的、断言、代码覆盖率以及处理不稳定性测试等内容。