異例なテストのための共通テスト

数章前、EUnitを使ってユニットテストとモジュールテスト、さらには並行テストを行う方法を学びました。その時点で、EUnitは限界を示し始めました。複雑な設定や、互いにやり取りする必要がある長いテストは問題になりました。さらに、分散Erlangとそのパワーに関する新しい知識を扱うものは何もありませんでした。幸いにも、もう一つのテストフレームワークが存在し、これは私たちが今行いたい重い作業に適しています。

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

Common Testとは何か

プログラマーとして、私たちはプログラムをブラックボックスとして扱うことを好みます。多くの人が、良い抽象化の背後にある中核原則を、自分が書いたものを匿名のブラックボックスで置き換えることができることとして定義するでしょう。何かを箱の中に入れれば、何かが出てきます。あなたが望むものを得ている限り、内部でどのように動作するかは気にしません。

テストの世界では、これはシステムをテストする方法と重要な関連性があります。EUnitを使用していたとき、モジュールを*ブラックボックス*として扱う方法を見ました。エクスポートされていない内部の関数ではなく、エクスポートされた関数のみをテストします。また、プロセスクエストプレイヤーモジュールのテストの場合のように、モジュールの内部を見てテストを簡素化することで、アイテムを*ホワイトボックス*としてテストする例も示しました。これは、ボックス内のすべての可動部分の相互作用が、外部からのテストを非常に複雑にしたため、必要でした。

それはモジュールと関数についてでした。少しズームアウトしてみましょう。より広い範囲を見てみましょう。テストしたいものがライブラリの場合はどうでしょうか?アプリケーションの場合はどうでしょうか?さらに広く、完全なシステムの場合はどうでしょうか?必要なのは、*システムテスト*と呼ばれる作業に長けたツールです。

EUnitは、モジュールレベルでのホワイトボックステストに非常に優れたツールです。ライブラリやOTPアプリケーションをテストするための適切なツールです。システムテストやブラックボックステストを行うことは可能ですが、最適ではありません。

しかし、Common Testはシステムテストに非常に優れています。ライブラリやOTPアプリケーションのテストには適しており、個々のモジュールのテストにも使用できますが、最適ではありません。したがって、テストするものが小さければ小さいほど、EUnitはより適切(柔軟で楽しい)になります。テストが大きくなればなるほど、Common Testはより適切(柔軟で、まあまあ楽しい)になります。

以前Common Testのことを聞いたことがあり、Erlang/OTPに付属のドキュメントから理解しようとしたことがあるかもしれません。そして、すぐに諦めたでしょう。心配しないでください。問題は、Common Testは非常に強力であり、それに応じて長いユーザーガイドがあり、この記事を書いている時点では、そのドキュメントの大部分は、エリクソン社内でのみ使用されていた時代の内部ドキュメントから来ているように見えることです。実際、そのドキュメントは、すでにCommon Testを理解している人向けの参照マニュアルであり、チュートリアルではありません。

Common Testを適切に学習するには、最も単純な部分から始め、徐々にシステムテストへと進んでいく必要があります。

Common Testケース

始める前に、Common Testがどのようにものを整理しているかについて少し概要を説明する必要があります。まず、Common Testはシステムテストに適しているため、2つのことを前提としています。

  1. ものをインスタンス化するためにデータが必要です。
  2. 私たちが不注意な人間であるため、私たちが行うすべての副作用のあるものを保存する場所が必要です。

このため、Common Testは通常、次のように構成されます。

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).

テストケースは最も単純なものです。失敗するか成功するかのコードです。ケースがクラッシュすると、テストは失敗します(驚くべきことではありません)。それ以外の場合は、テストケースは成功したと見なされます。Common Testでは、テストケースは単一の関数です。これらの関数はすべてテストスイート(3)内に存在し、関連するテストケースをまとめて管理するモジュールです。各テストスイートは、テストオブジェクトディレクトリ(2)というディレクトリ内に存在します。テストルート(1)は多くのテストオブジェクトディレクトリを含むディレクトリですが、OTPアプリケーションは多くの場合個別に開発されるため、多くのErlangプログラマーはこのレイヤーを省略する傾向があります。

