EUnited Nations Council

測試的必要性

G-Unit logo, parodied to be say 'E-Unit'

隨著時間推移,我們編寫的軟體變得越來越大且稍微複雜。當這種情況發生時,啟動 Erlang shell、輸入內容、查看結果並確保程式碼變更後一切正常,就會變得相當繁瑣。隨著時間的推移,對所有人來說,執行事先準備好的測試,而不是一直遵循手動檢查清單,變得更加簡單。這些通常都是在您的軟體中需要測試的充分理由。您也可能是一位測試驅動開發的愛好者,因此也會覺得測試很有用。

如果您還記得我們編寫 RPN 計算機的章節,我們手動編寫了一些測試。它們只是一組 結果 = 運算式 形式的模式匹配,如果發生錯誤就會崩潰,否則就會成功。這適用於您自己編寫的簡單程式碼片段,但當我們進行更嚴肅的測試時,我們絕對會需要更好的東西,例如一個框架。

對於單元測試,我們傾向於使用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(模組). 就是我們所需要的!耶,我們現在知道 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).

所以我們有 opsops_tests,其中第二個包含與第一個相關的測試。以下是 EUnit 可以做到的一件事

3> c(ops).
{ok,ops}
4> c(ops_tests).
{ok,ops_tests}
5> eunit:test(ops).
  Test passed.
ok

呼叫 eunit:test(模組) 會自動尋找 模組_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
A lie detector with a red light lit up, meaning someone lied

我們可以看見哪個測試失敗(ops_tests: add_test...)以及它失敗的原因(::error:{badmatch,4})。我們還獲得了通過或失敗的測試數量的完整報告。不過,輸出相當糟糕。至少和一般的 Erlang 崩潰一樣糟糕:沒有行號、沒有明確的解釋(4 到底與什麼不符?),等等。我們被一個執行測試但沒有告訴您太多關於它們的測試框架弄得不知所措。

因此,EUnit 引入了一些巨集來幫助我們。它們中的每一個都將為我們提供更清晰的報告(包括行號)和更清晰的語義。它們是了解出錯與知道為什麼出錯之間的區別

?assert(運算式), ?assertNot(運算式)
將測試布林值。如果除了 true 之外的任何值進入 ?assert,將會顯示錯誤。?assertNot 也是一樣,但是針對負值。這個巨集有點類似於 true = Xfalse = Y
?assertEqual(A, B)
在兩個運算式 AB 之間進行嚴格比較(相當於 =:=)。如果它們不同,就會發生失敗。這大致相當於 true = X =:= Y。自 R14B04 起,巨集 ?assertNotEqual 可用來執行與 ?assertEqual 相反的操作。
?assertMatch(模式, 運算式)
這允許我們以類似於 模式 = 運算式 的形式進行匹配,而變數永遠不會繫結。這意味著我可以執行類似 ?assertMatch({X,X}, some_function()) 的操作,並斷言我收到一個具有兩個相同元素的元組。此外,我可以稍後執行 ?assertMatch(X,Y),而 X 不會被繫結。
也就是說,它不像 模式 = 運算式,我們所擁有更接近於 (fun (模式) -> true; (_) -> erlang:error(nomatch) end)(運算式):模式頭中的變數永遠不會在多個斷言之間繫結。巨集 ?assertNotMatch 已在 R14B04 中新增至 EUnit。
?assertError(模式, 運算式)
告訴 EUnit 運算式 應該產生錯誤。舉例來說,?assertError(badarith, 1/0) 將會是一個成功的測試。
?assertThrow(模式, 運算式)
?assertError 完全相同,但使用 throw(模式) 而不是 erlang:error(模式)
?assertExit(模式, 運算式)
?assertError 完全相同,但使用 exit(模式)(而不是 exit/2)而不是 erlang:error(模式)
?assertException(類別, 模式, 運算式)
前三個巨集的通用形式。舉例來說,?assertException(error, 模式, 運算式)?assertError(模式, 運算式) 相同。從 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 術語中稱為測試表示的東西。我們還有其他一些表示

這些不同的測試表示可以輕鬆地執行整個應用程式甚至版本的測試套件。

a light fixture

固定裝置

