非典型测试的通用测试

几章前,我们已经看到了如何使用 EUnit 进行单元和模块测试,甚至是一些并发测试。在那个时候,EUnit 开始显示出它的局限性。复杂的设置和需要相互交互的较长测试变得有问题。此外,里面没有任何东西可以处理我们对分布式 Erlang 及其全部能力的新知识。幸运的是,还有一个测试框架存在,这个框架更适合我们现在想要做的繁重工作。

A black box with a heart on it, sitting on a pink heart also.

什么是通用测试

作为程序员,我们喜欢将我们的程序视为黑盒。我们中的许多人会将良好抽象背后的核心原则定义为能够用一个匿名黑盒替换我们编写的任何东西。你把东西放进盒子里,你就能从盒子里拿出来东西。你不在乎它在内部是如何工作的,只要你得到你想要的东西。

在测试领域,这与我们喜欢测试系统的方式有着重要的联系。当我们使用 EUnit 时,我们已经看到了如何将模块视为一个 *黑盒*:你只测试导出的函数,而不测试内部未导出的函数。我还给出了关于测试项目作为一个 *白盒* 的示例,例如在进程任务玩家模块的测试中,我们查看了模块的内部以使其测试更简单。这是有必要的,因为盒子内部所有活动部件的交互使从外部对其进行测试变得非常复杂。

那是针对模块和函数的。如果我们稍微放大一点呢?让我们调整我们的范围,以便看到更广阔的画面。如果我们要测试的是一个库呢?如果它是一个应用程序呢?更广泛地说,如果它是一个完整的系统呢?那么我们需要一个更适合做 *系统测试* 的工具。

EUnit 是一个非常适合在模块级别进行白盒测试的工具。它是一个测试库和 OTP 应用程序的不错工具。进行系统测试和黑盒测试是可能的,但不是最佳选择。

然而,通用测试非常适合系统测试。它适合测试库和 OTP 应用程序,并且可以,但不是最佳选择,使用它来测试单个模块。因此,测试的内容越小,EUnit 就越合适(灵活、有趣)。测试越大,通用测试就越合适(灵活、以及,呃,有点有趣)。

你可能以前听说过通用测试,并试图从 Erlang/OTP 提供的文档中理解它。然后你可能很快就放弃了。别担心。问题是通用测试非常强大,并且有一个相应长的用户指南,并且在撰写本文时,它的文档大部分来自内部文档,这些文档来自它仅在爱立信内部使用的那段日子。事实上,它的文档更像是为已经了解它的人准备的参考手册,而不是教程。

为了正确学习通用测试,我们必须从它最简单的部分开始,然后慢慢地发展到系统测试。

通用测试用例

在开始之前,我必须给你一个关于通用测试如何组织它东西的简要概述。首先,因为通用测试适合系统测试,它将假设两件事

  1. 我们将需要数据来实例化我们的东西
  2. 我们需要一个地方来存储我们所做的所有这些副作用,因为我们是乱七八糟的人。

因此,通用测试通常按以下方式组织

A diagram showing nested boxes. On the outmost level is the test root, labeled (1). Inside that one is the Test Object Diretory, labeled (2). Inside (2) is the test suite (3), and the innermost box, inside the suite, is the test case (4).

测试用例是最简单的。这是一段代码,它要么失败要么成功。如果该用例崩溃,则测试不成功(多么令人惊讶)。否则,测试用例被认为是成功的。在通用测试中,测试用例是单个函数。所有这些函数都位于测试套件(3)中,测试套件是一个模块,负责将相关的测试用例组合在一起。每个测试套件都将位于一个目录中,即测试对象目录(2)。测试根(1)是一个包含多个测试对象目录的目录,但由于 OTP 应用程序通常单独开发,因此许多 Erlang 程序员往往会省略这一层。

无论如何,现在我们已经了解了这种组织,我们可以回到我们的两个假设(我们需要实例化东西,然后搞砸东西)。每个测试套件都是一个以 _SUITE 结尾的模块。如果我要测试上一章中的魔法八球应用程序,那么我可能会将我的套件命名为 m8ball_SUITE。与之相关的是一个名为 *数据目录* 的目录。每个套件允许有一个这样的目录,通常命名为 Module_SUITE_data/。在魔法八球应用程序的情况下,它将是 m8ball_SUITE_data/。该目录包含你想要的任何东西。

副作用呢?嗯,因为我们可能多次运行测试,通用测试会进一步发展其结构

Same diagram (nested boxes) as earlier, but an arrow with 'running' tests points to a new box (Log directory) with two smaller boxes inside: priv dir and HTML files.

无论何时运行测试,通用测试都会找到一些地方来记录内容(通常是当前目录,但我们将在后面看到如何配置它)。在这样做时,它将创建一个唯一的目录,你可以在其中存储你的数据。该目录(上面所示的 *私有目录*)以及数据目录将作为初始状态的一部分传递到每个测试中。然后,你可以随意在该私有目录中写入任何内容,然后稍后检查它,而无需冒覆盖重要内容或以前测试运行结果的风险。

关于这种架构材料就足够了;我们准备编写第一个简单的测试套件。创建一个名为 ct/ 的目录(或任何你喜欢的,毕竟这应该是一个自由的国家)。该目录将成为我们的测试根目录。在它里面,我们可以创建一个名为 demo/ 的目录,用于我们将用作示例的更简单的测试。这将是我们的测试对象目录。

在测试对象目录中,我们将从一个名为 basic_SUITE.erl 的模块开始,以查看最基本的东西。你可以省略创建 basic_SUITE_data/ 目录——我们这次不需要它。通用测试不会抱怨。

以下是该模块的外观

-module(basic_SUITE).
-include_lib("common_test/include/ct.hrl").
-export([all/0]).
-export([test1/1, test2/1]).

