TDD(测试驱动开发)erlang

单元测试

2018-09-18  本文已影响5人  虫大侠

erlang 单元测试

learn you some erlang for great good

欧盟国家委员会

测试的需要

我们编写的软件随着时间的推移变的越来越大,越来越复杂.
当发生这种情况时,启动 Erlang shell, 输入内容,查看结果以及确保代码更改后,能够正常工作.
随着时间的推移,每个人都可以更加简单的运行之前准备好的测试代码,而不是一直按照清单去手工检查软件里的所有测试案例.
您也可能是测试驱动开发的粉丝,因此也会发现测试很有用.

如果你还记得我们编写 RPN 计算器的章节,我们有一些手动编写的测试方法.
它们只是一组 Result = Expression 形式的模式匹配,如果出现问题就会崩溃,否则会成功.

这适用于您自己编写的简单代码,但是当我们进行更严格的测试时,我们肯定会想要更好的东西,比如:框架.

对于单元测试,我们倾向于坚持 EUnit, 我们在本章中看到的.
对于集成测试, EUnit 和 Common Test 都可以完成这项工作.

事实上, Common Test 可以完成单元测试到系统测试,实质是外部软件测试的所有工作,而不是用 erlang 编写的.
现在我们将使用 EUnit, 因为它产生好的结果是那么简单.

单元测试,什么是单元测试

EUnit,最简单的形式,只是模块中以 _test 结尾的自动运行的方法,就认为他们是单元测试.
如果你去发掘我上面提到的 RPN 计算器,你会发现以下代码:

rpn_test() ->
    5 = rpn("2 3 +"),
    87 = rpn("90 3 -"),
    -4 = rpn("10 4 3 + 2 * -"),
    -2.0 = rpn("10 4 3 + 2 * - 2 /"),
    ok = try
        rpn("90 34 12 33 55 66 + * - +")
    catch
        error:{badmatch,[_|_]} -> ok
    end,
    4037 = rpn("90 34 12 33 55 66 + * - + -"),
    8.0 =  rpn("2 3 ^"),
    true = math:sqrt(2) == rpn("2 0.5 ^"),
    true = math:log(2.7) == rpn("2.7 ln"),
    true = math:log10(2.7) == rpn("2.7 log10"),
    50 = rpn("10 10 10 20 sum"),
    10.0 = rpn("10 10 10 20 sum 5 /"),
    1000.0 = rpn("10 10 20 0.5 prod"),
    ok.

这正是我们编写的测试函数,以确保计算器工作正常.找到之前的模块,并尝试运行以下命令.

1> c(calc).
{ok,calc}
2> eunit:test(calc).
  Test passed.
ok

调用eunit:test(Module).正是我们需要的,是的,我们现在知道了 EUnit, 打开香槟,让我们进入一个不同的章节.

显然,只做这一点测试框架不会非常有用,而且在技术程序员的术语中,他可能被描述为"不太好".
EUnit 不仅仅是自动导出和运行以 _test()结尾的函数.

例如,您可以将测试移动到另一个模块,以便您的代码和测试不会混合在一起.
这意味着,您不能再测试私有函数,但也意味着,如果您针对模块的接口(导出的函数)开发所有测试,那么在重构代码时,不需要重写测试.

让我们尝试用两个简单的模块分离测试和代码.

所以我们有 ops 和 ops_test, 其中第二个包括与第一个相关的测试.这是 EUnit 可以做的事情.

调用 eunit:test(Mod) 会自动查找 Mod_tests 并在其中运行测试.让我们稍微改变测试(使其成为 3= ops:add(2,2)) 以查看失败的样子

我们可以看到测试失败的结果和原因.
我们获得了有关通过和失败的测试数量的完整报告.虽然输出很糟糕.

至少和普通的 Erlang 崩溃一样糟糕:没有行号,没有明确的解释,并没有确切的匹配什么内容 等等.
偶尔们对运行测试的测试框架感到无助,但并没有告诉你太多关于他们的信息.
出于这个原因, EUnit 引入了一些宏来帮助我们.他们中的每一个都将为我们提供更清晰的报告(包括行号)和更清晰的语义.
他们知道出了什么问题和知道出错的原因是不同的

?assert(Expression), ?assertNot(Expression)

