不尋常測試的通用測試

在前面的章節中,我們已經了解如何使用 EUnit 進行單元和模組測試,甚至是一些並行測試。那時,EUnit 開始顯露出它的局限性。複雜的設定和需要彼此互動的較長測試變得有問題。此外,我們對於分散式 Erlang 及其所有強大功能的新知識,也沒有任何東西可以處理。幸運的是,還有另一個測試框架存在,這個框架更適合我們現在想要做的繁重工作。

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

什麼是通用測試

身為程式設計師,我們喜歡將我們的程式視為黑盒子。我們許多人會將良好抽象的核心原則定義為能夠將我們所寫的任何東西替換為匿名的黑盒子。您將某個東西放入盒子中,您會得到某個東西。您不關心它在內部如何運作,只要您得到您想要的東西即可。

在測試領域,這與我們喜歡如何測試系統有著重要的關聯。當我們使用 EUnit 時,我們已經了解如何將模組視為「黑盒子」:您只測試匯出的函式,而不測試內部未匯出的函式。我也給了一些關於將項目視為「白盒子」進行測試的範例,例如在處理程序任務播放器模組的測試中,我們查看了模組的內部結構,以簡化其測試。這是必要的,因為盒子內部所有移動部件的互動使得從外部測試它變得非常複雜。

那是針對模組和函式。如果我們稍微放大一點呢?讓我們調整範圍,以便看到更廣闊的畫面。如果我們想要測試的是一個函式庫呢?如果它是一個應用程式呢?更廣泛地說,如果它是一個完整的系統呢?那麼我們需要的是一個更擅長執行稱為「系統測試」的工具。

EUnit 是一個非常好的工具,可以在模組級別進行白盒測試。它是一個用於測試函式庫和 OTP 應用程式的不錯工具。可以進行系統測試和黑盒測試,但它不是最佳的。

然而,通用測試在系統測試方面非常出色。它對於測試函式庫和 OTP 應用程式來說是不錯的,並且可以使用它來測試個別模組,但不是最佳的。因此,您測試的東西越小,EUnit 就會越適合(且靈活且有趣)。您的測試越大,通用測試就會越適合(且靈活且,嗯,有點有趣)。

您之前可能聽過通用測試,並嘗試從 Erlang/OTP 提供的文件中了解它。然後您可能很快就放棄了。別擔心。問題是通用測試非常強大,並且有一個相應的長篇使用者指南,而且在撰寫本文時,它的大部分文件似乎都來自於它僅在 Ericsson 內部使用時的內部文件。事實上,它的文件更像是已經理解它的人的參考手冊,而不是教學。

為了正確學習通用測試,我們必須從它最簡單的部分開始,然後慢慢地發展到系統測試。

通用測試案例

甚至在開始之前,我必須先向您概述一下通用測試如何組織它的東西。首先,由於通用測試適用於系統測試,因此它會假設兩件事

  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 結尾的模組。如果我要測試上一章的魔術 8 球應用程式,我可能會將我的套件命名為 m8ball_SUITE。與之相關的是一個名為「資料目錄」的目錄。每個套件都允許有一個這樣的目錄,通常命名為 Module_SUITE_data/。以魔術 8 球應用程式為例,它將是 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 版本並使用通用測試。您會驚訝地發現您的瀏覽器和記錄的樣式將您帶回了 1990 年代後期。

使用狀態進行測試

如果您已閱讀 EUnit 章節(並且沒有跳過),您會記得 EUnit 有這些稱為「fixture」的東西,我們會為測試案例提供一些特殊的實例化(設定)和拆卸程式碼,分別在案例之前和之後呼叫。

通用測試遵循該概念。它沒有像 EUnit 式的 fixture,而是依賴於兩個函式。第一個是設定函式,稱為 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 之外,Common Test 的 include 檔還提供了一個 ?config(Key, List) 巨集,它會傳回與給定鍵值相符的值。此巨集實際上等同於 proplists:get_value/2,並已明確註明,因此您可以將 Config 視為屬性列表來處理,而不必擔心它會出錯。

舉例來說,如果我有 abc 這三個測試,而且只想為前兩個測試設定 setup 和 teardown 函數,我的 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 函數是在與測試案例相同的程序中執行的。因此,您可以安全地執行諸如設定連結、啟動表格之類的操作,而不必擔心不同的程序會破壞您的東西。那測試案例中的錯誤呢?幸好,除非是 kill 結束訊號,否則在測試案例中發生崩潰不會阻止 Common Test 清理並呼叫 end_per_testcase 函數。