all() -> [test1,test2].

test1(_Config) ->
    1 = 1.

test2(_Config) ->
    A = 0,
    1/A.

让我们一步一步地研究它。首先,我们必须包含文件 "common_test/include/ct.hrl"。该文件提供了一些有用的宏,即使 basic_SUITE 不使用它们,但通常包含该文件是一个好习惯。

然后我们有函数 all/0。该函数返回一个测试用例列表。它基本上是在告诉通用测试“嘿,我想运行这些测试用例!”。EUnit 会根据名称(*_test()*_test_())来做到这一点;通用测试通过一个显式的函数调用来做到这一点。

Folders on the floor with paper everywhere. One of the folder has the label 'DATA', and another one has the label 'not porn'

这些 _Config 变量呢?它们现在还没有使用,但为了你个人的了解,它们包含你的测试用例需要的初始状态。该状态实际上是一个属性列表,并且它最初包含两个值,data_dirpriv_dir,即我们用于静态数据和我们可以乱搞的两个目录。

我们可以从命令行或 Erlang shell 运行测试。如果你使用命令行,你可以调用 $ ct_run -suite Name_SUITE。在 R15(大约在 2011 年 12 月发布)之前的 Erlang/OTP 版本中,默认命令是 run_test 而不是 ct_run(尽管某些系统已经同时拥有这两个命令)。该名称的更改是为了最大程度地减少与其他应用程序发生名称冲突的风险,方法是改为一个稍微不太通用的名称。运行它,我们会发现

ct_run -suite basic_SUITE
...
Common Test: Running make in test directories...
Recompile: basic_SUITE
...
Testing ct.demo.basic_SUITE: Starting test, 2 test cases

- - - - - - - - - - - - - - - - - - - - - - - - - -
basic_SUITE:test2 failed on line 13
Reason: badarith
- - - - - - - - - - - - - - - - - - - - - - - - - -

Testing ct.demo.basic_SUITE: *** FAILED *** test case 2 of 2
Testing ct.demo.basic_SUITE: TEST COMPLETE, 1 ok, 1 failed of 2 test cases

Updating /Users/ferd/code/self/learn-you-some-erlang/ct/demo/index.html... done
Updating /Users/ferd/code/self/learn-you-some-erlang/ct/demo/all_runs.html... done

我们发现我们的两个测试用例之一失败了。我们还发现我们显然继承了一堆 HTML 文件。在了解这是什么之前,让我们看看如何从 Erlang shell 运行测试

$ erl
...
1> ct:run_test([{suite, basic_SUITE}]).
...
Testing ct.demo.basic_SUITE: Starting test, 2 test cases

- - - - - - - - - - - - - - - - - - - - - - - - - -
basic_SUITE:test2 failed on line 13
Reason: badarith
- - - - - - - - - - - - - - - - - - - - - - - - - -
...
Updating /Users/ferd/code/self/learn-you-some-erlang/ct/demo/index.html... done
Updating /Users/ferd/code/self/learn-you-some-erlang/ct/demo/all_runs.html... done
ok

我删除了上面输出中的一部分,但它给出了与命令行版本完全相同的结果。让我们看看这些 HTML 文件是怎么回事

$ ls
all_runs.html
basic_SUITE.beam
basic_SUITE.erl
ct_default.css
ct_run.NodeName.YYYY-MM-DD_20.01.25/
ct_run.NodeName.YYYY-MM-DD_20.05.17/
index.html
variables-NodeName

哦,天哪,通用测试对我的漂亮目录做了什么?这真是一个令人羞愧的事情。我们有两个目录在那里。如果你感觉冒险,可以随意浏览它们,但我这样胆小鬼会更愿意查看 all_runs.htmlindex.html 文件。前者将链接到所有你运行的测试迭代的索引,而后者将链接到最新的运行。选择一个,然后在浏览器中点击(或者如果你不相信鼠标作为输入设备,则按下)直到找到包含两个测试的测试套件

A screenshot of the HTML log from a browser

你会看到 test2 失败了。如果你点击带下划线的行号,你会看到模块的原始副本。如果你点击 test2 链接,你会看到发生事件的详细日志

=== source code for basic_SUITE:test2/1 
=== Test case started with:
basic_SUITE:test2(ConfigOpts)
=== Current directory is "Somewhere on my computer"
=== Started at 2012-01-20 20:05:17
[Test Related Output]
=== Ended at 2012-01-20 20:05:17
=== location [{basic_SUITE,test2,13},
              {test_server,ts_tc,1635},
              {test_server,run_test_case_eval1,1182},
              {test_server,run_test_case_eval,1123}]
=== reason = bad argument in an arithmetic expression
  in function  basic_SUITE:test2/1 (basic_SUITE.erl, line 13)
  in call from test_server:ts_tc/3 (test_server.erl, line 1635)
  in call from test_server:run_test_case_eval1/6 (test_server.erl, line 1182)
  in call from test_server:run_test_case_eval/9 (test_server.erl, line 1123)

日志让你确切地知道什么失败了,并且它比我们之前在 Erlang shell 中看到的更加详细。这一点很重要,因为如果你是一个 shell 用户,你会发现通用测试非常痛苦。如果你是一个更倾向于使用 GUI 的人,那么对你来说会很有趣。

但不要再在漂亮的 HTML 文件中闲逛了,让我们看看如何用更多状态进行测试。

**注意:**如果你想穿越回过去,而无需借助时间机器,请下载 R15B 之前的 Erlang 版本,并使用它进行通用测试。你会惊讶地发现你的浏览器和日志的风格把你带回到了 20 世纪 90 年代末。

使用状态进行测试