将测试布尔值.如果除 true 之外的任何值传入 ?assert ,将会展示错误提示.同样对于 ?assertNot,
对于不正确的值,这个宏相当于执行了 true =X 或者 false=Y.

?assertEqual(A, B)

在两个表达式之间,进行严格比较,相当于=:=, 如果他们不等,将会提示失败.这大致相当于 true= X=:=Y.
从 R14B04版本开始,宏 ?assertNorEqual 是与 ?assertEqual 相反的操作

?assertMatch(Pattern, Expression)

这允许我们以类似于 Pattern = Expression 的形式进行匹配,而不需要绑定变量.
这意味着我可以执行类似的操作: ?aseertMatch({X,X},some_function()),并断言我收到一个元素相同的元组.
而且,我以后可以做 ?assertMatch(X,Y), 并且 X不用绑定

这就是说,不用像 Pattern= Expression, 我们更接近于 (fun(Pattern) -> true; (_)-> erlang:error(nomatch) end)(Expressson):
模式中的变量头,永远不会收到多个断言的绑定.
?assertNotMatch在 EUnitR14B04版本中加入

?assertError(Pattern, Expression)

告诉 EUnit 表达式应该导致错误, 例如: ?assertError(badarith,1/0)将会是一次成功的测试

?assertThrow(Pattern, Expression)

?assertError 完全相同,但是使用 exit(Pattern) (并不是 exit/2)并不是erlang:error(Pattern)

?assertException(Class, Pattern, Expression)

先前三个宏一样的形式,例如:?assertException(error,Pattern,Expression),与?assertError(Pattern,Expression)相同,从 R14B04版本开始,还有可以用于 ?assertNotException/3的宏

使用这些宏,我们可以再我们的模块中编写更好的测试

-module(ops_tests).
-include_lib("eunit/include/eunit.hrl").

add_test() ->
    4 = ops:add(2,2).

new_add_test() ->
    ?assertEqual(4, ops:add(2,2)),
    ?assertEqual(3, ops:add(1,2)),
    ?assert(is_number(ops:add(1,2))),
    ?assertEqual(3, ops:add(1,1)),
    ?assertError(badarith, 1/0).

看看错误报告有多好,我们知道 ops_tests第11行的 assertEqual 失败了.
当我们调用 ops:add(1,1),我们认为会得到3作为值,但是我们得到2

当然,您必须将这些值当做 erlang 术语来读,但至少他们在那里

然而,令人讨厌的是,即使我们有5个断言,但是只有一个失败,但整个测试仍然被视为失败
如果知道某些断言失败,而没有表现的所有其他断言也失败,那就更好了

我们的测试相当于在学校考试,一旦你犯错,你就会失败并被抛弃.
然后你就像是一只死狗了,而且你只有一个可怕的一天了

测试生成器

由于这种对灵活性的共同需求, EUnit 支持成为测试生成器的东西.测试生成器非常简单,可以用巧妙的方式在稍后运行的函数中包含断言.
我们将使用以 test() 结尾的函数和assertSomething 形式的宏,而不是以 _test()结尾的函数和?assertSomething 形式的宏

这些都是微小的变化,但他们使事情变得更加强大.以下两个测试将是等效的.

function_test() -> ?assert(A == B).
function_test_() -> ?_assert(A == B).

这里, funtion_test_()被称为测试生成器函数,而 ?assert(A==B) 被称作是测试生成器

他被称为这样,因为谜底是,?_assert(A==B)的底层是现实 fun()->?_assert(A==B) end. 也就是说生成测试的函数

与常规断言相比,测试生成器的优势在于他们是 fun 函数,这意味着,可以再不执行的情况下操纵他们.
事实上,我们可以拥有以下形式的测试集

my_test_() ->
  [?_assert(A),
    [
      ?_assert(B), ?_assert(C), [?_assert(D)]
    ],
    [[?_assert(E)]]
  ].

测试集可以试测试生成器的深层嵌套列表.我们拥有可以返回测试函数的函数,我们将以下内容添加到 ops_test:

add_test_() ->
  [test_them_types(),
  test_them_values(),
  ?_assertError(badarith, 1/0)].
 
test_them_types() ->
  ?_assert(is_number(ops:add(1,2))).
 