僅僅使用斷言和測試產生器來測試整個應用程式仍然非常困難。這就是為什麼新增了固定裝置。雖然固定裝置並不是讓您的測試在應用程式層級啟動並運行的萬能解決方案,但它們允許您圍繞測試建立某種腳手架。

所討論的腳手架是一種通用結構,允許我們為每個測試定義設定和拆解函式。這些函式將允許您建立每個測試有用的狀態和環境。此外,腳手架將讓您指定如何執行測試(您想要在本機執行它們、在單獨的處理序中執行,等等?)。

有幾種類型的固定裝置可用,它們都有各自的變化。第一種類型簡稱為設定固定裝置。設定固定裝置採用以下許多形式之一

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

啊!我們似乎需要一些 EUnit 詞彙才能理解這一點(如果您需要去閱讀 EUnit 文件,這將會很有用)

設定
一個不帶引數的函式。每個測試都會傳遞設定函式傳回的值。
清理
一個以設定函式結果作為引數的函式,並負責清理任何需要的東西。如果在 OTP 中 terminate 執行與 init 相反的操作,那麼清理函式對於 EUnit 來說就是設定函式的相反操作。
實例化器
它是一個以設定函式結果作為引數並傳回測試集的函式(記住,測試集是 ?_Macro 斷言的深度巢狀清單)。
地點
指定如何執行測試:localspawn{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 函式中啟動registry 伺服器。然後,會呼叫實例化器 two_names_one_pid(ResultFromSetup)。在該測試中,我唯一做的事情就是嘗試將目前的程序註冊兩次。

這就是實例化器發揮作用的地方。第二次註冊的結果會儲存在變數 Res 中。然後,該函式會回傳一個包含單一測試的測試集 (?_assertEqual({error, already_named}, Res))。該測試集將由 EUnit 執行。接著,會呼叫拆解函式 stop/1。使用設定函式回傳的 pid,它將能夠關閉我們之前啟動的 registry。太棒了!

更棒的是,整個測試本身都可以放入一個測試集中

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

而且這會運作!令人惱火的是,需要一直重複設定和拆解函式,尤其是當它們總是相同的時候。這就是第二種測試類型,foreach 測試,登場的時候

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

foreach 測試與設定測試非常相似,不同之處在於它接受實例化器列表。以下是使用 foreach 測試編寫的 some_test_/0 函式

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

這樣更好。然後,foreach 測試會取得每個實例化器,並為每個實例化器執行設定和拆解函式。

現在我們知道如何為一個實例化器建立測試,然後為許多實例化器建立測試 (每個實例化器都會取得其設定和拆解函式呼叫)。如果我想要為許多實例化器呼叫一次設定函式和一次拆解函式呢?

換句話說,如果我有許多實例化器,但我只想設定一次狀態呢?沒有簡單的方法可以做到這一點,但這裡有一個小技巧可能可以做到

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

透過利用測試集可以是深度巢狀列表的事實,我們用一個行為類似於它們的實例化器的匿名函式來包裝一堆實例化器。

A fat Spawn (the anti-hero comic book character)

當您使用測試時,測試也可以對它們應該如何執行進行更細微的控制。有四個選項可用

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

這就是測試的大部分內容,但還有一點我忘了現在展示的好技巧。您可以用一種整潔的方式提供測試的描述。看看這個

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

不錯吧?您可以透過執行 {Comment, Fixture} 來包裝測試,以取得可讀的測試。讓我們將其付諸實踐。

測試 Regis

因為只看上面那些虛假的測試並不是最有趣的事情,而且假裝測試不存在的軟體更糟糕,所以我們將研究我為 regis-1.0.0 程序註冊表所寫的測試,也就是 Process Quest 使用的那個。

A portrait of Regis Philbin

現在,regis 的開發是以測試驅動的方式完成的。希望您不討厭 TDD (測試驅動開發),但即使您討厭,也不會太糟,因為我們將在事後查看測試套件。透過這樣做,我們避開了一些試錯序列和回溯,而我可能在第一次編寫時遇到這些情況,而且多虧了文字編輯的魔力,我看起來會非常有能力。

regis 應用程式由三個程序組成:一個監管者、一個主要伺服器和一個應用程式回呼模組。知道監管者只會檢查伺服器,並且應用程式回呼模組除了作為其他兩個模組的介面之外不會執行任何操作,我們可以安全地編寫一個測試套件,專注於伺服器本身,而沒有任何外部依賴項。

作為一個好的 TDD 粉絲,我首先寫下我想要涵蓋的所有功能的清單

這是一個值得尊敬的清單。逐一完成這些元素,並在我進行時新增案例,我將每個規格轉換為一個測試。取得的最終檔案是 regis_server_tests。我使用一個類似於這樣的基本結構來編寫內容

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

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

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

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

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

好吧,我承認,當模組為空時,這看起來很奇怪,但是當您填滿它時,它會越來越有意義。

在新增第一個測試之後,第一個測試應該可以啟動伺服器並透過名稱存取它,檔案看起來像這樣

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

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

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

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

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

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

現在看到組織結構了嗎?已經好很多了。檔案的頂部只包含測試和功能的頂級描述。第二部分包含我們可能需要的設定和清除函式。最後一個包含回傳測試集的實例化器。

在這種情況下,實例化器會檢查 regis_server:start_link() 是否產生了一個真正存活的程序,以及它是否已使用名稱 regis_server 註冊。如果是真的,那麼它將適用於伺服器。

如果我們查看檔案的目前版本,前兩個部分現在看起來更像這樣

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

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

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

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

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

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

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

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

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

不錯吧?請注意,當我編寫套件時,我最終發現我從來不需要任何其他設定和拆解函式,除了 start/0stop/1。因此,我新增了 ?setup(Instantiator) 巨集,這使得事情看起來比完全展開所有測試要好一點。現在很明顯,我將功能清單中的每個點都轉變為一堆測試。您會注意到我將所有測試分開,具體取決於它們是否與啟動和停止伺服器 (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))].
a heart monitor

