Efx
带有副作用的测试通常很难。存在各种解决方案来规避这些困难,例如模拟(mocking)。本库通过模拟提供了一种非常简便的方式来编写可测试的代码。我们不再谈论模拟,而是将副作用绑定到另一个实现。
Efx 提供了一种声明式的方法来标记具有副作用的函数并在测试中绑定它们。
Efx 允许在子进程中甚至进行异步测试,因为它使用进程字典 来存储绑定并在监督树中查找它们(参见此 测试用例)。
设计理由
Efx 是一个小型库,只做一件事并且做得非常好:使包含副作用的代码可测试。
现有的模拟库通常以非声明式的方式设置模拟:需要
调整配置并初始化模拟。在源代码中存在侵入性的
指令来设置可模拟的代码。Efx 在源代码和测试代码中都非常无侵入性。它提供了一种方便且声明式的语法。我们不再谈论模拟,而是将副作用绑定。
Efx 遵循以下原则:
- 实现和绑定效果应尽可能简单和声明式。
- 模块包含只能作为一组进行绑定的效果集合。
- 我们希望尽可能多地异步运行测试。因此,我们以隔离的方式遍历 监督树,以在生成的测试进程中查找重新绑定的效果。
- 效果在测试中默认执行其默认实现,因此必须显式绑定。
- 效果只能在测试中绑定,而不能在生产环境中绑定。在生产环境中,始终执行默认实现。
- 我们希望生产环境中零性能开销。
用法
设置
要在您的项目中使用 Efx,请在 mix.ex 的依赖项中添加以下内容:
{:efx, "~> 1.0.1"}
如果您希望 Efx.defeffect/2 宏具有正确的格式,您可以将
以下行添加到您的 .formatter.ex 中:
[
...,
import_deps: [:efx]
]
示例
给定以下代码:
defmodule MyModule do
def read_data() do
File.read!("file.txt")
|> deserialize()
end
def write_data(data) do
serialized_data = data |> serialize()
File.write!("file.txt", deserialized_data)
end
defp deserialize(raw) do
...
end
defp serialize(data) do
...
end
end
在此示例中,测试反序列化和序列化相当复杂,因为 我们需要为每个测试正确准备并放置文件。
我们可以使用 Efx 重写该模块,如下所示:
defmodule MyModule do
use Efx
def read_data() do
read_file!()
|> deserialize()
end
def write_data(data) do
data
|> serialize()
|> write_file!()
end
@spec read_file!() :: binary()
defeffect read_file!() do
File.read!("file.txt")
end
@spec write_file!(binary()) :: :ok
defeffect write_file!(raw) do
File.write!("file.txt", raw)
end
...
end
通过使用 defeffect-宏,我们定义了一个 effect-function,并在其主体中提供了一个默认实现。每个 effect-function 都必须有一个匹配的 spec。
上述代码现在易于测试,因为我们可以轻松重新绑定 effect-functions:
defmodule MyModuleTest do
use EfxCase
describe "read_data/0" do
test "works as expected with empty file" do
bind(&MyModule.read_file!/0, fn -> "" end)
bind(&MyModule.write_file!/1, fn _ -> :ok end)
# test code here
...
end
test "works as expected with proper contents" do
bind(&MyModule.read_file!/0, fn -> "some expected file content" end)
bind(&MyModule.write_file!/1, fn _ -> :ok end)
# test code here
...
end
end
end
而不是返回默认实现的值,MyModule.read_file!/0 返回测试用例所需的测试数据。MyModule.write_file! 不执行任何操作。
有关更多详细信息,请参阅 EfxCase 模块。
注意:Efx 生成一个行为
请注意,Efx 会生成并实现一个行为。因此,建议将副作用移至专用子模块,以免意外干扰现有行为。 话虽如此,我们创建以下模块:
defmodule MyModule.Effects do
use Efx
@spec read_file!() :: binary()
defeffect read_file!() do
File.read!("file.txt")
end
@spec write_file!(binary()) :: :ok
defeffect write_file!(raw) do
File.write!("file.txt", raw)
end
end
并直接在原始模块中使用它:
defmodule MyModule do
alias MyModule.Effects
def read_data() do
Effects.read_file!()
|> deserialize()
end
def write_data(data) do
data
|> serialize()
|> Effects.write_file!()
end
...
end
这样,我们就能在具有副作用的代码和纯代码之间实现清晰的分离。
委托副作用
以我们使用 Kernel.defdelegate/2 的相同方式,我们可以实现仅委托给另一个函数的副作用函数,如下所示:
@spec to_atom(String.t()) :: atom()
delegateeffect to_atom(str), to: String
delegateeffect 遵循与 Kernel.defdelegate/2 相同的语法。
使用 defdelegate 定义的函数可以在测试中绑定,就像它们是使用 defeffect 创建的一样。
自定义测试环境
默认情况下,绑定仅在 mix-env :test 中有效。在其他环境中,effect 函数会被剥离为其默认函数体,以避免产生运行时开销。如果你需要在其他测试环境中使用绑定,可以在配置中添加以下内容:
config :efx, :test_envs, [:test, :other_test_env]
请注意,您还需要显式列出 :test。
需要 OTP 版本 25
进程字典中的 ancestor-key 对于 Erlang 来说相对较新。它是在 OTP 25 中引入的,因此这是最低要求的 OTP 版本。
许可证
版权所有 © 2024 Bravobike GmbH 及贡献者
本项目采用 Apache 2.0 许可证授权。