現在,我們在靈活性方面,如果不是更多的話,幾乎與 Common Test 的 EUnit 相等。雖然我們沒有所有好用的斷言巨集,但我們有更精美的報告、類似的 fixtures,以及可以從頭開始寫入東西的私有目錄。我們還想要什麼呢?

注意: 如果您最終想要輸出內容來幫助您除錯或只是顯示測試進度,您會很快發現 io:format/1-2 只會列印在 HTML 紀錄檔中,而不會列印在 Erlang shell 中。如果您想要兩者都執行(包含免費的時間戳記),請使用 ct:pal/1-2 函數。它的運作方式與 io:format/1-2 類似,但會同時列印到 shell 和紀錄檔。

測試群組

目前,我們的套件內的測試結構最好看起來像這樣:

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。我們很快就會看到它們的含義。此範例也顯示一個群組包含另一個群組。這假設群組函數可能有點像這樣:

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
以隨機順序執行測試。用於序列的隨機種子(初始化值)將會列印在 HTML 紀錄檔中,格式為 {A,B,C}。如果特定順序的測試失敗,而您想要重現它,請使用 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 記錄,你將能夠看到所有失敗的測試運行以及原因。點擊測試名稱,你將獲得正確的測試運行。

注意:在從測試群組繼續前,最後一件(也是非常重要)的事情要知道的是,雖然測試案例的初始化函式與測試案例在同一個程序中執行,但群組的初始化函式是在與測試不同的程序中執行。這表示每當你初始化連結到產生它們的程序的 Actor 時,你必須先取消連結它們。對於 ETS 表格,你必須定義一個繼承者以確保它不會消失。對於其他所有附加到程序的概念(sockets、檔案描述符等)也是如此。

測試套件

除了群組的嵌套和在層次結構方面操作事物運行方式之外,我們還能為測試套件添加什麼更好的東西?不多,但我們仍然會將測試套件本身再增加一層。

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 元組,很像一個 consult 檔案。以下是一些它可以包含的項目

{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: 原因」,其中 原因Comment 包含的任何內容。
{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/ 目錄(我檔案中的 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(對於 R15 之前的 Erlang 版本,則使用 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

如果你花時間查看記錄,你會看到兩個用於不同測試執行的目錄。其中一個會出現失敗;那是預期會失敗的會議。另一個將有一個成功,和一個跳過的案例,格式為 1 (1/0)。通常,格式是 TotalSkipped (IntentionallySkipped/SkippedDueToError)。在本例中,跳過是從規格檔案發生的,因此它會在左側。如果它是由於許多初始化函式之一失敗而發生的,則它會在右側。

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, 秒數},
{init_timeout, 秒數},
{startup_timeout, 秒數}
這三個選項可讓您等待遠端節點啟動的不同階段。啟動逾時是指節點變為可 ping 通的時間長度,預設值為 3 秒。初始化逾時是一個內部計時器,新的遠端節點會呼叫 CT 主節點來告知它已啟動。預設情況下,它會持續一秒鐘。最後,啟動逾時會告知 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, '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"}.

這做了一些更改。我們定義了兩個從屬節點 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)
(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 顯示它啟動了節點,以及它使用的 Cookie。如果您在未先終止主節點的情況下嘗試再次執行測試,則會顯示以下警告:

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 沒有必要實際啟動任何它將在上面執行測試的遠端節點,但我通常覺得這樣做很有用。

這就是分散式規格檔案的要點。當然,您可以深入研究更複雜的情況,在其中設定更複雜的叢集並撰寫出色的分散式測試,但隨著測試變得更複雜,您對它們成功展示您的軟體屬性的能力信心越低,因為測試本身可能會隨著它們變得複雜而包含越來越多的錯誤。

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 是一個非常複雜的野獸。有一些方法可以為某些變數新增組態檔案、新增在測試執行期間的許多點執行的掛鉤、在套件期間的事件上使用回呼、使用模組來透過 SSHTelnetSNMPFTP 進行測試。

本章僅觸及表面,但如果您想更深入地探索,這就足夠讓您入門。關於 Common Test 的更完整文件是 Erlang/OTP 隨附的 使用者指南。它本身很難閱讀,但毫無疑問,了解本章所涵蓋的材料將有助於您理解文件。