How do I write Elixir tests?
How do I write Elixir tests?
I created this post for myself to codify some basic guides that I use while writing tests. If you, my dear reader, want to read this, then remember one important thing: These are guides not rules. Each codebase is different and exceptions are expected and will happen. Just use the thing between your ears in your coding.
我撰写这篇文章是为了整理自己在编写测试时所遵循的一些基本准则。亲爱的读者,如果你想阅读本文,请记住最重要的一点:这些是准则而非规则。每个代码库的情况各不相同,例外情况不仅会发生,而且是必然的。在编码时,请务必发挥你自己的判断力。
@subject module attribute for module under test
While reading ExUnit test, I often find it hard to remember which of the used modules is tested. Imagine test like:
test "foo should frobnicate when bar" do
bar = pick_bar()
assert :ok == MyBehaviour.foo(MyImplementation, bar)
end
It is not obvious at the first sight what is tested here. And this is pretty simplified example. In real world it can became even harder to notice what is module under test (MUT).
在阅读 ExUnit 测试时,我经常难以分辨所使用的模块中哪一个是正在被测试的。想象一下这样的测试:
test "foo should frobnicate when bar" do
bar = pick_bar()
assert :ok == MyBehaviour.foo(MyImplementation, bar)
end
初看之下,这里测试的对象并不明显。这还是一个非常简化的例子,在现实世界中,要识别“被测模块”(MUT)可能会更加困难。
To resolve that I came up with a simple solution. I create module attribute named @subject that points to the MUT:
@subject MyImplementation
test "foo should frobnicate when bar" do
bar = pick_bar()
assert :ok == MyBehaviour.foo(@subject, bar)
end
Now it is more obvious what is MUT and what is just wrapper code around it.
为了解决这个问题,我想出了一个简单的方案:创建一个名为 @subject 的模块属性,指向被测模块(MUT):
@subject MyImplementation
test "foo should frobnicate when bar" do
bar = pick_bar()
assert :ok == MyBehaviour.foo(@subject, bar)
end
现在,什么是被测模块,什么是外层的包装代码,一目了然。
describe with function name
That one is pretty basic. I have seen that it is pretty standard for people: when you are writing tests for module functions, then group them in describe blocks that will contain name (and arity) of the function in the name.
这是一个非常基础的准则。我发现这已成为一种标准做法:在为模块函数编写测试时,将其归入 describe 代码块中,并在块名称中包含函数名(及其元数/arity)。
# Module under test
defmodule Foo do
def a(x, y, z) do # some code end
end
# Tests
defmodule FooTest do
use ExUnit.Case, async: true
@subject Foo
describe "a/3" do
# Some tests here
end
end
This allows me to see what functionality I am testing. Of course that doesn’t apply to the Phoenix controllers, as there we do not test functions, but tuples in form {method, path} which I then write as METHOD path, for example POST /users. But the idea still stands - describe block provide immediate context about what is tested.
这让我能清晰地看到我正在测试的功能。当然,这不适用于 Phoenix 控制器,因为在那里我们测试的不是函数,而是 {method, path} 形式的元组,我通常将其写为 METHOD path,例如 POST /users。但核心思想是一样的——describe 代码块提供了关于测试内容的即时上下文。
Avoid module mocking
In Elixir we have bunch of the mocking libraries out there, but most of them have quite substantial issue for me - these prevent me from using async: true for my tests. This often causes substantial performance hit, as it prevents different modules to run in parallel.
在 Elixir 生态中有很多 Mock 库,但对我来说,它们大多数都有一个严重的问题——它们阻止我在测试中使用 async: true。这通常会导致显著的性能损耗,因为它阻碍了不同模块的并行运行。
Instead of mocks I prefer to utilise dependency injection. Some people may argue that “Elixir is FP, not OOP, there is no need for dependency injection”. They could not be further from truth. DI isn’t related to OOP, it just have different form - function arguments.
与其使用 Mock,我更倾向于使用依赖注入(DI)。有些人可能会争辩说:“Elixir 是函数式编程(FP),不是面向对象编程(OOP),不需要依赖注入。”这种观点大错特错。DI 与 OOP 无关,它只是以不同的形式存在——即函数参数。
For example, if we want to have function that do something with time, in particular - current time, then instead of writing:
def my_function(a, b) do
do_foo(a, b, DateTime.utc_now())
end
Which would require me to use mocks for DateTime or other workarounds to make tests time-independent. I would do:
def my_function(a, b, now \\ DateTime.utc_now()) do
do_foo(a, b, now)
end
Which still provide me the ergonomics of my_function/2 as above, but is way easier to test, as I can pass the date to the function itself. Now I can run this test in parallel as it will not cause other tests to do weird stuff because of altered DateTime behaviour.
例如,如果我们想要一个处理时间的函数(特别是当前时间),与其写成:
def my_function(a, b) do
do_foo(a, b, DateTime.utc_now())
end
这会迫使我必须 Mock DateTime 或使用其他变通方法来确保测试与时间无关。我更倾向于这样做:
def my_function(a, b, now \\ DateTime.utc_now()) do
do_foo(a, b, now)
end
这既保留了上述 my_function/2 的易用性,又大大简化了测试,因为我可以将日期直接传递给函数。现在我可以并行运行此测试,因为它不会因为改变了 DateTime 的行为而导致其他测试出现异常。
Avoid ex_machina factories
I have poor experience with tools like ex_machina or similar. These often bring whole “Banana Gorilla Jungle” problem back, just changed a little, as now instead of just passing data around, we create all needless structures for sole purpose of test, even when they aren’t needed for anything.
我对 ex_machina 之类的工具体验不佳。它们往往会把“香蕉大猩猩丛林”问题(指为了获取一个对象而不得不创建一连串依赖对象)带回来,只是换了种形式。我们不再是简单地传递数据,而是为了测试目的创建了大量不必要的结构,即使它们在测试中根本没用到。
For start we can see a single problem there - we do not validate our factories against our schema changesets. Without additional tests like:
@subject MyApp.Article
test "factory conforms to changeset" do
changeset = @subject.changeset(%@subject{}, params_for(:article))
assert changeset.valid?
end
We cannot be sure that our tests test what we want them to test. And if we pass custom attribute values in some tests it gets even worse, because we cannot be sure if these are conforming either. That mean that our tests may be moot, because we aren’t testing against real situations, but against some predefined state.
首先,这里有一个明显的问题——我们没有根据 Schema 的 Changeset 来验证 Factory。如果没有像下面这样的额外测试:
@subject MyApp.Article
test "factory conforms to changeset" do
changeset = @subject.changeset(%@subject{}, params_for(:article))
assert changeset.valid?
end
我们就无法确定测试是否真的达到了目的。如果我们还在某些测试中传入了自定义属性值,情况会更糟,因为我们无法确定这些值是否符合规范。这意味着我们的测试可能是无效的,因为我们测试的不是真实场景,而是一些预定义的状态。