いずれにせよ、この構成を理解したので、2つの前提(ものをインスタンス化し、それからものを混乱させる必要がある)に戻ることができます。各テストスイートは、`_SUITE`で終わるモジュールです。前の章のマジック8ボールアプリケーションをテストする場合は、スイートを`m8ball_SUITE`と呼ぶことができます。それに関連するディレクトリとして、*データディレクトリ*があります。各スイートは、通常`Module_SUITE_data/`という名前のデータディレクトリを1つ持つことができます。マジック8ボールアプリの場合、`m8ball_SUITE_data/`になります。そのディレクトリには、必要なものを何でも入れることができます。

副作用はどうでしょうか?テストを何度も実行する可能性があるため、Common Testは構造をさらに発展させます。

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.

テストを実行するたびに、Common Testはログを保存する場所を見つけます(通常は現在のディレクトリですが、後で設定方法を示します)。その際、データの保存場所として一意のディレクトリを作成します。そのディレクトリ(上記の*Priv Dir*)とデータディレクトリは、いくつかの初期状態の一部として各テストに渡されます。その後、そのプライベートディレクトリに何でも自由に書き込み、後で検査することができます。重要なものや以前のテスト実行の結果を上書きするリスクはありません。

このアーキテクチャに関する説明はこれで終わりです。最初の単純なテストスイートを作成する準備ができました。`ct/`という名前のディレクトリ(または好きな名前、ここは自由の国のはずです)を作成します。このディレクトリがテストルートになります。その中に、例として使用する簡単なテストのための`demo/`という名前のディレクトリを作成できます。これがテストオブジェクトディレクトリになります。

テストオブジェクトディレクトリ内に、可能な限り基本的なものを見るために、`basic_SUITE.erl`という名前のモジュールを作成します。basic_SUITE.erl。`basic_SUITE_data/`ディレクトリを作成する必要はありません。今回は必要ありません。Common Testは文句を言いません。

モジュールの内容は次のとおりです。

-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`関数があります。この関数はテストケースのリストを返します。基本的にCommon Testに「これらのテストケースを実行したい」と伝えるものです。EUnitは名前(`*_test()`または`*_test_()`)に基づいて実行しますが、Common 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_dir`と`priv_dir`の2つの値を含み、静的データ用の2つのディレクトリと、自由に操作できるディレクトリです。

テストはコマンドラインまたはErlangシェルから実行できます。コマンドラインを使用する場合は、`$ ct_run -suite Name_SUITE`と呼び出すことができます。R15(2011年12月頃リリース)以前のErlang/OTPバージョンでは、デフォルトのコマンドは`ct_run`ではなく`run_test`でした(ただし、一部のシステムでは既に両方存在していました)。名前は、より一般的な名前ではなくやや一般的な名前にすることで、他のアプリケーションとの名前の衝突のリスクを最小限に抑える目的で変更されました。実行すると、次のようになります。

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

2つのテストケースのうちの1つが失敗することがわかります。また、どうやらたくさんのHTMLファイルを受け継いだようです。これが何かを知る前に、Erlangシェルからテストを実行する方法を見てみましょう。

$ 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

Common Testは私の美しいディレクトリに何をしたのでしょうか?見るのは恥ずかしいものです。そこには2つのディレクトリがあります。冒険心を抱いているなら自由に探検してください。しかし、私のような臆病者は、`all_runs.html`または`index.html`ファイルを見る方が良いでしょう。前者は実行したテストのすべての反復のインデックスへのリンクを提供し、後者は最新の反復へのリンクのみを提供します。どちらかを選択し、ブラウザでクリックして回るか(マウスをデバイスとして信じていない場合は、押し回してください)、2つのテストを含むテストスイートを見つけるまで操作してください。

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シェルにあったものよりもはるかに詳細です。シェルユーザーの場合、Common Testの使用は非常に困難になります。GUIの使用に慣れている人であれば、非常に楽しく感じるでしょう。

しかし、美しいHTMLファイルをさまようのはこれで終わりです。より多くの状態をテストする方法を見てみましょう。

**注:** 時刻を戻したいと思った場合は、R15B以前のバージョンのErlangをダウンロードして、Common Testを使用してみてください。ブラウザとログのスタイルが1990年代後半にあなたを連れ戻したことに驚くでしょう。

