联合国理事会
测试的必要性
我们编写的软件随着时间的推移变得越来越大,也变得越来越复杂。发生这种情况时,启动 Erlang shell、输入内容、查看结果,以及在代码更改后确保一切正常,变得非常乏味。随着时间的推移,让每个人运行预先准备好的测试,而不是始终遵循手动检查事项的清单,变得更简单。这些通常是您想要在软件中进行测试的非常好的理由。您也可能是测试驱动开发的粉丝,因此您也会发现测试很有用。
如果您还记得我们编写 RPN 计算器 的那一章,我们有一些手动编写的测试。它们只是一组模式匹配,形式为 Result = Expression
,如果出现问题,就会崩溃,否则就会成功。这适用于您为自己编写的简单代码片段,但当我们进行更严肃的测试时,我们肯定会需要更好的东西,比如一个框架。
对于单元测试,我们倾向于坚持使用 EUnit(我们在本章中看到)。对于集成测试,EUnit 和 Common Test 都可以完成这项工作。事实上,Common Test 可以从单元测试一直做到系统测试,甚至可以测试用 Erlang 编写的外部软件。现在,我们将使用 EUnit,因为它简单易用,并且可以带来良好的结果。
EUnit,什么是 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()
结尾的函数。首先,您可以将测试移到另一个模块,以便您的代码和测试不混合在一起。这意味着您无法再测试私有函数,但也意味着,如果您针对模块的接口(导出的函数)开发所有测试,那么在重构代码时您就不需要重写测试。让我们尝试使用两个简单的模块来分离测试和代码
-module(ops). -export([add/2]). add(A,B) -> A + B.
-module(ops_tests). -include_lib("eunit/include/eunit.hrl"). add_test() -> 4 = ops:add(2,2).
因此,我们有 ops 和 ops_tests,其中第二个包含与第一个相关的测试。以下是 EUnit 可以做的一件事
3> c(ops). {ok,ops} 4> c(ops_tests). {ok,ops_tests} 5> eunit:test(ops). Test passed. ok
调用 eunit:test(Mod)
会自动查找 Mod_tests
并运行其中的测试。让我们稍微更改一下测试(将其改为 3 = ops:add(2,2)
)看看失败的样子
6> c(ops_tests). {ok,ops_tests} 7> eunit:test(ops). ops_tests: add_test (module 'ops_tests')...*failed* ::error:{badmatch,4} in function ops_tests:add_test/0 ======================================================= Failed: 1. Skipped: 0. Passed: 0. error
我们可以看到哪个测试失败了(ops_tests: add_test...
)以及它为什么失败(::error:{badmatch,4}
)。我们还获得了关于有多少测试通过或失败的完整报告。但是,输出很糟糕。至少和常规的 Erlang 崩溃一样糟糕:没有行号,没有清晰的解释(4
到底与什么不匹配?),等等。我们对一个运行测试却没有告诉你太多信息的测试框架无能为力。
出于这个原因,EUnit 引入了一些宏来帮助我们。它们中的每一个都将提供更清晰的报告(包括行号)和更清晰的语义。它们是知道出现了问题和知道出现了问题的区别 为什么 出了问题
?assert(Expression), ?assertNot(Expression)
- 将测试布尔值。如果
?assert
中出现除true
以外的任何值,则会显示错误。?assertNot
也是如此,但用于负值。这个宏在某种程度上等效于true = X
或false = Y
。 ?assertEqual(A, B)
- 对两个表达式 A 和 B 进行严格比较(等效于
=:=
)。如果它们不同,则会发生失败。这大约等效于true = X =:= Y
。从 R14B04 开始,可以使用?assertNotEqual
宏来执行与?assertEqual
相反的操作。 ?assertMatch(Pattern, Expression)
- 这允许我们以类似于
Pattern = Expression
的形式进行匹配,而无需绑定任何变量。这意味着我可以执行类似于?assertMatch({X,X}, some_function())
的操作,并断言我收到一个包含两个相同元素的元组。此外,我以后可以执行?assertMatch(X,Y)
,并且 X 将不会绑定。 - 也就是说,与其真正像
Pattern = Expression
一样,我们所拥有的更接近(fun (Pattern) -> true; (_) -> erlang:error(nomatch) end)(Expression)
:模式头中的变量 永远 不会跨多个断言绑定。?assertNotMatch
宏已添加到 R14B04 中的 EUnit 中。 ?assertError(Pattern, Expression)
- 告诉 EUnit Expression 应该导致错误。例如,
?assertError(badarith, 1/0)
将是一个成功的测试。 ?assertThrow(Pattern, Expression)
- 与
?assertError
完全相同,但使用throw(Pattern)
而不是erlang:error(Pattern)
。 ?assertExit(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).
并运行它们
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
看看错误报告有多好?我们知道 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).
这里,function_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_tests 中
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_()
调用以生成测试
1> c(ops_tests). ./ops_tests.erl:12: Warning: this expression will fail with a 'badarith' exception ./ops_tests.erl:17: Warning: this expression will fail with a 'badarith' exception {ok,ops_tests} 2> eunit:test(ops). ops_tests:25: test_them_values...*failed* [...] ops_tests: new_add_test...*failed* [...] ======================================================= Failed: 2. Skipped: 0. Passed: 5. 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 行话中的 测试表示。我们还有其他一些表示
{module, Mod}
运行 Mod 中的所有测试{dir, Path}
运行在 Path 中找到的模块的所有测试{file, Path}
运行在单个编译模块中找到的所有测试{generator, Fun}
运行单个生成器函数作为测试,如上所示{application, AppName}
运行 AppName 的.app
文件中提到的所有模块的所有测试。
这些不同的测试表示可以轻松运行整个应用程序甚至版本的测试套件。
夹具
仅仅使用断言和测试生成器来测试整个应用程序仍然非常困难。这就是 夹具 被添加的原因。夹具虽然不是解决测试问题的一劳永逸的解决方案,但它们允许您在测试周围构建一个特定的脚手架。
所讨论的脚手架是一个通用结构,允许我们为每个测试定义设置和拆卸函数。这些函数将允许您构建每个测试所需的 state 和环境,使其有用。此外,脚手架将允许您指定如何运行测试(您是想在本地运行它们,还是在单独的进程中运行它们,等等?)
有几种类型的夹具可用,它们也有所不同。第一种类型简单地称为 设置 夹具。设置夹具采用以下几种形式之一
{setup, Setup, Instantiator} {setup, Setup, Cleanup, Instantiator} {setup, Where, Setup, Instantiator} {setup, Where, Setup, Cleanup, Instantiator}
啊!看来我们需要一些 EUnit 词汇才能理解这一点(如果您需要阅读 EUnit 文档,这将非常有用)
- 设置
- 一个不接受参数的函数。每个测试都将获得设置函数返回的值。
- 清理
- 一个函数,它以设置函数的结果作为参数,并负责清理所有需要清理的内容。如果在 OTP 中
terminate
与init
相反,那么清理函数就是 EUnit 中设置函数的相反操作。 - 实例化器
- 它是一个函数,它接受设置函数的结果并返回一个测试集(请记住,测试集可能是深度嵌套的
?_Macro
断言列表)。 - 其中
- 指定如何运行测试:
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,它将能够关闭我们之前启动的注册表。太棒了!
更棒的是,整个fixture本身可以放在一个测试集中
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}].
这将起作用!令人讨厌的是,总是需要重复那些setup和teardown函数,尤其是当它们总是相同的。这就是第二种类型的fixture,foreach fixture,登场的时候了
{foreach, Where, Setup, Cleanup, [Instantiator]} {foreach, Setup, Cleanup, [Instantiator]} {foreach, Where, Setup, [Instantiator]} {foreach, Setup, [Instantiator]}
foreach fixture与setup fixture非常相似,不同之处在于它接收实例化器的列表。以下是用foreach fixture编写的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 fixture将依次获取每个实例化器,并为每个实例化器运行setup和teardown函数。
现在我们知道如何为一个实例化器创建一个fixture,然后为多个实例化器创建一个fixture(每个实例化器都获得自己的setup和teardown函数调用)。如果我想对多个实例化器进行一次setup函数调用和一次teardown函数调用,该怎么办?
换句话说,如果我有许多实例化器,但只希望设置一次状态呢?没有简单的方法,但这里有一个小技巧可能可以做到
some_tricky_test_() -> {setup, fun start/0, fun stop/1, fun (SetupData) -> [some_instantiator1(SetupData), some_instantiator2(SetupData), ... some_instantiatorN(SetupData)] end}.
利用测试集可以是深度嵌套列表这一事实,我们将一堆实例化器包装在一个匿名函数中,该函数的行为类似于它们的实例化器。
测试还可以对使用fixture时运行方式进行更细粒度的控制。有四种选项可用
{spawn, TestSet}
- 在与主测试进程不同的进程中运行测试。测试进程将等待所有生成的测试完成
{timeout, Seconds, TestSet}
- 测试将运行Seconds秒。如果它们花费的时间超过Seconds秒,它们将被终止,不再进行任何处理。
{inorder, TestSet}
- 这告诉EUnit严格按照测试集中返回的顺序运行测试。
{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}.
这基本上是fixture的所有内容,但还有一个我暂时忘记展示的小技巧。你可以以一种简洁的方式提供测试描述。看看这个
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}
来包装fixture,以获得可读的测试。让我们把它付诸实践。
测试Regis
因为仅仅看到上面那样的假测试并不是最有趣的事情,而且假装测试不存在的软件更糟糕,所以我们将研究我为regis-1.0.0进程注册表编写的测试,该注册表是Process Quest使用的注册表。
现在,regis
的开发是采用测试驱动的方式进行的。希望你并不讨厌TDD(测试驱动开发),但即使你讨厌,也不应该太糟糕,因为我们将在事后查看测试套件。通过这样做,我们将消除我在第一次编写代码时可能遇到的那些反复试验和回溯,并且看起来我真的很熟练,这要归功于文本编辑的魔力。
regis应用程序由三个进程组成:一个supervisor,一个主服务器,以及一个应用程序回调模块。知道supervisor只检查服务器,应用程序回调模块除了充当两个其他模块的接口之外什么也不做,我们可以放心地编写一个专注于服务器本身的测试套件,没有任何外部依赖关系。
作为一个优秀的TDD粉丝,我首先列出了所有想要涵盖的功能
- 遵循与Erlang默认进程注册表类似的接口
- 服务器将有一个注册名称,以便可以在不跟踪其pid的情况下联系它
- 可以通过我们的服务注册进程,然后可以通过其名称联系它
- 可以获取所有注册进程的列表
- 任何进程都没有注册的名称应该返回原子'undefined'(与常规的Erlang注册表非常相似),以便使使用它们的调用崩溃
- 一个进程不能有两个名称
- 两个进程不能共享相同的名称
- 如果一个进程在调用之间被注销,则可以再次注册它
- 注销进程永远不会崩溃
- 注册进程的崩溃将注销其名称
这是一个很不错的列表。逐个执行这些元素并添加案例,我将每个规范都转换成了一个测试。最终获得的文件是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 %%% %%%%%%%%%%%%%%%%%%%%%%%%
现在看到组织了吗?已经好多了。文件的上半部分只包含fixture和功能的顶层描述。第二部分包含我们可能需要的setup和cleanup函数。最后一个包含返回测试集的实例化器。
在本例中,实例化器检查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/0
和stop/1
之外,我从不需要任何其他setup和teardown函数。因此,我添加了?setup(Instantiator)
宏,这使得代码看起来比完全展开所有fixture要好一些。现在很明显,我将功能列表中的每个要点都转换成了许多测试。你会注意到,我将所有测试都分成了两类,分别是与启动和停止服务器相关的测试(start_stop_test_/0
),注册进程相关的测试(register_test_/0
)和注销进程相关的测试(unregister_test_/0
)。
通过阅读测试生成器的定义,我们可以知道模块应该做什么。测试变成了文档(尽管它们不应该取代正常的文档)。
我们将研究一下这些测试,并看看为什么有些事情是以某种方式完成的。start_stop_test_/0
列表中的第一个测试,其简单的要求是服务器可以注册
start_stop_test_() -> {"The server can be started, stopped and has a registered name", ?setup(fun is_registered/1)}.
测试本身的实现放在is_registered/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)].
诚然,这不是最优雅的测试。它所做的是生成一个进程,该进程除了注册自身并回复我们发送给它的某些消息之外什么也不做。这一切都在以下定义的callback/1
辅助函数中完成
%%%%%%%%%%%%%%%%%%%%%%%% %%% HELPER FUNCTIONS %%% %%%%%%%%%%%%%%%%%%%%%%%% callback(Name) -> ok = regis_server:register(Name, self()), receive {From, Ref, Msg} -> From ! {Ref, Msg} end.
因此,该函数让模块注册自身,接收消息,并发送回复。进程启动后,register_contact/1
实例化器等待15毫秒(只是一点微小的延迟,以确保另一个进程注册自身),然后尝试使用regis_server
中的whereis
函数检索Pid,并将消息发送到该进程。如果regis服务器正常运行,将收到回复消息,并且在函数底部的测试中pid将匹配。
不要喝太多酷乐
通过阅读该测试,你已经看到了我们必须进行的微小计时器工作。由于Erlang程序的并发和时间敏感性,测试中经常会充斥着这样的微小计时器,它们唯一的职责是尝试同步代码片段。
问题就变成了尝试定义什么应该被认为是好的计时器,一个足够长的延迟。对于运行许多测试或甚至在负载很重的计算机上运行的系统,计时器是否仍然会等待足够长的时间?
编写测试的Erlang程序员有时必须很聪明,才能最大限度地减少他们需要进行的同步操作才能使事情正常工作。对此没有简单的解决方案。
接下来的测试的介绍如下
{"A list of registered processes can be obtained", ?setup(fun registered_list/1)}
因此,当注册了一堆进程时,应该可以获取所有名称的列表。这与Erlang的registered()
函数调用类似
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的所有整数,包括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()))].
如你所见,它测试了两个方面:我们返回undefined
,以及规范中假设使用undefined
确实会使尝试的调用崩溃。对于这一方面,不需要使用临时变量来存储状态:这两个测试可以在regis服务器生命周期的任何时间执行,因为我们从未更改它的状态。
我们继续
{"A process can not have two names", ?setup(fun two_names_one_pid/1)}, ... two_names_one_pid(_) -> ok = regis_server:register(make_ref(), self()), Res = regis_server:register(make_ref(), self()), [?_assertEqual({error, already_named}, Res)].
这几乎与我们在本章前面部分演示中使用的测试相同。在这个测试中,我们只是想知道我们是否获得了正确的输出,以及测试进程是否无法使用不同的名称注册自身两次。
注意:你可能已经注意到上面的测试倾向于大量使用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)].
在这里,因为我们需要两个进程,并且只需要其中一个进程的结果,所以诀窍是启动一个进程(我们不需要其结果的进程),然后自己完成关键部分。
您可以看到,使用计时器来确保另一个进程首先尝试注册名称(在callback/1
函数中),并且测试进程本身等待轮到它,预期出现错误元组({error, name_taken}
)作为结果。
这涵盖了与进程注册相关的测试的所有功能。只剩下与进程注销相关的测试。
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)].
这种将所有调用序列化到列表中的方法是我喜欢使用的一种技巧,当我需要测试所有事件的结果时。通过将它们放在列表中,我可以将操作顺序与预期的[ok, {error, already_named}, ok, ok]
进行比较,以查看事情的进展。请注意,没有任何东西指定 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 编织者
在本章中,我们看到了如何使用 EUnit 的大多数功能,以及如何运行用它们编写的套件。更重要的是,我们看到了一些与如何为并发进程编写测试相关的技术,使用在现实世界中意义重大的模式。
还有一个技巧应该知道:当您想测试诸如gen_server
和gen_fsm
之类的进程时,您可能想检查进程的内部状态。这里有一个不错的技巧,来自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(一个可供所有 Erlang 进程使用的内存数据库)时,我们将看到测试的真正价值。