Elixir/ExUnit:如何最优雅地测试带有系统调用的函数?

3

情况

通常,像ExUnit这样的单元测试应该是自包含的,包括输入、函数调用和期望输出,这样测试可以在任何系统上运行,并且无论环境如何都能正确测试。

另一方面,如果您的应用程序执行系统调用,例如使用Elixir的System.cmd/3或Erlang的:os.cmd/1并处理结果,则由于不同/更新的二进制文件、不同的操作系统等原因,您的测试可能会得到不同的结果。

当然,在这些情况下测试失败是好事,这样你就可以增加对现实生活情况的覆盖率。然而,在开发时,您希望先让您的函数正确运行,然后再进行正确的操作。如果外部世界发生变化,则始终可预测地运行测试是困难甚至不可能的。

此外,您可能希望测试很少或几乎从不发生的条件,但是您的系统调用不会给您提供那些信息,因为那确实很少发生。您需要以某种方式模拟系统调用的输出并将其与程序的内部逻辑分离。


例子

为了保持简单(相同的原则适用于更复杂的情况),考虑读取系统的启动时间并根据清理后的结果进行响应:

def what_time do
  time =
    :os.cmd('who -b | cut -d\' \' -f14') # Returns something like '13:50\n'
    |> to_string
    |> String.trim("\n")
    |> String.split(":")
    |> List.to_tuple
  case time do
    {"12", "00"} -> {:ok, "It's High Noon!"}
    _ -> {:error, "meh"}
  end
end

这个函数只有在特定时间重启系统才能正确测试,这当然是不合理的。但由于大致了解输出格式,您可以创建一个测试值列表,例如['16:04','23:59','12:00',"12:00",2,"xyz",'1.0"],并在没有系统调用的情况下测试解析部分,然后像往常一样将其与您期望的结果进行比较。

天真的方法

但是,怎么做呢?系统调用是函数中的第一件事,因此如果将其提取到单独的函数中,则可以测试系统调用,但这对您帮助不大,因为系统调用本身就是问题所在:
def what_time do
  time = get_time
    |> to_string
    [...]
end

def get_time do
  :os.cmd('who -b | cut -d\' \' -f14') # Returns something like '13:50\n'
end

稍微改进一下...

如果您添加另一个辅助方法,只需解析字符串/字符列表,就可以实现您想要的功能,同时使系统调用本身变为私有:

def what_time do
  what_time_helper(get_time())
end

def what_time_helper(time) do
  time =
    time
    |> to_string
    [...]
  end
end

defp get_time do
  :os.cmd('who -b | cut -d\' \' -f14') # Returns something like '13:50\n'
end

现在你可以在ExUnit案例中调用helper测试函数,普通程序可以调用普通函数。
但是这种方法似乎不太优雅。我可以看到以下缺点:
1.每个函数都需要分成私有系统调用、公共辅助和公共正常方法,使函数数量增加三倍。由于没有必要的分区,结果代码更长,更难以阅读。
2.为了进行测试,helper方法需要是公共的,但不应该对外暴露。因此,必须编写额外的文档,API参考变得更长,并且该方法必须执行更多的检查以确保安全操作(而之前,只有由系统调用本身产生的值才可能发生)。
3.虽然小的主函数只调用另一个预定义集合,但它不能包含在测试覆盖范围内。这个抱怨有点吹毛求疵,但我想象一下如果使用自动化测试工具显示代码行或函数数量的测试覆盖率,这会变得很麻烦。
问题:
那么我的问题是:
1.如何在测试中正确处理这种情况,例如使用ExUnit?
2.如何将系统调用与内部逻辑分离并减少样板函数的数量?
3.在函数式编程中通常如何处理这种情况?

1
你可以使用 https://github.com/jjh42/mock 来模拟 :os.cmd/1 - Dogbert
1
不确定这是否适用于Elixir,但至少在一个C++应用程序中,我们通过一个包装器进行所有系统调用,以便我们可以轻松地进行模拟 - merlin2011
@merlin2011 是的,这是我在面向对象语言中完成的方式,因为您可以轻松地提供符合相同接口的真实和测试实现。没有任何对象,我目前有点难以找到正确的方法。 - user493184
请注意,模拟:os模块函数可能会很困难,至少对于:meck来说是这样的:https://github.com/eproxus/meck#caveats。将它们包装在实用程序模块中并进行模拟可能是一个好主意。 - nietaki
1个回答

3
关于风格和将功能分成单独的函数或保留在一个函数中,取决于您的需求以及您希望如何处理代码。每种解决方案都有其优缺点(全部放在一个函数中或分离出来)。
关于测试方面,最好将操作系统调用视为外部API调用。这样做,您可以轻松地在测试中使用模拟和存根,以便您可以控制测试的内容和方式。
Jose Valim有一篇非常全面的博客文章,介绍了如何模拟和测试外部调用。我建议先阅读这篇文章。
如果您搜索一下,就会发现有一些库可以为您提供存根/模拟的功能:
  • ex_stub
  • meck - 在Erlang中,但我相信也可以在Elixir中使用
  • mock

感谢您的博客文章和建议,我会阅读并尝试编写适合我的示例的代码。 - user493184
1
请注意,只有在万不得已且您确切知道它们的作用时(主要是围绕全局模块名称进行操作),才应该使用Elixir模拟。还有更干净但稍微繁琐一些的方法可以实现这一点。 - cdegroot
@cdegroot,您是否指的是像José在链接帖子中展示的那样模拟对象(基本上就是在测试时替换普通类的类)?如果是,您有什么其他建议呢? - user493184
我在多个项目中使用Elixir的:meck,它非常简单且易于使用。 - nietaki
@nietaki 感谢确认,我本人还没有在 Elixir 中使用过它。但在 Erlang 的 EUnit / CT 中它完美运作。 - Máté
@user493184,我不喜欢José的提议,因为即使它解决了一些最严重的问题,它仍然停留在适当的模拟上,这类似于嘲笑整个类而不仅仅是一个实例。要做到这一点,您需要能够注入单个模拟进程来捕获它们的消息(并具有一些合理的行为以返回)。我仍然不确定正确的解决方案是什么,http://evrl.com/elixir/tdd/mocking/2017/03/05/elixir-mocking.html 在这里展示了我的一些想法(但还有更多内容,_然后_我才能在这里适当地发布答案;-)) - cdegroot

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接