状態をテストする

EUnitの章を読まれた方(飛ばさずに読まれた方)なら、テストケースに特別なインスタンス化(セットアップ)とティアダウンコードをそれぞれケースの前後で行うための、フィクスチャと呼ばれるものがあったことを覚えているでしょう。

Common Testもその概念に従います。EUnitスタイルのフィクスチャを持つ代わりに、2つの関数を使用します。1つ目はセットアップ関数で、init_per_testcase/2と呼ばれ、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).

これはいくつかのordered_setの概念をチェックする通常のETSテストです。興味深いのは、init_per_testcase/2end_per_testcase/2という2つの新しい関数です。両方の関数は呼び出されるためにエクスポートする必要があります。エクスポートされると、これらの関数はモジュール内のすべてのテストケースに対して呼び出されます。引数に基づいてそれらを分離できます。最初の引数はテストケースの名前(アトムとして)、2つ目は変更できるConfigプロプリストです。

注記:Configから読み取るには、proplists:get_value/2を使用する代わりに、Common Testのインクルードファイルには、指定されたキーに一致する値を返す?config(Key, List)マクロがあります。このマクロは実際にはproplists:get_value/2と等価であり、そのように記述されているため、Configをプロプリストとして扱うことができ、破損する心配はありません。

例として、abcというテストがあり、最初の2つのテストに対してのみセットアップ関数とティアダウン関数が必要な場合、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_testcase関数とend_per_testcase関数は、テストケース自体と同じプロセスで実行されるということです。したがって、リンクを設定したり、テーブルを開始したりするなど、異なるプロセスが問題を引き起こすことを心配することなく、安全に実行できます。テストケースのエラーはどうでしょうか?幸いなことに、テストケースでクラッシュしても、kill終了シグナルを除いて、Common Testはクリーンアップしてend_per_testcase関数を呼び出すのを停止しません。

Common Testを使用すると、少なくとも柔軟性の点では、EUnitと同等になりました(あるいはそれ以上です)。すべての便利なアサーションマクロがあるわけではありませんが、より高度なレポート、同様のフィクスチャ、そしてゼロから記述できるプライベートディレクトリがあります。他に何が欲しいでしょうか?

注記:デバッグを支援したり、テストの進行状況を表示するために何かを出力したいと思うようになった場合、io:format/1-2はHTMLログにのみ出力され、Erlangシェルには出力されないことがすぐにわかります。両方(無料のタイムスタンプ付き)で行いたい場合は、関数ct:pal/1-2を使用してください。io:format/1-2のように機能しますが、シェルとログの両方に出力されます。

テストグループ

現時点では、スイート内のテスト構造は、せいぜい次のようになります。

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

いくつかのinit関数に関して同様のニーズを持つ多くのテストケースがあり、それらの中には異なる部分がある場合、どうすればよいでしょうか?簡単な方法はコピー&ペーストして修正することですが、これは保守が非常に困難になります。

さらに、多くのテストで行いたいことが、順番にではなく、並列またはランダムな順序で実行することである場合はどうでしょうか?これまでのところ、それを行う簡単な方法はありません。これは、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という2つのプロパティを指定しています。それらが何を意味するかはすぐにわかります。この例は、別のグループを含むグループも示しています。これは、グループ関数が次のようになる可能性があることを前提としています。

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_group関数とend_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}
上記と同じですが、少なくとも1つのケースが成功するまでテストが実行される可能性があります。
{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}.

これらの基本的な関数は、中央レジストリプロセスを呼び出します。これらは、部屋の予約、プロジェクターのレンタル、椅子の確保などを可能にします。この演習のために、私たちは非常に複雑な企業構造を持つ大規模な組織にいます。そのため、プロジェクター、部屋、椅子を担当する3人の担当者がいますが、中央レジストリは1つだけです。そのため、すべてのアイテムを一度に予約することはできませんが、3つの異なるメッセージを送信する必要があります。

誰が何を予約したかを知るには、すべての値を取得するためにレジストリにメッセージを送信できます。

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}]