test_them_values() ->
  [?_assertEqual(4, ops:add(2,2)),
  ?_assertEqual(3, ops:add(1,2)),
  ?_assertEqual(3, ops:add(1,1))].

因为只有 add_test_()_ test_中结束,所以两个函数 test_them_Something()不会被视为测试.实际上,他们只会被 add_test_调用以生成测试.

8> c(ops_tests).
./ops_tests.erl:12: Warning: this expression will fail with a 'badarith' exception
{ok,ops_tests}
9> eunit:test(ops).
ops_tests: new_add_test...*failed*
::error:{assertEqual_failed,[{module,ops_tests},
                           {line,11},
                           {expression,"ops : add ( 1 , 1 )"},
                           {expected,3},
                           {value,2}]}
  in function ops_tests:'-new_add_test/0-fun-3-'/1
  in call from ops_tests:new_add_test/0


=======================================================
  Failed: 1.  Skipped: 0.  Passed: 1.
error

所以我们仍然得到预期的失败,现在你看到我们从2个测试跳到了7个,测试生成器的魔力

如果我们只想测试套件的某些部分,也许只是 add_test_/0怎么办,那么 EUnit 有一些技巧

3> eunit:test({generator, fun ops_tests:add_test_/0}). 
ops_tests:25: test_them_values...*failed*
::error:{assertEqual_failed,[{module,ops_tests},
                           {line,25},
                           {expression,"ops : add ( 1 , 1 )"},
                           {expected,3},
                           {value,2}]}
  in function ops_tests:'-test_them_values/0-fun-4-'/1

=======================================================
  Failed: 1.  Skipped: 0.  Passed: 4.
error

请注意,这仅适用于测试生成器功能,我们在这里的 {generator,Fun} 就是 EUnit 的用法所谓的测试表示.我们还有一些其他表示

这些不同的测试表示,可以轻松地为整个应用程序甚至版本运行测试套件

装置, 测试套件

test fixtur 名词解释

仅通过使用断言和测试生成器来测试整个应用程序,仍然非常困难.
这就是添加固定装置的原因, Fixtures 虽然不是让你的测试运行到应用程序级别的全能解决方案,但允许你围绕测试建一个特定的脚手架.

所讨论的脚手架是一种通用结构,允许我们为每个测试定义设置和拆卸功能.

这些函数将允许您构建每个测试所需要的状态和环境,此外,脚手架将允许您指定如何运行测试,您想在本地,在单独的进程中运行他们吗.

有几种类型的脚手架可供选择,并且各种变化,第一种类型简称为设置脚手架.设置脚手架可以采用以下多种形式之一.

{setup, Setup, Instantiator}
{setup, Setup, Cleanup, Instantiator}
{setup, Where, Setup, Instantiator}
{setup, Where, Setup, Cleanup, Instantiator}

哎呀,看来我们需要阅读一些 EUnit 词汇才能理解这一点,(如果您需要阅读 EUnit 文档,这将非常有用)

Setup
一个不带参数的函数,每个测试都将传递 setup 函数返回的值

cleanup
一种函数,他将 setup 函数的结果作为参数,并负责清理所需的任何内容..如果在 OTP 中终止与 init 相反,则 cleanup 功能和 setup 功能相反

Instantiator 实例化

她是一个获取设置函数结果并返回测试集的函数,请记住,测试集可能是深层嵌套的 ?_Macro 断言列表

where
指定如何运行测试, local,spawn,{spawn,node()}

好吧,那么在实践中看起来像什么,好吧,让我想象一下测试,以确保虚拟进程注册表正确处理尝试注册相同的进程两次,使用不同的名称:

double_register_test_() ->
    {setup,
     fun start/0,               % setup function
     fun stop/1,                % teardown function
     fun two_names_one_pid/1}.  % instantiator

start() ->
    {ok, Pid} = registry:start_link(),
    Pid.

stop(Pid) ->
    registry:stop(Pid).

two_names_one_pid(Pid) ->
    ok = registry:register(Pid, quite_a_unique_name, self()),
    Res = registry:register(Pid, my_other_name_is_more_creative, self()),
    [?_assertEqual({error, already_named}, Res)].

这个脚手架,首先在 start/0函数内启动注册表服务器,然后调用实例化 two_names_one_pid(REsultFromSetup).
在那个测试中,我唯一要做的就是尝试两次注册当前进程.

