我如何在Erlang中进行依赖注入和模拟?

22

在Java编写代码时,采用组合依赖注入非常有帮助,通过模拟协作对象可以轻松进行纯单元测试。

我发现在Erlang中做同样的事情不太直接,会使代码变得更加混乱。

这可能是我的错,因为我对Erlang还很陌生,而且很沉迷于JUnit、EasyMock和java接口...

假设我有这个愚蠢的函数:

%% module mymod
handle_announce(Announce) ->
    AnnounceDetails = details_db:fetch_details(Announce),
    AnnounceStats = stats_db:fetch_stats(Announce),
    {AnnounceDetails, AnnounceStats}.

在对 mymod 进行单元测试时,我只想证明 details_dbstats_db 被传递了正确的参数,并且返回值被正确使用。而 details_dbstats_db 生成正确值的能力已在其他地方进行了测试。

为了解决这个问题,我可以通过以下方式重构我的代码:

%% module mymod
handle_announce(Announce, [DetailsDb, StatsDb]) ->
    AnnounceDetails = DetailsDb:fetch_details(Announce),
    AnnounceStats = StatsDb:fetch_stats(Announce),
    {AnnounceDetails, AnnounceStats}.

可以这样进行测试(基本上是将调用直接存根到测试模块中):

%% module mymod_test
handle_announce_test() ->
    R = mymod:handle_announce({announce, a_value}, [?MODULE, ?MODULE, ?MODULE]),
    ?assertEqual({details,stats}, R).

fetch_details({announce, a_value}) ->
    details.

fetch_stats({announce, a_value}) ->
    stats.

它能够工作,但应用程序代码变得混乱,我总是不得不携带那个丑陋的模块列表。

我尝试过几个模拟库(erlymock 和 (另一个) 但我并不满意。

你如何对你的Erlang代码进行单元测试?

谢谢!

4个回答

23
这里有两个需要考虑的事情...
你需要将所有的代码分成两种不同类型的模块:
- 纯函数模块(也称为无副作用模块) - 带有副作用的模块
(你应该阅读相关资料并确保你理解它们之间的区别——最典型的副作用是在数据库中写入。)
纯函数模块变得非常容易测试。每个导出的函数(根据定义)在输入相同的值时总是返回相同的值。你可以使用Richard Carlsson和Mickael Remond编写的EUnit/Assert框架。轻松愉快,工作完成...
关键是你的90%代码应该在纯函数模块中,这样你就能大幅缩小问题的范围。(你可能认为这不是“解决”问题,只是“减少”问题——你大多数时候是对的...)
一旦你完成了这个分离,最好的单元测试带有副作用的模块的方法是使用standard test framework
我们的做法不是使用mock对象,而是在init_per_suite或init_per_test函数中加载数据库,然后运行模块本身...

最好的方法是尽快转向系统测试,因为单元测试很难维护 - 因此只需拥有足够的单元测试来完成系统测试往返,不要再多了(最好尽快删除数据库单元测试)。


谢谢Gordon,讲解得非常好。我仍在努力转向函数式编程范式。无论如何,在我正在编写的项目中(一个Torrent Tracker),所有调用都源自Web层并最终到达数据库,因此大多数模块都具有或依赖于副作用。我将尝试标准的测试框架。 - Matteo Caprari
好的 Erlang 代码应该有很多小函数。重构并将您的代码拆分为实用模块,您会惊讶地发现其中很少涉及对数据库的编写。在 Erlang 中,15-25 行是一个很长的函数。纯函数是指接受一组参数、对其进行计算并返回值的函数 - 您应该有很多这样的函数。 - Gordon Guthrie
我并不认同你应该尽早转向集成测试。通过参数化模块和erlymock提供的模拟功能,给定的函数将变得非常容易测试。您可以在模块内部通过模拟两个db模块来进行测试。您可以通过将db模块作为您模块的参数,在其他测试中使用此模块。任何研究Erlang模拟的人都应该再次关注erlymock--它的主要问题是缺乏文档,因此您必须阅读源代码才能理解它。 - Jon Watte
2
具有副作用模块的单元测试问题在于维护成本。让我举个具体的例子。在构建系统时,我们不得不进行大量的性能调整和重构工作。每次这样做时,我们都必须重新调整数据库架构等内容。拥有系统测试意味着我们可以直接进行测试,如果失败,测试套件会告诉我们。如果我们有很多单元测试,它们将与数据库表示形式相关联,并且必须进行重写。 - Gordon Guthrie

4
我只是直接回答问题,不试图判断作者是否应该这样做。
使用meck,您可以编写如下示例的单元测试:
handle_announce_test() ->
    %% Given
    meck:new([details_db, stats_db]),
    meck:expect(details_db, fetch_details, ["Announce"], "AnnounceDetails"),
    meck:expect(stats_db, fetch_stats, ["Announce"], "AnnounceStats"),
    %% When
    Result = handle_announce("Announce"),
    %% Then
    ?assertMatch({"AnnounceDetails", "AnnounceStats"}, Result),
    %% Cleanup
    meck:unload().

我使用字符串只是为了强调它们不是真正传递的值,而是虚假的值。由于语法高亮,它们在测试代码中很容易被发现。

老实说,我曾经是一名深爱Mockito的Java开发人员,最近转向Erlang,并开始为上述项目做贡献。


4

高登是正确的,主要目标是测试没有副作用的小函数。

但是...也有可能测试集成,让我们展示一下如何做到这一点。

注入

避免使用列表来携带参数化的依赖项。使用记录、进程字典、参数化模块。代码会更少丑陋。

接缝

不要把变量模块作为依赖接缝的重点,让进程成为接缝。硬编码注册过的进程名是错失注入依赖项的机会。


3
我不建议使用进程字典。它看起来很简单易用,但是它是共享状态,最终会导致难以发现的错误。在Erlang中,很难概念化和理性化进程的生命周期(尽管你可能认为你可以),而进程生命周期的变化是导致你的清洁进程字典出现严重问题的根源。我从痛苦的经历中得知这一点 :( - Gordon Guthrie
1
@Christian,emock的链接似乎失效了。这个链接是否正确:https://github.com/noss/emock? - James Kingsbery

4

我赞同Guthrie所说的。您会惊讶于有多少逻辑可以转换为纯函数。

最近我尝试使用新的参数化模块来进行依赖注入。这避免了参数列表和进程字典的问题。如果您可以使用最近版本的Erlang,那么这也可能是一个不错的选择。


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