素晴らしい。しかし、これは間違っているように見えます。何かがうまくいかないかもしれないという、残っている感覚があるかもしれません。多くの場合、3つの呼び出しを十分に速く行うと、問題なく部屋から必要なものをすべて取得できるはずです。2人が同時に実行し、呼び出し間に短い休止時間がある場合、2つ(またはそれ以上)のグループが同時に同じ機器をレンタルしようとする可能性があります。

大変だ!突然、プログラマーはプロジェクターを持っている可能性があり、取締役会は部屋を、人事部はすべての椅子を一度にレンタルした可能性があります。すべての資源が束縛されていますが、誰も役に立つことはできません!

その問題の修正は心配しません。代わりに、Common Testスイートで存在することを示そうとします。

meeting_SUITE.erlという名前のスイートは、登録を混乱させる競合状態を引き起こそうとするという単純なアイデアに基づいています。したがって、それぞれがグループを表す3つのテストケースがあります。カーラは女性を、マークは男性を、犬はなんらかの理由で人間が作ったツールで会議を開こうと決めた動物のグループを表します。

-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モジュール(テストのためにどのように配置するかをすぐに確認します)を使用して、間違った予約を生成しようとするためにあるだけです。

これらのテストのすべて間に競合状態があったかどうかを確認するために、4番目にして最後のテストで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

これは、予約できるすべての異なるオブジェクトの所有者に対してパターンマッチングを行い、それらが実際に同じ所有者によって予約されているかどうかを確認しようとします。効率的な会議を探している場合は、これは望ましいことです。

ファイル内の4つのテストケースから機能するものに移行するにはどうすればよいでしょうか?テストグループを巧みに使用する必要があります。

まず、競合状態が必要なため、多くのテストを並列で実行する必要があることがわかっています。次に、これらの競合状態から問題を確認するという要件があるため、大惨事の間にall_same_ownerを何度も実行するか、その後ろで絶望的に結果を確認するかのどちらかが必要です。

後者を選びました。これは次のような結果になります。

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

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

これにより、個々のテストがcarlamarkdogであるclientsというテストグループが作成されます。これらは、それぞれ10回ずつ並列で実行されます。

all/0関数にグループを含め、次にall_same_ownerを配置していることがわかります。これは、デフォルトでは、Common Testがall/0内のテストとグループを宣言された順序で実行するためです。

しかし、待ってください。meetingプロセス自体の開始と停止を忘れていました。「clients」グループにあるかどうかに関係なく、すべてのテストでプロセスをアクティブに維持する方法が必要です。この問題に対する解決策は、別のグループにさらに1レベル深くネストすることです。

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_group関数とend_per_group関数を使用して、sessionグループ(現在は{group, clients}all_same_ownerを実行しています)がアクティブな会議で動作することを指定します。2つのセットアップ関数とティアダウン関数をエクスポートすることを忘れないでください。そうでないと、何も並列で実行されません。

さて、テストを実行して結果を見てみましょう。

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

興味深いですね。問題は、異なる所有者が所有する異なるアイテムを持つ3つのタプルとの不一致です。さらに、出力からall_same_ownerテストが失敗したことがわかります。これは、all_same_ownerが計画通りにクラッシュしたことを示すかなり良い兆候だと思います。

HTMLログを確認すれば、失敗した正確なテストとその理由を含むすべての実行を確認できます。テスト名をクリックすると、正しいテスト実行が表示されます。

注記: テストグループから先に進む前に知っておくべき最後の(そして非常に重要な)ことは、テストケースのinit関数はテストケースと同じプロセスで実行される一方で、グループのinit関数はテストとは異なるプロセスで実行されることです。これは、生成したプロセスにリンクされるアクタを初期化するときは、最初にそれらをアンリンクする必要があることを意味します。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)という2つの追加関数があります。これらは、他のすべてのinit関数とend関数と同様に、データとプロセスの初期化に対するより多くの制御を提供することを目的としています。

init_per_suite/1関数とend_per_suite/1関数は、それぞれすべてのグループまたはテストケースの前と後に一度だけ実行されます。これらは、すべてのテストに必要な一般的な状態と依存関係を扱う場合に非常に役立ちます。たとえば、依存するアプリケーションを手動で起動する場合などです。