这就是实例化器工作的地方,第二次注册的结果存储在 Res 变量中,
然后,该函数将返回包含单个测试的测试集 (?assertEqual({error,already_named},Res)).

该测试集将由 EUnit 运行,然后将调用 teardown 拆卸方法 stop/1将会被调用.使用 setup 函数返回的 pid, 它将能够关闭我们事先启动的注册表. 美好.

更好的是整个脚手架本身可以放在一个测试装置中

some_test_() ->
    [{setup, fun start/0, fun stop/1, fun some_instantiator1/1},
     {setup, fun start/0, fun stop/1, fun some_instantiator2/1},
     ...
     {setup, fun start/0, fun stop/1, fun some_instantiatorN/1}].

这将有效,令人烦恼的是需要始终重复设置和拆卸功能,特别是当他们始终相同时,这就是第二种类型的脚手架,即 foreach 脚手架进入舞台的地方

{foreach, Where, Setup, Cleanup, [Instantiator]}
{foreach, Setup, Cleanup, [Instantiator]}
{foreach, Where, Setup, [Instantiator]}
{foreach, Setup, [Instantiator]}

foreach脚手架与 setup 脚手架非常相似,区别在于他需要实例化表,这是使用 foreach 脚手架编写 some_test_/0函数

some2_test_() ->
    {foreach,
     fun start/0,
     fun stop/1,
     [fun some_instantiator1/1,
      fun some_instantiator2/1,
      ...
      fun some_instantiatorN/1]}.

那更好,然后 foreach 脚手架将获取每个实例化器,并为每个实例化器运行 setup 和 teardown 功能

现在我们知道如何为一个实例化器设置一个脚手架,然后为他们中的许多实例设置(每个实例都进行setup 和 teardown 函数调用).
如果我想要一个setup 函数调用,并且一个 teaddown 函数,需要多个实例化器,该怎么办

换句话说,如果我有很多实例化器,但我只想设置一些状态呢. 对此有没有简单的办法,但这可能是一个技巧

some_tricky_test_() ->
    {setup,
     fun start/0,
     fun stop/1,
     fun (SetupData) ->
        [some_instantiator1(SetupData),
         some_instantiator2(SetupData),
         ...
         some_instantiatorN(SetupData)]
     end}.

通过使用测试集可以是深层嵌套列表的事实,我们将一对具有匿名函数实例化器包装秤类似于他们的实例化器

当您使用脚手架时,测试还可以对他们应该如何运行进行更精细的控制,有四种选择

  1. {spawn, TestSet}
    • 在主要测试过程之外的单独过程中运行测试
    • 测试过程将等待所有生成的测试完成
  2. {timeout, Seconds, TestSet}
    • 测试将运行数秒,如果他们花费时间超出,他们将被终止,而不会更加轻松
  3. {inorder, TestSet}
    • 这告诉 EUnit 严格按照返回的顺序在测试集中运行测试
  4. {inparallel, Tests}
    • 在可能的情形下,测试将并行运行

作为例子, some_tricky_test_/0 测试生成器可以重写为下面这样

some_tricky2_test_() ->
    {setup,
     fun start/0,
     fun stop/1,
     fun(SetupData) ->
       {inparallel,
        [some_instantiator1(SetupData),
         some_instantiator2(SetupData),
         ...
         some_instantiatorN(SetupData)]}
     end}.

这对于脚手架来说,真的是大部,但是现在还有一个我忘了展示的好玩法.
您可以以一种简洁的方法描述测试,看一下这个

double_register_test_() ->
    {"Verifies that the registry doesn't allow a single process to "
     "be registered under two names. We assume that each pid has the "
     "exclusive right to only one name",
     {setup,
      fun start/0,
      fun stop/1,
      fun two_names_one_pid/1}}.

很好,对吧,你可以通过执行{Comment,Fixture}来包装脚手架,以获得可读性,让我们把它付诸实践吧

注册测试

因为只是看到上面的虚假测试并不是最有趣的事情,并且以为假装测试不存在的软件更糟糕
我们将研究我为 regis-1.0.0进程注册表编写的测试, 这是 Process Quest 使用的测试

现在, regis 的开发是以测试驱动的方式完成的. 希望你不讨厌 TDD(测试驱动开发),但即使你这样做,也不应该太糟糕,因为事后
我们会看看测试套件