如果你已经阅读了 EUnit 章节(并且没有跳来跳去),你会记得 EUnit 有这些叫做 *夹具* 的东西,在那里我们会给一个测试用例一些特殊的实例化(设置)和拆卸代码,分别在用例之前和之后调用。

通用测试遵循这一概念。它没有使用 EUnit 式的夹具,而是依赖于两个函数。第一个是设置函数,称为 init_per_testcase/2,第二个是拆卸函数,称为 end_per_testcase/2。要查看它们的使用方式,请创建一个名为 state_SUITE 的新测试套件(仍在 demo/ 目录下),添加以下代码

-module(state_SUITE).
-include_lib("common_test/include/ct.hrl").

-export([all/0, init_per_testcase/2, end_per_testcase/2]).
-export([ets_tests/1]).

all() -> [ets_tests].

init_per_testcase(ets_tests, Config) ->
    TabId = ets:new(account, [ordered_set, public]),
    ets:insert(TabId, {andy, 2131}),
    ets:insert(TabId, {david, 12}),
    ets:insert(TabId, {steve, 12943752}),
    [{table,TabId} | Config].

end_per_testcase(ets_tests, Config) ->
    ets:delete(?config(table, Config)).

ets_tests(Config) ->
    TabId = ?config(table, Config),
    [{david, 12}] = ets:lookup(TabId, david),
    steve = ets:last(TabId),
    true = ets:insert(TabId, {zachary, 99}),
    zachary = ets:last(TabId).

这是一个正常的 ETS 测试,它检查了一些 ordered_set 概念。有趣的是两个新函数,init_per_testcase/2end_per_testcase/2。这两个函数都需要导出才能被调用。如果它们被导出,这些函数将对模块中的 *所有* 测试用例调用。你可以根据参数将它们分开。第一个是测试用例的名称(作为原子),第二个是你可以修改的 Config 属性列表。

**注意:**要从 Config 中读取,而不是使用 proplists:get_value/2,通用测试包含文件有一个 ?config(Key, List) 宏,它返回与给定键匹配的值。宏实际上等同于 proplists:get_value/2,并且被记录为这样,因此你不用担心它会失效,就可以将 Config 当作属性列表处理。

例如,如果我的测试是 abc,并且只想要前两个测试的设置和拆卸函数,我的 init 函数可能如下所示

init_per_testcase(a, Config) ->
    [{some_key, 124} | Config];
init_per_testcase(b, Config) ->
    [{other_key, duck} | Config];
init_per_testcase(_, Config) ->
    %% ignore for all other cases
    Config.

end_per_testcase/2 函数也是如此。

回顾 state_SUITE,你可以看到测试用例,但有趣的是我如何实例化 ETS 表。我指定了没有继承者,然而,在 init 函数完成后,测试能够正常运行。

你会记得我们在 ETS 章节 中看到过,ETS 表通常由启动它们的进程拥有。在本例中,我们保持表原样。如果你运行测试,你会发现该套件成功了。

从这里我们可以推断出,init_per_testcaseend_per_testcase 函数在与测试用例相同的进程中运行。因此,您可以安全地执行诸如设置链接、启动表格等操作,而无需担心不同的进程会破坏您的操作。测试用例中的错误呢?幸运的是,测试用例中的崩溃不会阻止 Common Test 进行清理并调用 end_per_testcase 函数,除了 kill 退出信号。

现在我们几乎与 EUnit 相媲美,至少在灵活性方面,甚至可能更胜一筹。虽然我们还没有所有好用的断言宏,但我们拥有更华丽的报告、类似的装置以及那个我们可以从头开始编写内容的私有目录。我们还能想要什么?

注意:如果您最终觉得需要输出一些东西来帮助您调试问题,或者只是在测试中显示进度,您会很快发现 io:format/1-2 只在 HTML 日志中打印,而不在 Erlang shell 中打印。如果您想同时进行这两项操作(包括免费时间戳),请使用函数 ct:pal/1-2。它的工作原理类似于 io:format/1-2,但会同时打印到 shell 和日志。

测试组

现在,我们套件中的测试结构可能看起来像这样

Sequence of [init]->[test]->[end] in a column

如果我们有很多测试用例,它们在某些初始化函数方面有类似的需求,但在某些方面有所不同,该怎么办?好吧,最简单的做法是复制粘贴并修改,但这将非常难以维护。

此外,如果我们想对许多测试进行的操作是并行运行或随机运行,而不是一个接一个地运行,该怎么办?那么基于我们目前所看到的,就没有简单的方法来做到这一点。这几乎是限制我们使用 EUnit 的同一种问题。

为了解决这些问题,我们有了名为测试组的东西。Common Test 测试组允许我们将一些测试按层次结构分组。更重要的是,它们可以将一些组分组到其他组中。

The sequence of [init]->[test]->[end] from the previous illustration is now integrated within a [group init]->[previous picture]->[group end]

为了使它起作用,我们需要能够声明这些组。声明它们的方法是添加一个组函数来声明所有组。

groups() -> ListOfGroups.

好吧,有一个 groups() 函数。以下是 ListOfGroups 应该是什么。

[{GroupName, GroupProperties, GroupMembers}]

更详细地说,这看起来可能像这样

[{test_case_street_gang,
  [],
  [simple_case, more_complex_case]}].

这是一个小小的测试用例街头帮派。这里有一个更复杂的例子

[{test_case_street_gang,
  [shuffle, sequence],
  [simple_case, more_complex_case,
   emotionally_complex_case,
   {group, name_of_another_test_group}]}].

它指定了两个属性,shufflesequence。我们很快就会了解它们的含义。此示例还展示了一个包含另一个组的组。这假设组函数可能有点像这样