テスト仕様

テストを実行した後にテストディレクトリを確認した場合、かなり迷惑だと感じるものがあるかもしれません。ログ用のファイルがディレクトリに大量に散らばっています。CSSファイル、HTMLログ、ディレクトリ、テスト実行履歴などです。これらのファイルを単一のディレクトリに格納する良い方法があれば便利です。

もう1つの点は、これまでテストスイートからテストを実行してきました。複数のテストスイートを一度に実行する方法、またはスイート(または複数のスイート)から1つまたは2つのケースやグループのみを実行する方法を実際に見ていません。

もちろん、私がこう言っているのは、これらの問題に対する解決策があるからです。コマンドラインとErlangシェルから両方実行する方法があり、ct_runのドキュメントに記載されています。ただし、テストを実行するたびにすべてを手動で指定する方法について説明する代わりに、テスト仕様と呼ばれるものを見てみましょう。

a button labeled 'do everything'

テスト仕様は、テストの実行方法についてすべてを詳細に指定できる特別なファイルであり、Erlangシェルとコマンドラインで機能します。テスト仕様は、任意の拡張子を持つファイルに配置できます(個人的には.specファイルを好みますが)。specファイルには、consultファイルと同様に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ログに挿入されます。テーブルには、ReasonCommentの内容である「SKIPPED: Reason」という黄色の表示が表示されます。
{groups, Directory, Suite, Groups}
これは、指定されたスイートからいくつかのグループのみを選択するためのオプションです。Groups変数は、単一のアトム(グループ名)またはすべてのグループを表すallにすることができます。値はより複雑にすることができ、{GroupName, [parallel]}のような値を選択することで、テストケース内のgroups()内のグループ定義をオーバーライドできます。これにより、テストを再コンパイルする必要なく、GroupNameparallelオプションがオーバーライドされます。
{groups, Directory, Suite, Groups, {cases,Cases}}
上記と似ていますが、Casesを単一ケース名(アトム)、名前のリスト、またはアトムallに置き換えることで、テストに含めるテストケースを指定できます。
{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/の上のディレクトリにlogs/ディレクトリを追加する必要があります(私のファイルではct/)。当然のことながら、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"}.

このspecファイルは、2つのエイリアスdemomeetingを宣言し、これらは2つのテストディレクトリを指します。ログは最新のディレクトリであるct/logs/に配置します。次に、meetingディレクトリ内のすべてのスイートを実行するように要求します。これは偶然にもmeeting_SUITEスイートです。次に、demoディレクトリ内の2つのスイートがあります。さらに、ゼロ除算が含まれており、失敗することがわかっているため、basic_SUITEスイートからtest2をスキップするように要求します。

テストを実行するには、$ ct_run -spec spec.spec(またはR15以前のバージョンのErlangの場合はrun_test)を使用するか、Erlangシェルから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

ログを確認すると、異なるテスト実行のディレクトリが2つ表示されます。1つは失敗しています。これは期待通りに失敗するmeetingです。もう1つは、成功したケースが1つ、スキップされたケースが1つ(1 (1/0)の形式)あります。一般的に、形式はTotalSkipped (IntentionallySkipped/SkippedDueToError)です。この場合、スキップはspecファイルから行われたため、左側に表示されます。多くの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は、単一のノードエイリアスまたは複数のノードエイリアスのリストにすることができます。Optionsは、ct_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, 秒},
{init_timeout, 秒},
{startup_timeout, 秒}
これらの3つのオプションを使用すると、リモートノードの起動の異なる部分の待機時間を設定できます。ブートタイムアウトは、ノードがping応答可能になるまでの時間(デフォルト値は3秒)です。イニシャルタイムアウトは、新しいリモートノードがCTマスターに起動を通知する内部タイマーです。デフォルトでは1秒です。最後に、スタートアップタイムアウトは、startup_functionsタプルの一部として先に定義した関数の完了をCommon Testが待機する時間を指定します。
{kill_if_fail, true | false}
このオプションは、上記の3つのタイムアウトのいずれかに反応します。いずれかのタイムアウトが発生した場合、Common Testは接続を中止し、テストをスキップするなどしますが、オプションがtrueに設定されていない限り、ノードを強制終了するとは限りません。幸いなことに、デフォルト値はtrueです。