通过这样做,我们切断了一些试错序列,并且我可能已经第一次编写了它,并且由于文本编辑的魔力,我看起来真的很称职.
regis 应用程序由三个过程组成:

我们可以安全的编写一个专注于server 本身的测试套件,而不需要任何外部依赖

作为一名优秀的 TDD 粉丝,我首先编写了一分我想要涵盖的所有功能的列表:

这是一个值得尊敬的名单.逐个完成元素并按照我的方式添加案例,我将每个规范转换为测试.获得最终的文件是 regis_server_tests.我们使用基本结构写了一些东西

-module(regis_server_tests).
-include_lib("eunit/include/eunit.hrl").

%%%%%%%%%%%%%%%%%%%%%%%%%%
%%% TESTS DESCRIPTIONS %%%
%%%%%%%%%%%%%%%%%%%%%%%%%%

%%%%%%%%%%%%%%%%%%%%%%%
%%% SETUP FUNCTIONS %%%
%%%%%%%%%%%%%%%%%%%%%%%

%%%%%%%%%%%%%%%%%%%%
%%% ACTUAL TESTS %%%
%%%%%%%%%%%%%%%%%%%%

%%%%%%%%%%%%%%%%%%%%%%%%
%%% HELPER FUNCTIONS %%%
%%%%%%%%%%%%%%%%%%%%%%%%

好吧, 我把它给你,当模块是空时看起来很奇怪,但是当你填满它,他会越来越有意义
添加第一个测试后,最初的测试是应该可以启动服务器并按名称访问他,该文件看起来像这样:

-module(regis_server_tests).
-include_lib("eunit/include/eunit.hrl").

%%%%%%%%%%%%%%%%%%%%%%%%%%
%%% TESTS DESCRIPTIONS %%%
%%%%%%%%%%%%%%%%%%%%%%%%%%
start_stop_test_() ->
    {"The server can be started, stopped and has a registered name",
     {setup,
      fun start/0,
      fun stop/1,
      fun is_registered/1}}.

%%%%%%%%%%%%%%%%%%%%%%%
%%% SETUP FUNCTIONS %%%
%%%%%%%%%%%%%%%%%%%%%%%
start() ->
    {ok, Pid} = regis_server:start_link(),
    Pid.

stop(_) ->
    regis_server:stop().

%%%%%%%%%%%%%%%%%%%%
%%% ACTUAL TESTS %%%
%%%%%%%%%%%%%%%%%%%%
is_registered(Pid) ->
    [?_assert(erlang:is_process_alive(Pid)),
     ?_assertEqual(Pid, whereis(regis_server))].

%%%%%%%%%%%%%%%%%%%%%%%%
%%% HELPER FUNCTIONS %%%
%%%%%%%%%%%%%%%%%%%%%%%%

现在看组织, 已经好多了
文件顶部仅包含脚手架和功能的顶级描述
第二部分,包含我们可能需要的装置和清理功能,我们最后一个包返回测试集的实例化器

在这种情况下,实例化器会检查 regis_server:start_link() 是否生成了一个真正存活的进程,
并且它是使用名称 regis_server 注册的.如果这是真的,那么这将适用于服务

如果我们查看文件的当前版本,她现在看起来更像是这两个第一部分.

-module(regis_server_tests).
-include_lib("eunit/include/eunit.hrl").

-define(setup(F), {setup, fun start/0, fun stop/1, F}).

%%%%%%%%%%%%%%%%%%%%%%%%%%
%%% TESTS DESCRIPTIONS %%%
%%%%%%%%%%%%%%%%%%%%%%%%%%

start_stop_test_() ->
    {"The server can be started, stopped and has a registered name",
     ?setup(fun is_registered/1)}.

register_test_() ->
    [{"A process can be registered and contacted",
      ?setup(fun register_contact/1)},
     {"A list of registered processes can be obtained",
      ?setup(fun registered_list/1)},
     {"An undefined name should return 'undefined' to crash calls",
      ?setup(fun noregister/1)},
     {"A process can not have two names",
      ?setup(fun two_names_one_pid/1)},
     {"Two processes cannot share the same name",
      ?setup(fun two_pids_one_name/1)}].