正如我們在查看測試的第一個版本時所解釋的那樣,這會檢查程序是否可用。這個測試沒有什麼特別之處,儘管 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_serverwhereis 函式來擷取 Pid 並向該程序傳送訊息。如果 regis 伺服器運作正常,則會收到一條訊息,並且 pid 將在函式底部的測試中匹配。

不要喝太多 Kool-Aid
透過閱讀該測試,您已經看到了我們必須做的一點計時器工作。由於 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 之間的所有整數,包括在內。然後透過消除所有已註冊的程序來完成稍微的清理 — 畢竟我們不想洩漏它們。

a pocket watch

您會注意到測試傾向於在變數 (L1L2) 中儲存狀態,然後再在測試集中使用它們。這樣做的原因是,回傳的測試集會在測試啟動器 (程式碼的整個作用部分) 執行之後執行。如果您嘗試將依賴於其他程序和時間敏感事件的函式呼叫放入 ?_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 伺服器下執行測試,那麼就可以這樣做,而無需修改測試。

如果我們在所有測試中都使用像是 abc 這樣的硬編碼名稱,那麼如果我們嘗試同時執行許多測試套件,很可能遲早會發生名稱衝突。 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).

這個測試是循序讀取的。

  1. 註冊一個進程
  2. 確認進程已註冊
  3. 殺死該進程
  4. 竊取進程的身份(真正的間諜方式)
  5. 檢查我們自己是否持有該名稱。

老實說,這個測試可以用更簡單的方式編寫。

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' 選項如何將測試描述和執行時間資訊添加到報告中?這很棒。

a ball of yarn

編織 EUnit 的人

在本章中,我們已經看到了如何使用 EUnit 的大多數功能,以及如何執行其中編寫的套件。更重要的是,我們已經看到了一些關於如何為並行進程編寫測試的技巧,使用在現實世界中有意義的模式。

還應該知道最後一個技巧:當你覺得要測試諸如 gen_servergen_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 的 player 模組編寫的測試。它們使用不同的技術測試 gen_server,其中所有對 handle_callhandle_casthandle_info 的個別呼叫都是獨立嘗試的。它仍然是以測試驅動的方式開發的,但該需求迫使事情以不同的方式完成。

無論如何,當我們重寫進程註冊表以使用 ETS 時,我們將看到測試的真正價值,ETS 是一個適用於所有 Erlang 進程的記憶體資料庫。