groups() ->
    [{test_case_street_gang,
      [shuffle, sequence],
      [simple_case, more_complex_case, emotionally_complex_case,
       {group, name_of_another_test_group}]},
     {name_of_another_test_group,
      [],
      [case1, case2, case3]}].

您也可以在另一个组中内联定义组

[{test_case_street_gang,
  [shuffle, sequence],
  [simple_case, more_complex_case,
   emotionally_complex_case,
   {name_of_another_test_group,
    [],
    [case1, case2, case3]}
  ]}].

这有点复杂,对吧?仔细阅读,随着时间的推移,它应该会变得更简单。无论如何,嵌套组不是强制性的,如果您觉得它们令人困惑,可以避免使用它们。

但是等等,您如何使用这样的组?好吧,通过将它们放在 all/0 函数中

all() -> [some_case, {group, test_case_street_gang}, other_case].

这样,Common Test 就能知道它是否需要运行测试用例。

我快速地略过了组属性。我们已经看到了 shufflesequence 和一个空列表。以下是它们的含义

空列表 / 无选项
组中的测试用例将一个接一个地运行。如果一个测试失败,列表中的其他测试也会继续运行。
shuffle
按随机顺序运行测试。用于该序列的随机种子(初始化值)将以 {A,B,C} 的形式打印在 HTML 日志中。如果特定测试序列失败,并且您想重现它,请在 HTML 日志中使用该种子,并将 shuffle 选项改为 {shuffle, {A,B,C}}。这样,您就可以在需要时以精确的顺序重现随机运行。
parallel
测试在不同的进程中运行。请注意,如果您忘记导出 init_per_groupend_per_group 函数,Common Test 会静默地忽略此选项。
sequence
这并不一定意味着测试按顺序运行,而是指如果组列表中的测试失败,则跳过其他所有后续测试。如果您想让任何随机测试失败停止后续测试,则可以将此选项与 shuffle 结合使用。
{repeat, Times}
将组重复运行 Times 次。因此,您可以通过使用组属性 [parallel, {repeat, 9}] 来连续并行运行组中的所有测试用例 9 次。Times 也可以取值为 forever,虽然“永远”有点不切实际,因为它不能克服硬件故障或宇宙热寂之类的概念(咳咳)。
{repeat_until_any_fail, N}
运行所有测试,直到其中一个测试失败或它们已运行 N 次。N 也可以是 forever
{repeat_until_all_fail, N}
与上面相同,但测试可能会运行,直到所有用例都失败。
{repeat_until_any_succeed, N}
与之前相同,不同的是测试可能会运行,直到至少有一个用例成功。
{repeat_until_all_succeed, N}
我想您现在可以自己猜到这个了,但以防万一,它与之前相同,不同的是测试用例可能会运行,直到它们都成功。

好吧,这还不错。老实说,对于测试组来说,这已经很多内容了,我觉得这里应该举一个例子。

LMFAO-like golden robot saying 'every day I'm shuffling (test cases)'

会议室

为了首次使用测试组,我们将创建一个会议室预订模块。

-module(meeting).
-export([rent_projector/1, use_chairs/1, book_room/1,
         get_all_bookings/0, start/0, stop/0]).
-record(bookings, {projector, room, chairs}).