unregister_test_() ->
    [{"A process that was registered can be registered again iff it was "
      "unregistered between both calls",
      ?setup(fun re_un_register/1)},
     {"Unregistering never crashes",
      ?setup(fun unregister_nocrash/1)},
     {"A crash unregisters a process",
      ?setup(fun crash_unregisters/1)}].

%%%%%%%%%%%%%%%%%%%%%%%
%%% SETUP FUNCTIONS %%%
%%%%%%%%%%%%%%%%%%%%%%%
start() ->
    {ok, Pid} = regis_server:start_link(),
    Pid.

stop(_) ->
    regis_server:stop().

%%%%%%%%%%%%%%%%%%%%%%%%
%%% HELPER FUNCTIONS %%%
%%%%%%%%%%%%%%%%%%%%%%%%
%% nothing here yet

不错,不是吗. 请注意,在我编写套件时,我最终看到我从不需要任何其他设置和拆卸功能,而不是 start/0stop/1
出于这个原因,我添加了 set_up 宏,这使得事情看起来必有脚手架都要完全展开要好一些,
现在很明显,我将功能列表的每个点都变成了一堆测试
你会注意到我根据他们是否与启动和停止有关,注册进程和取消进程.

通过阅读测试生成器的定义,我们可以知道该模块应该做什么.测试生成文档(尽管他们不应取代适当的文档)

我们将稍微研究一下测试,看看事情是以某种方式完成.列表 start_stop_test_/0中的第一个测试,只需要注册服务

start_stop_test_() ->
    {"The server can be started, stopped and has a registered name",
     ?setup(fun is_registered/1)}.

测试本身的实现,放在 is_register/1函数中:

%%%%%%%%%%%%%%%%%%%%
%%% ACTUAL TESTS %%%
%%%%%%%%%%%%%%%%%%%%
is_registered(Pid) ->
    [?_assert(erlang:is_process_alive(Pid)),
     ?_assertEqual(Pid, whereis(regis_server))].

如前所述,当我们查看测试的第一个版本时,会检查该过程是否可用.
虽然函数 erlang:is_process_alive(Pid) 对你来说可能不熟悉,但 没有什么特别之处.
顾名思义,他会检查进程当前是否正在运行.

我把那个测试放在那里的原因很简单,一旦我们启动它很可能服务器崩溃,或者它从未在第一个时间开始崩溃,我们不希望这样
第二个测试与能够注册流程有关

{"A process can be registered and contacted",
 ?setup(fun register_contact/1)}

这是测试代码的样子:

register_contact(_) ->
    Pid = spawn_link(fun() -> callback(regcontact) end),
    timer:sleep(15),
    Ref = make_ref(),
    WherePid = regis_server:whereis(regcontact),
    regis_server:whereis(regcontact) ! {self(), Ref, hi},
    Rec = receive
         {Ref, hi} -> true
         after 2000 -> false
    end,
    [?_assertEqual(Pid, WherePid),
     ?_assert(Rec)].

当然这不是最优雅的测试,他的作用是产生一个过程,它只会注册自己并回复我们发送的一些消息.
这都是在 call_back/1 辅助函数中完成的,定义如下:

%%%%%%%%%%%%%%%%%%%%%%%%
%%% HELPER FUNCTIONS %%%
%%%%%%%%%%%%%%%%%%%%%%%%
callback(Name) ->
    ok = regis_server:register(Name, self()),
    receive
        {From, Ref, Msg} -> From ! {Ref, Msg}
    end.

因此该函数具有模块寄存器本身,接收消息,并发回响应.
一旦进程启动, register_contract/1 实例化器等待15毫秒,以确保其他进程自己注册
然后尝试使用 regis_server 中的whereis 函数来检索 Pid 并向进程发送消息. 如果 regis 服务器运行正常,
将收到一条消息,并且 Pid 将在函数底部的测试中匹配

不要喝太多的 kool-aid: 通过阅读该测试,您已经看到了我们必须做的小计时器工作,由于erlang 程序的并发性,和时间敏感性,
测试通常会被这样的小型计时器填充,这些计时器的唯一作用是尝试同步代码.
然后问题就是尝试定义一个好的定时器,延迟足够长.
如果系统正在运行高负荷的事情,计时器是否会等待足够长的时间.
编写测试的 erlang 程序员有时必须聪明,才能够最大限度的减少他们需要他们需要多少同步才能使事情发挥作用
没有更简单的方案