注記: これらのオプションはすべて、ct_slaveモジュールによって提供されます。適切なインターフェースを尊重する限り、独自のモジュールを定義してスレーブノードを起動できます。

リモートノードには多くのオプションがありますが、これはCommon Testが分散処理能力を持つ理由の一部です。シェルで手動で行う場合とほぼ同じレベルの制御でノードを起動できます。ただし、ノードの起動には関係しない、分散テスト用のオプションが他にもあります。

{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, 'a@ferdmbp.local'}.
{node, b, 'b@ferdmbp.local'}.

{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"}.

これは少し変更を加えます。テストのために開始する必要がある2つのスレーブノード、abを定義します。特別なことは何も行いませんが、マスターが死んだ場合に確実に終了するようにします。ディレクトリのエイリアスは以前と同じです。

logdirの値は興味深いものです。all_nodesまたはmasterとしてノードエイリアスを宣言しませんでしたが、それでもここにあります。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)
(ct@ferdmbp.local)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 'a@ferdmbp.local' started successfully with callback ct_slave
Node 'b@ferdmbp.local' started successfully with callback ct_slave
=== Cookie ===
'PMIYERCHJZNZGSRJPVRK'
=== Starting Tests ===
Tests starting on: ['b@ferdmbp.local','a@ferdmbp.local']
=== Test Info ===
Starting test(s) on 'b@ferdmbp.local'...
=== Test Info ===
Starting test(s) on 'a@ferdmbp.local'...
=== Test Info ===
Test(s) on node 'a@ferdmbp.local' finished.
=== Test Info ===
Test(s) on node 'b@ferdmbp.local' finished.
=== TEST RESULTS ===
a@ferdmbp.local_________________________finished_ok
b@ferdmbp.local_________________________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がノードを起動し、どのようなクッキーで起動したかを示していることにも注意してください。マスターを終了せずにテストを再度実行しようとすると、代わりに次の警告が表示されます。

WARNING: Node 'a@ferdmbp.local' is alive but has node_start option
WARNING: Node 'b@ferdmbp.local' is alive but has node_start option

それは問題ありません。これは、Common Testがリモートノードに接続できることを意味しますが、ノードが既に稼働しているため、テスト仕様からのinitタプルの呼び出しは不要であることがわかりました。Common Testがテストを実行するリモートノードを実際に起動する必要はありませんが、通常はそうするのが役立つと思います。

これが分散型specファイルの概要です。より複雑なクラスタを設定し、驚くほど複雑な分散テストを作成するなど、より複雑なケースに進むこともできますが、テストが複雑になるほど、テスト自体にエラーが含まれる可能性が高くなるため、ソフトウェアのプロパティを正常に示す能力に対する信頼性が低くなります。

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.

Common TestへのEUnitの統合

EUnitが最適なツールである場合と、Common Testが最適なツールである場合があります。一方を他方に含めることが望ましい場合があります。

Common TestスイートをEUnitスイートに含めるのは困難ですが、その逆は非常に簡単です。コツは、eunit:test(SomeModule)を呼び出すと、正常に動作した場合はok、失敗した場合はerrorを返すことができる点です。

つまり、EUnitテストをCommon Testスイートに統合するには、次のような関数を作成するだけです。

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

そして、TestsToRun記述によって検出できるすべてのEUnitテストが実行されます。失敗した場合、Common Testログに表示され、出力を読んで何が間違っていたかを確認できます。とても簡単です。

他にもありますか?

もちろんです。Common Testは非常に複雑です。いくつかの変数の設定ファイルを追加する方法、テスト実行中の多くの時点で実行されるフックを追加する方法、スイート中のイベントでコールバックを使用する方法、SSHTelnetSNMP、およびFTP経由でテストするモジュールなどがあります。

この章では表面をなぞっただけですが、より深く探求したい場合に始めるのに十分です。Common Testに関するより完全なドキュメントは、Erlang/OTPに付属するユーザーガイドです。それ自体では読みづらいですが、この章で説明した内容を理解することで、間違いなくドキュメントを理解するのに役立ちます。