start() ->
    Pid = spawn(fun() -> loop(#bookings{}) end),
    register(?MODULE, Pid).

stop() ->
    ?MODULE ! stop.

rent_projector(Group) ->
    ?MODULE ! {projector, Group}.

book_room(Group) ->
    ?MODULE ! {room, Group}.

use_chairs(Group) ->
    ?MODULE ! {chairs, Group}.

这些基本函数将调用一个中央注册进程。它们将执行诸如允许我们预订房间、租用投影仪和预订椅子之类的操作。为了练习,我们身处一个拥有庞大企业结构的大型组织。因此,负责投影仪、房间和椅子的有三个不同的人,但只有一个中央注册机构。因此,您不能一次预订所有物品,而必须通过发送三个不同的消息来进行。

要了解谁预订了什么,我们可以向注册机构发送一条消息,以获取所有值

get_all_bookings() ->
    Ref = make_ref(),
    ?MODULE ! {self(), Ref, get_bookings},
    receive
        {Ref, Reply} ->
            Reply
    end.

注册机构本身看起来像这样

loop(B = #bookings{}) ->
    receive
        stop -> ok;
        {From, Ref, get_bookings} ->
            From ! {Ref, [{room, B#bookings.room},
                          {chairs, B#bookings.chairs},
                          {projector, B#bookings.projector}]},
            loop(B);
        {room, Group} ->
            loop(B#bookings{room=Group});
        {chairs, Group} ->
            loop(B#bookings{chairs=Group});
        {projector, Group} ->
            loop(B#bookings{projector=Group})
    end.

就这样。要预订所有内容以成功举行会议,我们需要依次调用

1> c(meeting).
{ok,meeting}
2> meeting:start().
true
3> meeting:book_room(erlang_group).
{room,erlang_group}
4> meeting:rent_projector(erlang_group).
{projector,erlang_group}
5> meeting:use_chairs(erlang_group).
{chairs,erlang_group}
6> meeting:get_all_bookings().
[{room,erlang_group},
 {chairs,erlang_group},
 {projector,erlang_group}]

很好。但是,这似乎不对劲。您可能有一种挥之不去的感觉,认为事情可能会出错。在许多情况下,如果我们足够快地进行这三次调用,我们应该能够毫无问题地从房间中获得我们想要的一切。如果两个人同时进行操作,并且调用之间有短暂的暂停,则似乎有可能两个(或更多)组可能同时尝试租用相同的设备。

哦,不!突然之间,程序员可能最终得到了投影仪,而董事会得到了房间,人力资源部门设法同时租用了所有椅子。所有资源都被占用,但没有人能做任何有用的事情!

我们不会担心修复这个问题。相反,我们将努力尝试用 Common Test 套件来证明它的存在。

该套件名为 meeting_SUITE.erl,它基于一个简单的想法,即尝试激发一个竞态条件,这将破坏注册。因此,我们将有三个测试用例,每个用例代表一个组。Carla 代表女性,Mark 代表男性,一只狗代表一群动物,这些动物不知何故决定要使用人类制造的工具来举行会议。

-module(meeting_SUITE).
-include_lib("common_test/include/ct.hrl").

...

carla(_Config) ->
    meeting:book_room(women),
    timer:sleep(10),
    meeting:rent_projector(women),
    timer:sleep(10),
    meeting:use_chairs(women).

mark(_Config) ->
    meeting:rent_projector(men),
    timer:sleep(10),
    meeting:use_chairs(men),
    timer:sleep(10),
    meeting:book_room(men).

dog(_Config) ->
    meeting:rent_projector(animals),
    timer:sleep(10),
    meeting:use_chairs(animals),
    timer:sleep(10),
    meeting:book_room(animals).

我们并不关心这些测试是否真正测试了什么。它们只是在那里使用 meeting 模块(我们很快就会了解如何在测试中将其部署),并尝试生成错误的预订。

为了找出我们在这所有测试之间是否发生了竞态条件,我们将在第四个也是最后一个测试中使用 meeting:get_all_bookings() 函数

all_same_owner(_Config) ->
    [{_, Owner}, {_, Owner}, {_, Owner}] = meeting:get_all_bookings().
A dog with glasses standing at a podium where 'DOGS UNITED' is written

它对所有可以预订的不同对象的拥有者进行模式匹配,试图查看它们是否确实被同一个拥有者预订。如果我们正在寻找高效的会议,这是一个理想的事情。

我们如何从在一个文件中拥有四个测试用例过渡到一个可用的东西?我们需要巧妙地使用测试组。

首先,因为我们需要一个竞态条件,所以我们知道我们需要让一堆测试并行运行。其次,鉴于我们需要从这些竞态条件中看到问题,我们需要要么在整个混乱过程中多次运行 all_same_owner,要么仅在之后运行它,以绝望地查看后果。

我选择了后者。这将给我们带来以下结果

all() -> [{group, clients}, all_same_owner].

groups() -> [{clients,
              [parallel, {repeat, 10}],
              [carla, mark, dog]}].

这将创建一个名为 clients 的测试组,其各个测试是 carlamarkdog。它们将并行运行,每个测试运行 10 次。

您会看到,我将该组包含在 all/0 函数中,然后放入 all_same_owner。这是因为默认情况下,Common Test 会按声明顺序运行 all/0 中的测试和组。

但是等等。我们忘记启动和停止 meeting 进程本身。为了做到这一点,我们需要有一种方法来让一个进程保持活动状态,用于所有测试,无论它们是否在“clients”组中。解决此问题的办法是将事物嵌套到更深一层,在另一个组中

all() -> [{group, session}].

groups() -> [{session,
              [],
              [{group, clients}, all_same_owner]},
             {clients,
              [parallel, {repeat, 10}],
              [carla, mark, dog]}].

init_per_group(session, Config) ->
    meeting:start(),
    Config;
init_per_group(_, Config) ->
    Config.

end_per_group(session, _Config) ->
    meeting:stop();
end_per_group(_, _Config) ->
    ok.

我们使用 init_per_groupend_per_group 函数来指定 session 组(现在运行 {group, clients}all_same_owner)将使用一个活动的会议。不要忘记导出这两个安装和拆卸函数,否则将无法并行运行任何操作。

好吧,让我们运行测试,看看我们得到了什么

1> ct_run:run_test([{suite, meeting_SUITE}]).
...
Common Test: Running make in test directories...
...
TEST INFO: 1 test(s), 1 suite(s)

Testing ct.meeting.meeting_SUITE: Starting test (with repeated test cases)

- - - - - - - - - - - - - - - - - - - - - - - - - -
meeting_SUITE:all_same_owner failed on line 50
Reason: {badmatch,[{room,men},{chairs,women},{projector,women}]}
- - - - - - - - - - - - - - - - - - - - - - - - - -

Testing ct.meeting.meeting_SUITE: *** FAILED *** test case 31
Testing ct.meeting.meeting_SUITE: TEST COMPLETE, 30 ok, 1 failed of 31 test cases
...
ok

有趣的是。问题在于三个元组的匹配不匹配,这些元组包含由不同人员拥有的不同项目。此外,输出告诉我们,失败的是 all_same_owner 测试。我认为这是一个非常好的迹象,表明 all_same_owner 按计划崩溃了。

如果您查看 HTML 日志,您将能够看到所有运行,以及失败的确切测试及其原因。单击测试名称,您将获得正确的测试运行。

注意:在继续学习测试组之前,还需要了解最后一件事(也是非常重要的一件事),即虽然测试用例的初始化函数在与测试用例相同的进程中运行,但组的初始化函数在与测试不同的进程中运行。这意味着,无论何时您初始化与生成它们的进程链接的参与者,您都必须确保首先取消链接它们。对于 ETS 表格,您必须定义一个继承者,以确保它不会消失。对于所有其他与进程相关联的概念(套接字、文件描述符等),也是如此。

测试套件

我们可以在测试套件中添加哪些比组嵌套和层次结构中运行方式的操纵更好的内容?不多,但我们将通过测试套件本身来添加另一个级别。

Similar to the earlier groups and test cases nesting illustrations, this one shows groups being wrapped in suites: [suite init] -> [group] -> [suite end]

我们有两个额外的函数,init_per_suite(Config)end_per_suite(Config)。这些函数与所有其他初始化和结束函数一样,旨在提供对数据和进程初始化的更多控制。

init_per_suite/1end_per_suite/1 函数将分别在所有组或测试用例之前和之后运行一次。它们在处理所有测试都需要的一般状态和依赖项时将非常有用。例如,这可能包括手动启动您依赖的应用程序。

测试规范

如果您在运行测试后查看测试目录,可能会发现有一件事让您感到很烦。您的日志目录中散布着大量文件。CSS 文件、HTML 日志、目录、测试运行历史记录等。将这些文件以一种不错的方式存储在一个目录中会很不错。

另一件事是,到目前为止,我们一直在从测试套件运行测试。我们还没有真正看到一个好的方法来同时处理多个测试套件,甚至没有看到只运行一个或两个用例,或者从套件(或从多个套件)运行组的方法。

当然,我之所以这么说,是因为我找到了解决这些问题的方案。你可以通过命令行和 Erlang shell 来实现,具体方法可以在 ct_run 的文档中找到。但是,与其每次运行测试时都手动指定所有内容,不如看看一种叫做“测试规范”的东西。

a button labeled 'do everything'

测试规范是特殊的配置文件,允许你详细说明如何运行测试,并且适用于 Erlang shell 和命令行。测试规范可以保存在任何扩展名的文件中(虽然我个人更喜欢 .spec 文件)。规范文件将包含 Erlang 元组,就像一个咨询文件。以下列出了一些它可以包含的项目。

{include, IncludeDirectories}
当 Common Test 自动编译套件时,此选项允许你指定它应该在哪里查找包含文件,以确保它们存在。IncludeDirectories 值必须是一个字符串(列表)或一个字符串列表(列表的列表)。
{logdir, LoggingDirectory}
在记录时,所有日志都应移动到 LoggingDirectory,这是一个字符串。请注意,目录必须在运行测试之前存在,否则 Common Test 会报错。
{suites, Directory, Suites}
Directory 中查找给定的套件。Suites 可以是原子(some_SUITE)、原子列表,或者原子 all,表示运行目录中的所有套件。
{skip_suites, Directory, Suites, Comment}
此选项将从先前声明的套件列表中减去一系列套件并跳过它们。Comment 参数是一个字符串,解释了为什么要跳过它们。此注释将被添加到最终的 HTML 日志中。表格将显示黄色“SKIPPED: Reason”,其中 ReasonComment 中包含的内容。
{groups, Directory, Suite, Groups}
此选项用于从给定套件中选择一些组。Groups 变量可以是单个原子(组名)或 all,表示所有组。该值也可以更复杂,允许你通过选择类似 {GroupName, [parallel]} 的值来覆盖测试用例中 groups() 内的组定义,这将覆盖 GroupNameparallel 选项,而无需重新编译测试。
{groups, Directory, Suite, Groups, {cases,Cases}}
与上面的选项类似,但它允许你指定一些要包含在测试中的测试用例,方法是用单个用例名称(原子)、名称列表或原子 all 来替换 Cases
{skip_groups, Directory, Suite, Groups, Comment}
此命令是在 R15B 中添加的,并在 R15B01 中记录。它允许跳过测试组,类似于 skip_suites 用于跳过套件。没有解释为什么它之前没有存在。
{skip_groups, Directory, Suite, Groups, {cases,Cases}, Comment}
与上面的选项类似,但它还包括要跳过的特定测试用例。同样,它也只在 R15B 及更高版本中可用。
{cases, Directory, Suite, Cases}
运行给定套件中的特定测试用例。Cases 可以是原子、原子列表或 all
{skip_cases, Directory, Suite, Cases, Comment}
这与 skip_suites 类似,只是我们使用此选项选择要避免的特定测试用例。
{alias, Alias, Directory}
由于编写所有这些目录名称(尤其是完整路径)会非常烦人,所以 Common Test 允许你用别名(原子)来替换它们。这对于简明扼要非常有用。

在展示一个简单的示例之前,你应该在 demo/ 目录(在我的文件中是 ct/)的上级目录中添加一个 logs/ 目录。不出所料,我们的 Common Test 日志将被移动到那里。以下是一个可能的测试规范示例,用于我们到目前为止的所有测试,其名称为 spec.spec

{alias, demo, "./demo/"}.
{alias, meeting, "./meeting/"}.
{logdir, "./logs/"}.

{suites, meeting, all}.
{suites, demo, all}.
{skip_cases, demo, basic_SUITE, test2, "This test fails on purpose"}.

此规范文件声明了两个别名:demomeeting,它们分别指向我们拥有的两个测试目录。我们将日志放在 ct/logs/ 中,这是我们新创建的目录。然后,我们要求运行 meeting 目录中的所有套件,这恰好是 meeting_SUITE 套件。列表中的下一项是 demo 目录中的两个套件。此外,我们要求跳过 basic_SUITE 套件中的 test2,因为它包含一个我们知道会失败的除零错误。

要运行测试,你可以使用 $ ct_run -spec spec.spec(或 Erlang R15 之前的版本使用 run_test),或者使用 Erlang shell 中的 ct:run_test([{spec, "spec.spec"}]). 函数。

Common Test: Running make in test directories...
...
TEST INFO: 2 test(s), 3 suite(s)

Testing ct.meeting: Starting test (with repeated test cases)

- - - - - - - - - - - - - - - - - - - - - - - - - -
meeting_SUITE:all_same_owner failed on line 51
Reason: {badmatch,[{room,men},{chairs,women},{projector,women}]}
- - - - - - - - - - - - - - - - - - - - - - - - - -

Testing ct.meeting: *** FAILED *** test case 31
Testing ct.meeting: TEST COMPLETE, 30 ok, 1 failed of 31 test cases

Testing ct.demo: Starting test, 3 test cases
Testing ct.demo: TEST COMPLETE, 2 ok, 0 failed, 1 skipped of 3 test cases

Updating /Users/ferd/code/self/learn-you-some-erlang/ct/logs/index.html... done
Updating /Users/ferd/code/self/learn-you-some-erlang/ct/logs/all_runs.html... done

如果你花时间查看日志,你会看到两个用于不同测试运行的目录。其中一个将包含一个失败,即 meeting 预期失败。另一个将包含一个成功和一个跳过的用例,格式为 1 (1/0)。一般来说,格式是 TotalSkipped (IntentionallySkipped/SkippedDueToError)。在本例中,跳过是来自规范文件,因此位于左侧。如果它是由于许多 init 函数之一失败而导致的,那么它将位于右侧。

Common Test 开始看起来像一个相当不错的测试框架,但能够利用我们分布式编程的知识并将其应用到其中会很棒。

a circus ride-like scale with a card that says 'you must be this tall to test'

大规模测试

Common Test 支持分布式测试。在疯狂地编写一堆代码之前,让我们看看它提供了什么。嗯,并没有那么多。它的核心是 Common Test 允许你在许多不同的节点上启动测试,但也提供了一种动态启动这些节点并让它们互相监视的方法。

因此,Common Test 的分布式功能在你需要在许多节点上并行运行大型测试套件时非常有用。这通常值得这样做,可以节省时间,因为代码将在不同的计算机上运行生产环境——反映这一点的自动化测试是可取的。

当测试变为分布式时,Common Test 要求存在一个中央节点(称为 CT 主节点),它负责所有其他节点。一切都将从那里进行管理,包括启动节点、安排测试运行顺序、收集日志等。

要以这种方式启动测试,第一步是扩展我们的测试规范,使其成为分布式规范。我们将添加一些新的元组

{node, NodeAlias, NodeName}
{alias, AliasAtom, Directory} 用于测试套件、组和用例类似,只是它用于节点名称。NodeAliasNodeName 都需要是原子。此元组特别有用,因为 NodeName 需要是一个长节点名称,在某些情况下这可能非常长。
{init, NodeAlias, Options}
这是一个更复杂的元组。这是允许你启动节点的选项。NodeAlias 可以是单个节点别名,也可以是多个节点别名的列表。Optionsct_slave 模块可用的选项。

以下是一些可用的选项

{username, UserName}{password, Password}
使用 NodeAlias 给出的节点的主机部分,Common Test 将尝试通过 SSH(在端口 22 上)连接到给定的主机,使用用户名和密码,并从那里运行。
{startup_functions, [{M,F,A}]}
此选项定义了一个函数列表,这些函数将在另一个节点启动后立即被调用。
{erl_flags, String}
这将设置我们想要在启动 erl 应用程序时传递的标准标志。例如,如果我们想要使用 erl -env ERL_LIBS ../ -config conf_file 启动节点,则该选项将是 {erl_flags, "-env ERL_LIBS ../ -config config_file"}
{monitor_master, true | false}
如果 CT 主节点停止运行并且该选项设置为 true,那么从节点也将被关闭。我建议在生成远程节点时使用此选项;否则,如果主节点死亡,它们将在后台继续运行。此外,如果你再次运行测试,Common Test 将能够连接到这些节点,并且它们将带有一些状态信息。
{boot_timeout, Seconds},
{init_timeout, Seconds},
{startup_timeout, Seconds}
这三个选项允许你等待远程节点启动的不同阶段。启动超时是指节点变得可 ping 的时间,默认值为 3 秒。初始化超时是一个内部计时器,新的远程节点会回调 CT 主节点以通知它已启动。默认情况下,它持续 1 秒。最后,启动超时告诉 Common Test 等待我们之前在 startup_functions 元组中定义的函数完成的时间。
{kill_if_fail, true | false}
此选项将响应上述三个超时之一。如果其中任何一个被触发,Common Test 将中止连接、跳过测试等,但不一定杀死节点,除非该选项设置为 true。幸运的是,这是默认值。

注意:所有这些选项都由 ct_slave 模块提供。你可以定义自己的模块来启动从节点,只要它符合正确的接口即可。

这为远程节点提供了很多选项,但这部分上也是 Common Test 分布式能力的体现;你能够启动节点,几乎与你在 shell 中手动操作时的控制程度一样。不过,还有更多用于分布式测试的选项,尽管它们不是用于启动节点的。

{include, Nodes, IncludeDirs}
{logdir, Nodes, LogDir}
{suites, Nodes, DirectoryOrAlias, Suites}
{groups, Nodes, DirectoryOrAlias, Suite, Groups}
{groups, Nodes, DirectoryOrAlias, Suite, GroupSpec, {cases,Cases}}
{cases, Nodes, DirectoryOrAlias, Suite, Cases}
{skip_suites, Nodes, DirectoryOrAlias, Suites, Comment}
{skip_cases, Nodes, DirectoryOrAlias, Suite, Cases, Comment}

这些选项与我们之前看到的选项基本相同,只是它们可以选择性地接受一个节点参数以添加更多细节。这样,你就可以决定在给定节点上运行一些套件,在其他节点上运行其他套件等。这在进行系统测试时很有用,其中不同的节点运行不同的环境或系统部分(例如数据库、外部应用程序等)。

为了了解其工作原理,让我们将之前的 spec.spec 文件转换为分布式文件。将其复制为 dist.spec,然后更改它,使其看起来像这样

{node, a, '[email protected]'}.
{node, b, '[email protected]'}.

{init, [a,b], [{node_start, [{monitor_master, true}]}]}.

{alias, demo, "./demo/"}.
{alias, meeting, "./meeting/"}.

{logdir, all_nodes, "./logs/"}.
{logdir, master, "./logs/"}.

{suites, [b], meeting, all}.
{suites, [a], demo, all}.
{skip_cases, [a], demo, basic_SUITE, test2, "This test fails on purpose"}.

这稍微改变了一些。我们定义了两个从节点:ab,它们需要在测试中启动。它们没有做任何特殊的事情,只是确保如果主节点死亡,它们也会死亡。目录的别名保持与之前相同。

logdir 值很有趣。我们没有声明任何节点别名作为 all_nodesmaster,但它们却出现了。all_nodes 别名代表 Common Test 中所有非主节点,而 master 代表主节点本身。要真正包含所有节点,需要 all_nodesmaster。没有明确解释为什么选择了这些名称。

A Venn diagram with two categories: boring drawings and self-referential drawings. The intersection of the two sets is 'this'.

我将所有值都放在那里,是因为 Common Test 将为每个从节点生成日志(和目录),并且它还将生成一组主日志,引用从日志。我不希望任何这些日志出现在 logs/ 以外的目录中。请注意,从节点的日志将分别存储在每个从节点上。在这种情况下,除非所有节点共享同一个文件系统,否则主日志中的 HTML 链接将无法工作,你必须访问每个节点才能获得它们各自的日志。

最后是 suitesskip_cases 条目。它们与之前的条目基本相同,只是针对每个节点进行了调整。这样,你就可以只在给定节点上跳过一些条目(你可能知道这些节点可能缺少库或依赖项),或者可能跳过一些更密集的条目,因为硬件无法承受负载。

要运行这种类型的分布式测试,我们必须使用 -name 启动一个分布式节点,并使用 ct_master 运行套件。

$ erl -name ct
Erlang R15B (erts-5.9) [source] [64-bit] [smp:4:4] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.9  (abort with ^G)
([email protected])1> ct_master:run("dist.spec").
=== Master Logdir ===
/Users/ferd/code/self/learn-you-some-erlang/ct/logs
=== Master Logger process started ===
<0.46.0>
Node '[email protected]' started successfully with callback ct_slave
Node '[email protected]' started successfully with callback ct_slave
=== Cookie ===
'PMIYERCHJZNZGSRJPVRK'
=== Starting Tests ===
Tests starting on: ['[email protected]','[email protected]']
=== Test Info ===
Starting test(s) on '[email protected]'...
=== Test Info ===
Starting test(s) on '[email protected]'...
=== Test Info ===
Test(s) on node '[email protected]' finished.
=== Test Info ===
Test(s) on node '[email protected]' finished.
=== TEST RESULTS ===
[email protected]_________________________finished_ok
[email protected]_________________________finished_ok

=== Info ===
Updating log files
Updating /Users/ferd/code/self/learn-you-some-erlang/ct/logs/index.html... done
Updating /Users/ferd/code/self/learn-you-some-erlang/ct/logs/all_runs.html... done
Logs in /Users/ferd/code/self/learn-you-some-erlang/ct/logs refreshed!
=== Info ===
Refreshing logs in "/Users/ferd/code/self/learn-you-some-erlang/ct/logs"... ok
[{"dist.spec",ok}]

没有办法使用 ct_run 运行此类测试。请注意,无论测试是否实际成功,CT 都会显示所有结果为 ok。这是因为 ct_master 只显示它是否能够联系到所有节点。实际结果实际上存储在每个节点上。

您还会注意到 CT 显示它启动了节点,以及使用哪些 cookie 完成的。如果您尝试在不先终止主节点的情况下再次运行测试,则会显示以下警告

WARNING: Node '[email protected]' is alive but has node_start option
WARNING: Node '[email protected]' is alive but has node_start option

没关系。这仅仅意味着 Common Test 能够连接到远程节点,但它没有发现从测试规范中调用我们的 `init` 元组的必要,因为这些节点已经处于活动状态。Common Test 不需要实际启动它将在其上运行测试的任何远程节点,但我通常发现这样做很有用。

这确实是分布式规范文件的要点。当然,您可以进入更复杂的情况,在其中设置更复杂的集群并编写出色的分布式测试,但随着测试变得越来越复杂,您对它们成功证明软件属性的能力的信心就会越低,仅仅因为测试本身可能会随着它们变得越来越复杂而包含越来越多的错误。

Little robots from rockem sockem (or whatever the name was). One is the Common Test bot while the other is the Eunit bot. In a political-cartoon-like satire, the ring is clearly labelled as 'system tests' and the Common Test bot knocks the head off the EUnit bot.

将 EUnit 集成到 Common Test 中

因为有时 EUnit 是最佳工具,有时 Common Test 是最佳工具,所以您可能希望将其中一个包含到另一个中。

虽然在 EUnit 套件中包含 Common Test 套件很困难,但反过来却很容易做到。诀窍是,当您调用 `eunit:test(SomeModule)` 时,该函数可以返回 `ok`(当一切正常时)或 `error`(在发生任何故障时)。

这意味着要将 EUnit 测试集成到 Common Test 套件中,您所需要做的就是编写一个类似这样的函数

run_eunit(_Config) ->
    ok = eunit:test(TestsToRun).

并且所有可以通过 `TestsToRun` 描述找到的 EUnit 测试都将被运行。如果发生故障,它将出现在您的 Common Test 日志中,您可以阅读输出以查看出了什么问题。就这么简单。

还有更多吗?

当然还有更多。Common Test 是一个非常复杂的野兽。您可以通过以下方式添加一些变量的配置文件:在测试执行期间的许多点添加钩子,在套件期间的事件上使用回调,通过 `

本章仅仅触及了表面,但足以让您开始更深入地探索。关于 Common Test 的更完整的文档是 Erlang/OTP 附带的 `