接下来的测试介绍如下:

{"A list of registered processes can be obtained",
 ?setup(fun registered_list/1)}

因此,当注册了一对进程,应该可以获得所有名称的列表,这是一个 类似于 erlang 的 registed() 函数的调用

registered_list(_) ->
    L1 = regis_server:get_names(),
    Pids = [spawn(fun() -> callback(N) end) || N <- lists:seq(1,15)],
    timer:sleep(200),
    L2 = regis_server:get_names(),
    [exit(Pid, kill) || Pid <- Pids],
    [?_assertEqual([], L1),
     ?_assertEqual(lists:sort(lists:seq(1,15)), lists:sort(L2))].

首先,我们确保注册进程的第一个列表是空的 ?_assertEqual([],L1),这样即使没有任何进程注册自己,我们也能运行.
然后创建了15个进程,所有进程都尝试使用数字1...15注册自己,我们让测试睡眠一会儿,以确保所有进程都有时间注册自己
然后调用 regis_server:get_names().

名称应包含1到15之间的所有整数,然后我们通过消除所有已注册的进程进行轻微清晰,毕竟我不想泄漏他们
在测试集中使用测试之前,您会注意到测试在变量 L1和 L2 中存储状态的趋势.
这样做的原因是返回的测试集在测试启动器与醒后很好的执行
如果尝试在?_assert*宏中放置依赖于其他进程和时间明暗时间的函数调用,
你会让一切都不同步,对于你和使用你的软件的人来说,事情通常会很糟糕

下一个测试很简单:

{"An undefined name should return 'undefined' to crash calls",
 ?setup(fun noregister/1)}

...

noregister(_) ->
    [?_assertError(badarg, regis_server:whereis(make_ref()) ! hi),
     ?_assertEqual(undefined, regis_server:whereis(make_ref()))].

这与我们在本章前一节的演示中使用的测试几乎相同.
在这一个中,我们只是想看看我们是否得到了正确的输出,并且测试过程中,不能用不同的名称注册两次

注意: 您可能已经注意到上面的测试经常使用 make_ref() 一大堆, 如果可能, 使用 make_ref 这样的唯一值的函数很有用,
如果将来有人想要并行运行测试或者在一个永不停止的 regis 服务下运行他们,那么就可以这样做而无需修改测试.
如果我们在所有测试中使用硬编码名称(如,a,b,c)
那么如果我们尝试同时运行多个测试套件,很可能迟早会发生名称冲突,并非 regis_server_tests 套件中的所有测试都遵循此建议
主要用于演示目的/

接下来的测试与 two_names_one_pid 相反:

{"Two processes cannot share the same name",
 ?setup(fun two_pids_one_name/1)}].

...

two_pids_one_name(_) ->
    Pid = spawn(fun() -> callback(myname) end),
    timer:sleep(15),
    Res = regis_server:register(myname, self()),
    exit(Pid, kill),
    [?_assertEqual({error, name_taken}, Res)].

这里,因为我们需要两个进程并且只需要其中一个进程的结果,所以诀窍是产生一个进程(我们不需要其结果的进程),然后自己完成关键部分

您可以看到定时器用于确保其他进程首先尝试注册名称在 call_back/1回调函数内
并且测试本身等待轮到尝试,因此期望出现错误元祖
这涵盖了与流程注册相关的测试的所有功能.
只留下与取消注册进程相关的那些:

unregister_test_() ->
    [{"A process that was registered can be registered again iff it was "
      "unregistered between both calls",
      ?setup(fun re_un_register/1)},
     {"Unregistering never crashes",
      ?setup(fun unregister_nocrash/1)},
     {"A crash unregisters a process",
      ?setup(fun crash_unregisters/1)}].

让我们看看他们是如何实现的,第一个很简单;

re_un_register(_) ->
    Ref = make_ref(),
    L = [regis_server:register(Ref, self()),
         regis_server:register(make_ref(), self()),
         regis_server:unregister(Ref),
         regis_server:register(make_ref(), self())],
    [?_assertEqual([ok, {error, already_named}, ok, ok], L)].

这种序列化列表中所有调用的方式是我在需要测试所有事件的结果时最喜欢做的一个很好的技巧.
通过将他们放入列表中,我可以将操作的顺序与预期的列表进行比价,看看情况如何

请注意,没有任何指定 erlang 应该按顺序评估列表,但上面的技巧几乎总是有效

以下测试,一个关于永不崩溃的测试,如下所示:

unregister_nocrash(_) ->
    ?_assertEqual(ok, regis_server:unregister(make_ref())).

哇,慢慢来,哥们. 就这样吧,而已, 是的,如果你回顾一下 re_un_register 你会发现他已经处理了对流程注销的测试
对于 unregister_nocrash 我们真的只想知道它是否可以尝试删除不存在的进程

然后是最后一个测试,也是您将拥有的任何测试注册表中最终的测试之一:崩溃的命名进程将具有未注册的名称
这有严重的影响,因为如果你没有删除名称,你最终会有一个不断增长的注册表服务,其名称选择越来越少

crash_unregisters(_) ->
    Ref = make_ref(),
    Pid = spawn(fun() -> callback(Ref) end),
    timer:sleep(150),
    Pid = regis_server:whereis(Ref),
    exit(Pid, kill),
    timer:sleep(95),
    regis_server:register(Ref, self()),
    S = regis_server:whereis(Ref),
    Self = self(),
    ?_assertEqual(Self, S).

这个按顺序读取:

老实说,测试可以用更简单的方式编写:

crash_unregisters(_) ->
    Ref = make_ref(),
    Pid = spawn(fun() -> callback(Ref) end),
    timer:sleep(150),
    Pid = regis_server:whereis(Ref),
    exit(Pid, kill),
    ?_assertEqual(undefined, regis_server:whereis(Ref)).

关于窃取死亡过程身份的整个部分只不过一个小偷的幻想.
而已. 如果你做得对,你应该能够编译代码并运行测试

$ erl -make
Recompile: src/regis_sup
...
$ erl -pa ebin/
1> eunit:test(regis_server).
  All 13 tests passed.
ok
2> eunit:test(regis_server, [verbose]).
======================== EUnit ========================
module 'regis_server'
  module 'regis_server_tests'
    The server can be started, stopped and has a registered name
      regis_server_tests:49: is_registered...ok
      regis_server_tests:50: is_registered...ok
      [done in 0.006 s]
...
  [done in 0.520 s]
=======================================================
  All 13 tests passed.
ok

哦,是的,看看如何添加 verbose 选项,会将测试描述和运行时信息添加到报告中,那很整齐

谁编织单元测试

在本章中,我们已经了解如何使用 EUnit 的大多数功能,如何运行写在其中的套件,更重要的是, 我们已经看到了一些与如何使用
在现实世界中有意义的模式编写并发进程测试相关的技术

应该知道最后一个测试技巧:
当您想要测试 gen_serversgen_fsms 等流程时,您可能会想要检查流程内部的 state, 这是一个很好的技巧,由 sys 模块提供:

3> regis_server:start_link().
{ok,<0.160.0>}
4> regis_server:register(shell, self()).
ok
5> sys:get_status(whereis(regis_server)).
{status,<0.160.0>,
        {module,gen_server},
        [[{'$ancestors',[<0.31.0>]},
          {'$initial_call',{regis_server,init,1}}],
         running,<0.31.0>,[],
         [{header,"Status for generic server regis_server"},
          {data,[{"Status",running},
                 {"Parent",<0.31.0>},
                 {"Logged events",[]}]},
          {data,[{"State",
                  {state,{1,{<0.31.0>,{shell,#Ref<0.0.0.333>},nil,nil}},
                         {1,{shell,{<0.31.0>,#Ref<0.0.0.333>},nil,nil}}}}]}]]}

整洁,对吧,与服务内部相关的一切都是给你的:
你现在可以随时检查你需要的一切?
如果您对服务和诸如此类的东西感觉更舒服,建议您阅读 为 Process Quests 的播放器模块写的测试

他们使用不同的技术测试 gen_server, 其中对 handle_call / handle_cast / handle_info 的所有单独调用都是独立尝试的
无论如何,当我们重写流程注册表以使用 ets 时,我们将看到测试的真正价值, ets 是一个可用于所有 erlang 进程的内存数据库.

引自https://www.jianshu.com/p/b6856a15478a

上一篇下一篇

猜你喜欢

热点阅读