檢視原始碼 EUnit - Erlang 的輕量級單元測試框架

EUnit 是一個用於 Erlang 的單元測試框架。它非常強大且靈活,易於使用,並且語法負擔很小。

EUnit 建基於物件導向語言的單元測試框架家族的概念,該家族起源於 Beck 和 Gamma 的 JUnit(以及 Beck 先前的 Smalltalk 框架 SUnit)。然而,EUnit 使用更適用於函數式和並行程式設計的技術,並且通常比其相關框架更不冗長。

儘管 EUnit 使用許多前處理器巨集,但它們的設計盡可能不具侵入性,不應與現有程式碼發生衝突。因此,將 EUnit 測試新增至模組通常不應需要變更現有程式碼。此外,僅執行模組的匯出函數的測試可以始終放置在完全獨立的模組中,完全避免任何衝突。

單元測試

單元測試是在相對隔離的情況下測試個別程式「單元」。沒有特定的尺寸要求:單元可以是函數、模組、程序,甚至是整個應用程式,但最典型的測試單元是個別函數或模組。為了測試一個單元,您需要指定一組個別測試,設定能夠執行這些測試所需的最小環境(通常,您根本不需要進行任何設定),您執行測試並收集結果,最後您進行任何必要的清理,以便以後可以再次執行測試。單元測試框架嘗試在這個過程的每個階段幫助您,以便您可以輕鬆編寫測試、輕鬆執行測試,並輕鬆查看哪些測試失敗(以便您可以修正錯誤)。

單元測試的優點

  • 降低變更程式的風險 - 大多數程式在其生命週期中都會被修改:錯誤會被修正、功能會被新增、最佳化可能會變得必要,或者程式碼需要以其他方式重構或清理,以使其更容易使用。但是,每次變更運作中的程式都存在引入新錯誤或重新引入先前已修正的錯誤的風險。擁有一組可以輕鬆執行的單元測試,可以很容易地知道程式碼是否仍然按照應有的方式運作(這種用法稱為回歸測試;請參閱術語)。這在很大程度上減少了對變更和重構程式碼的阻力。

  • 有助於指導和加速開發過程 - 透過專注於使程式碼通過測試,程式設計師可以變得更有效率,不會過度指定或迷失在過早的最佳化中,並建立從一開始就正確的程式碼(所謂的測試驅動開發;請參閱術語)。

  • 有助於將介面與實作分離 - 在編寫測試時,程式設計師可能會發現不應該存在,並且需要抽象化的相依性(為了使測試能夠執行)。這有助於在它們散佈到整個程式碼之前消除不良的相依性。

  • 使元件整合更容易 - 透過由下而上進行測試,從最小的程式單元開始,並建立對它們按應有方式運作的信心,就可以更輕鬆地測試由幾個此類單元組成的高階元件是否也按照規格執行(稱為整合測試;請參閱術語)。

  • 具有自我文件化功能 - 測試可以作為文件閱讀,通常會顯示正確和不正確用法的範例,以及預期的結果。

術語

  • 單元測試 - 測試程式單元是否按照其規格(本身)執行。單元測試在程式稍後因某些原因進行修改時,具有作為回歸測試的重要功能,因為它們會檢查程式是否仍然按照規格執行。

  • 回歸測試 - 在對程式進行變更後執行一組測試,以檢查程式是否以變更前的狀態運作(當然,行為的任何故意變更除外)。單元測試對於回歸測試很重要,但是回歸測試可能不僅僅涉及單元測試,也可能測試可能不屬於正常規格一部分的行為(例如錯誤相容性)。

  • 整合測試 - 測試一些單獨開發的程式單元(假設已單獨進行單元測試)是否按預期一起運作。根據所開發的系統,整合測試可能就像「另一個單元測試層級」一樣簡單,但也可能涉及其他類型的測試(請比較系統測試)。

  • 系統測試 - 測試完整的系統是否按照其規格執行。具體而言,系統測試不應需要了解有關實作的任何詳細資訊。它通常涉及測試系統行為的許多不同方面,除了基本功能外,還包括效能、可用性和可靠性。

  • 測試驅動開發 - 一種程式開發技術,您會在實作應該通過這些測試的程式碼之前持續編寫測試。這可以幫助您專注於解決正確的問題,而不是進行比必要的更複雜的實作,方法是讓單元測試決定程式何時「完成」:如果它符合其規格,則無需繼續新增功能。

  • 模擬物件 - 有時,測試某些單元 A(例如,函數)需要它以某種方式與某些其他單元 B 協作(可能作為引數傳遞,或透過參照)- 但 B 尚未實作。然後,可以使用「模擬物件」- 一個物件,為了測試 A 的目的,其外觀和行為類似於真實的 B。(當然,只有在實作真正的 B 比建立模擬物件需要更多的工作時,這才有用。)

  • 測試案例 - 單一、明確定義的測試,可以以某種方式唯一識別。執行時,測試案例會通過失敗;測試報告應明確識別哪些測試案例失敗。

  • 測試套件 - 測試案例的集合,通常具有特定的、共同的測試目標,例如單一函數、模組或子系統。測試套件也可以由較小的測試套件遞迴組成。

入門

包含 EUnit 標頭檔

在 Erlang 模組中使用 EUnit 的最簡單方法是在模組的開頭新增以下程式碼行(在 -module 宣告之後,但在任何函數定義之前)

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

這將產生以下效果

  • 建立一個匯出的函數 test()(除非關閉測試,並且模組尚未包含 test() 函數),可用於執行模組中定義的所有單元測試
  • 使所有名稱與 ..._test()..._test_() 相符的函數從模組自動匯出(除非關閉測試,或定義了 EUNIT_NOAUTO 巨集)
  • 使 EUnit 的所有前處理器巨集可用,以協助編寫測試

注意:為了使 -include_lib(...) 能夠運作,Erlang 模組搜尋路徑必須包含一個名稱以 eunit/ebin 結尾的目錄(指向 EUnit 安裝目錄的 ebin 子目錄)。如果 EUnit 安裝在您的 Erlang/OTP 系統目錄下的 lib/eunit 中,則當 Erlang 啟動時,其 ebin 子目錄將會自動新增至搜尋路徑。否則,您需要透過將 -pa 旗標傳遞給 erlerlc 命令來明確新增目錄。例如,Makefile 可以包含以下動作來編譯 .erl 檔案

   erlc -pa "path/to/eunit/ebin" $(ERL_COMPILE_FLAGS) -o$(EBIN) $<

或者,如果您希望在以互動方式執行 Erlang 時始終可以使用 Eunit,您可以將以下程式碼行新增至您的 $HOME/.erlang 檔案

   code:add_path("/path/to/eunit/ebin").

編寫簡單的測試函數

EUnit 框架使您可以在 Erlang 中極其輕鬆地編寫單元測試。不過,有幾種不同的編寫方式,因此我們先從最簡單的方式開始

名稱以 ..._test() 結尾的函數會被 EUnit 識別為簡單的測試函數 - 它不帶任何引數,並且它的執行會成功(傳回 EUnit 將會丟棄的某些任意值),或者會因拋出某種例外而失敗(或者因不終止而失敗,在這種情況下,它會在一段時間後中止)。

簡單測試函數的範例可能是以下

   reverse_test() -> lists:reverse([1,2,3]).

這僅測試當 List[1,2,3] 時,函數 lists:reverse(List) 不會當機。這不是一個好的測試,但是許多人編寫像這樣簡單的函數來測試其程式碼的基本功能,只要其函數名稱相符,EUnit 就可以直接使用這些測試,而無需變更。

使用例外來發出失敗訊號 為了編寫更有趣的測試,我們需要使其在沒有獲得預期結果時當機(拋出例外)。一種簡單的方法是使用模式比對與 =,如下列範例所示

   reverse_nil_test() -> [] = lists:reverse([]).
   reverse_one_test() -> [1] = lists:reverse([1]).
   reverse_two_test() -> [2,1] = lists:reverse([1,2]).

如果 lists:reverse/1 中存在某些錯誤,導致它在收到 [1,2] 作為輸入時,回傳的結果不是 [2,1],那麼上面的最後一個測試將會拋出 badmatch 錯誤。前兩個測試(我們假設它們不會得到 badmatch)將會分別簡單地回傳 [][1],因此兩者都會成功。(請注意,EUnit 並不是通靈的:如果您撰寫的測試回傳了一個值,即使該值是錯誤的,EUnit 也會將其視為成功。您必須確保測試的撰寫方式是,如果結果不符合預期,則會導致程式崩潰。)

使用 assert 巨集 如果您想在測試中使用布林運算子,assert 巨集會很有用(詳情請參閱 EUnit 巨集

   length_test() -> ?assert(length([1,2,3]) =:= 3).

?assert(Expression) 巨集將會評估 Expression,如果該表達式沒有評估為 true,它將會拋出一個例外;否則它只會回傳 ok。在上面的範例中,如果呼叫 length 沒有回傳 3,則測試將會失敗。

執行 EUnit

如果您已將宣告 -include_lib("eunit/include/eunit.hrl") 新增到您的模組中(如上所述),您只需要編譯該模組,並執行自動匯出的函式 test()。例如,如果您的模組名為 m,那麼呼叫 \m:test() 將會在該模組中執行的所有測試。您不需要為測試函式撰寫 -export 宣告。這一切都是透過魔法完成的。

您也可以使用函式 eunit:test/1 來執行任意測試,例如嘗試一些更進階的測試描述符(請參閱 EUnit 測試表示法)。例如,執行 eunit:test(m) 的效果與自動產生的函式 \m:test() 相同,而 eunit:test({inparallel, m}) 則會執行相同的測試案例,但會平行執行它們。

將測試放在個別的模組中

如果您想將測試程式碼與一般程式碼分開(至少在測試匯出的函式時),您可以簡單地在一個名為 m_tests 的模組中撰寫測試函式(請注意:不是 m_test),如果您的模組名為 m。然後,每當您要求 EUnit 測試模組 m 時,它也會尋找模組 m_tests 並執行其中的測試。詳情請參閱 Primitives 區段中的 ModuleName

EUnit 會擷取標準輸出

如果您的測試程式碼寫入標準輸出,您可能會驚訝地發現,在測試執行時,文字並不會顯示在主控台上。這是因為 EUnit 會擷取來自測試函式的所有標準輸出(這也包括設定和清除函式,但不包括產生器函式),以便在發生錯誤時將其包含在測試報告中。若要略過 EUnit 並在測試時直接將文字列印到主控台,您可以寫入 user 輸出串流,例如 io:format(user, "~w", [Term])。建議使用 EUnit 的 除錯巨集 來執行此操作,這會讓操作變得更簡單。

如需檢查受測單元產生的輸出,請參閱 用於檢查輸出的巨集

撰寫測試產生器函式

簡單測試函式的一個缺點是,您必須為每個測試案例撰寫一個單獨的函式(具有不同的名稱)。撰寫測試的更精簡方式(也更具彈性,我們稍後將會看到)是撰寫回傳測試的函式,而不是作為測試的函式。

名稱結尾為 ..._test_() 的函式(請注意最後的底線)會被 EUnit 識別為測試產生器函式。測試產生器會回傳要由 EUnit 執行的一組測試的表示法

將測試表示為資料 測試最基本的表示法是單一的 fun 表達式,它不接受任何引數。例如,下列測試產生器

   basic_test_() ->
       fun () -> ?assert(1 + 1 =:= 2) end.

將會與下列簡單測試產生相同的效果

   simple_test() ->
       ?assert(1 + 1 =:= 2).

(事實上,EUnit 會像處理 fun 表達式一樣處理所有簡單測試:它會將它們放在一個清單中,並依序執行它們)。

使用巨集撰寫測試 為了使測試更加精簡和易讀,並自動新增有關測試在原始碼中發生之行號的資訊(並減少您必須輸入的字元數),您可以使用 _test 巨集(請注意初始的底線字元),如下所示

   basic_test_() ->
       ?_test(?assert(1 + 1 =:= 2)).

_test 巨集會將任何表達式(「主體」)作為引數,並將其置於 fun 表達式中(以及一些額外資訊)。主體可以是任何類型的測試表達式,就像簡單測試函式的主體一樣。

底線前綴的巨集會建立測試物件 但這個範例可以變得更短!大多數測試巨集(例如 assert 巨集系列)都有一個帶有初始底線字元的對應形式,它會自動新增 ?_test(...) 包裝器。上面的範例可以簡單地寫成

   basic_test_() ->
       ?_assert(1 + 1 =:= 2).

這具有完全相同的意義(請注意 _assert 而不是 assert)。您可以將初始的底線視為表示測試物件

範例

有時候,一個範例勝過千言萬語。下列小型 Erlang 模組示範了如何在實務中使用 EUnit。

   -module(fib).
   -export([fib/1]).
   -include_lib("eunit/include/eunit.hrl").

   fib(0) -> 1;
   fib(1) -> 1;
   fib(N) when N > 1 -> fib(N-1) + fib(N-2).

   fib_test_() ->
       [?_assert(fib(0) =:= 1),
	?_assert(fib(1) =:= 1),
	?_assert(fib(2) =:= 2),
	?_assert(fib(3) =:= 3),
	?_assert(fib(4) =:= 5),
	?_assert(fib(5) =:= 8),
	?_assertException(error, function_clause, fib(-1)),
	?_assert(fib(31) =:= 2178309)
       ].

(作者註:當我第一次撰寫這個範例時,我碰巧在 fib 函式中寫了 * 而不是 +。當然,當我執行測試時,這立即顯示出來。)

如需所有可以在 EUnit 中指定測試集的完整清單,請參閱 EUnit 測試表示法

停用測試

可以在編譯時透過定義 NOTEST 巨集來關閉測試,例如作為 erlc 的選項,如下所示

   erlc -DNOTEST my_module.erl

或透過在程式碼中新增巨集定義,在包含 EUnit 標頭檔之前

   -define(NOTEST, 1).

(值並不重要,但通常應該是 1 或 true)。請注意,除非定義了 EUNIT_NOAUTO 巨集,否則停用測試也會自動從程式碼中移除所有測試函式,但明確宣告為匯出的任何函式除外。

例如,若要在您的應用程式中使用 EUnit,但預設關閉測試,請在標頭檔中放置以下幾行

   -define(NOTEST, true).
   -include_lib("eunit/include/eunit.hrl").

然後確保您應用程式的每個模組都包含該標頭檔。這表示您有一個單一的位置可以修改,以變更測試的預設設定。若要覆寫 NOTEST 設定,而無需修改程式碼,您可以在編譯器選項中定義 TEST,如下所示

   erlc -DTEST my_module.erl

如需有關這些巨集的詳細資訊,請參閱 編譯控制巨集

避免對 EUnit 的編譯時期依賴性

如果您要發佈應用程式的原始碼供其他人編譯和執行,您可能希望確保即使 EUnit 無法使用,程式碼也能夠編譯。與上一節中的範例一樣,您可以將以下幾行放在一個通用的標頭檔中

   -ifdef(TEST).
   -include_lib("eunit/include/eunit.hrl").
   -endif.

當然,還要確保將所有使用 EUnit 巨集的測試程式碼放置在 -ifdef(TEST)-ifdef(EUNIT) 區段中。

EUnit 巨集

雖然即使不使用前處理器巨集,EUnit 的所有功能都可用,但 EUnit 標頭檔定義了許多此類巨集,以便盡可能輕鬆地撰寫單元測試,並且盡可能精簡,而不會過於深入了解太多細節。

除非另有明確說明,否則使用 EUnit 巨集永遠不會在 EUnit 程式庫程式碼中引入執行時期依賴性,無論您的程式碼是否在啟用或停用測試的情況下編譯。

基本巨集

  • _test(Expr) - 透過將 Expr 包裝在 fun 表達式和來源行號中,將 Expr 變成「測試物件」。從技術上講,這與 {?LINE, fun () -> (Expr) end} 相同。

編譯控制巨集

  • EUNIT - 每當在編譯時期啟用 EUnit 時,此巨集永遠定義為 true。這通常用於將測試程式碼置於條件編譯中,例如

       -ifdef(EUNIT).
           % test code here
           ...
       -endif.

    例如,確保在停用測試時,可以在不包含 EUnit 標頭檔的情況下編譯程式碼。另請參閱巨集 TESTNOTEST

  • EUNIT_NOAUTO - 如果定義了此巨集,則會停用測試函式的自動匯出或移除。

  • TEST - 每當在編譯時期啟用 EUnit 時,此巨集都會永遠定義(為 true,除非先前由使用者定義為具有其他值)。這可用於將測試程式碼置於條件編譯中;另請參閱巨集 NOTESTEUNIT

    對於嚴格依賴 EUnit 的測試程式碼,最好使用 EUNIT 巨集來達到此目的,而對於使用更通用測試慣例的程式碼,則可能偏好使用 TEST 巨集。

    TEST 巨集也可以用來覆蓋 NOTEST 巨集。如果 TEST 在包含 EUnit 標頭檔之前定義 (即使同時定義了 NOTEST),則程式碼將會以啟用 EUnit 的狀態編譯。

  • NOTEST - 當 EUnit 在編譯時停用時,此巨集始終會被定義 (為 true,除非使用者先前已將其定義為其他值)。(請比較 TEST 巨集。)

    此巨集也可以用於條件編譯,但更常用於停用測試:如果 NOTEST 在包含 EUnit 標頭檔之前定義,且定義 TEST,則程式碼將會以停用 EUnit 的狀態編譯。另請參閱停用測試

  • NOASSERT - 如果定義了此巨集,當測試也停用時,assert 巨集將不會有任何作用。請參閱Assert 巨集。當測試啟用時,assert 巨集會自動啟用,且無法停用。

  • ASSERT - 如果定義了此巨集,它會覆蓋 NOASSERT 巨集,強制 assert 巨集始終處於啟用狀態,而不管其他設定為何。

  • NODEBUG - 如果定義了此巨集,除錯巨集將不會有任何作用。請參閱除錯巨集NODEBUG 也隱含 NOASSERT,除非測試已啟用。

  • DEBUG - 如果定義了此巨集,它會覆蓋 NODEBUG 巨集,強制啟用除錯巨集。

實用巨集

下列巨集可以使測試更精簡且更具可讀性

  • LET(Var,Arg,Expr) - 在 Expr 中建立一個區域綁定 Var = Arg。(這與 (fun(Var)->(Expr)end)(Arg) 相同。)請注意,該綁定不會匯出到 Expr 之外,並且在 Expr 內,此 Var 的綁定將會遮蔽周圍範圍中 Var 的任何綁定。

  • IF(Cond,TrueCase,FalseCase) - 如果 Cond 的計算結果為 true,則計算 TrueCase,否則,如果 Cond 的計算結果為 false,則計算 FalseCase。(這與 (case (Cond) of true->(TrueCase); false->(FalseCase) end) 相同。)請注意,如果 Cond 未產生布林值,則會發生錯誤。

Assert 巨集

(請注意,這些巨集也有對應的形式,以 "_" (底線) 字元開頭,如 ?_assert(BoolExpr),它會建立「測試物件」而不是立即執行測試。這等同於寫入 ?_test(assert(BoolExpr)) 等。)

如果在包含 EUnit 標頭檔之前定義了巨集 NOASSERT,則當測試也停用時,這些巨集將不起作用;請參閱編譯控制巨集以了解詳細資訊。

  • assert(BoolExpr) - 如果測試已啟用,則會計算運算式 BoolExpr。除非結果為 true,否則將產生資訊性例外。如果沒有例外,則巨集運算式的結果為原子 ok,並且會捨棄 BoolExpr 的值。如果測試已停用,則巨集不會產生任何程式碼,除了原子 ok 之外,並且不會計算 BoolExpr

    典型用法

       ?assert(f(X, Y) =:= [])

    assert 巨集可以用於程式中的任何位置,而不僅僅是單元測試中,以檢查前置/後置條件和不變量。例如

       some_recursive_function(X, Y, Z) ->
           ?assert(X + Y > Z),
           ...
  • assertNot(BoolExpr) - 等同於 assert(not (BoolExpr))

  • assertMatch(GuardedPattern, Expr) - 如果測試已啟用,則計算 Expr,並將結果與 GuardedPattern 比對。如果比對失敗,則會產生資訊性例外;請參閱 assert 巨集以了解更多詳細資訊。GuardedPattern 可以是您可以在 case 子句中 -> 符號的左側寫入的任何內容,但它不能包含以逗號分隔的守衛測試。

    使用 assertMatch 進行簡單比對而不是使用 = 進行比對的主要原因,是它會產生更詳細的錯誤訊息。

    範例

       ?assertMatch({found, {fred, _}}, lookup(bloggs, Table))
       ?assertMatch([X|_] when X > 0, binary_to_list(B))
  • assertNotMatch(GuardedPattern, Expr) - 為了方便起見,這是 assertMatch 的反向情況。

  • assertEqual(Expect, Expr) - 如果測試已啟用,則計算運算式 ExpectExpr,並比較結果是否相等。如果值不相等,則會產生資訊性例外;請參閱 assert 巨集以了解更多詳細資訊。

    當左側是計算值而不是簡單模式時,assertEqualassertMatch 更適合,並且比 ?assert(Expect =:= Expr) 提供更多詳細資訊。

    範例

       ?assertEqual("b" ++ "a", lists:reverse("ab"))
       ?assertEqual(foo(X), bar(Y))
  • assertNotEqual(Unexpected, Expr) - 為了方便起見,這是 assertEqual 的反向情況。

  • assertException(ClassPattern, TermPattern, Expr)

  • assertError(TermPattern, Expr)

  • assertExit(TermPattern, Expr)

  • assertThrow(TermPattern, Expr) - 計算 Expr,捕獲任何例外,並測試它是否與預期的 ClassPattern:TermPattern 比對。如果比對失敗,或者 Expr 沒有擲回任何例外,則會產生資訊性例外;請參閱 assert 巨集以了解更多詳細資訊。assertErrorassertExitassertThrow 巨集,等同於使用 assertException,且 ClassPattern 分別為 errorexitthrow

    範例

       ?assertError(badarith, X/0)
       ?assertExit(normal, exit(normal))
       ?assertException(throw, {not_found,_}, throw({not_found,42}))

用於檢查輸出的巨集

以下巨集可以用於測試案例中,以擷取寫入標準輸出的輸出。

  • capturedOutput - EUnit 在目前測試案例中擷取的輸出,以字串形式呈現。

    範例

       io:format("Hello~n"),
       ?assertEqual("Hello\n", ?capturedOutput)

用於執行外部命令的巨集

請記住,外部命令高度依賴於作業系統。您可以在測試產生器函式中使用標準程式庫函式 os:type(),根據目前的作業系統產生不同的測試集合。

注意:如果啟用測試進行編譯,這些巨集會引入對 EUnit 程式庫程式碼的執行階段相依性。

  • assertCmd(CommandString) - 如果測試已啟用,則會將 CommandString 作為外部命令執行。除非傳回的狀態值為 0,否則會產生資訊性例外。如果沒有例外,則巨集運算式的結果為原子 ok。如果測試已停用,則巨集不會產生任何程式碼,除了原子 ok 之外,並且不會執行命令。

    典型用法

       ?assertCmd("mkdir foo")
  • assertCmdStatus(N, CommandString) - 與 assertCmd(CommandString) 巨集類似,但除非傳回的狀態值為 N,否則會產生例外。

  • assertCmdOutput(Text, CommandString) - 如果測試已啟用,則會將 CommandString 作為外部命令執行。除非命令產生的輸出與指定的字串 Text 完全符合,否則會產生資訊性例外。(請注意,輸出已正規化為在所有平台上使用單個 LF 字元作為換行符號。)如果沒有例外,則巨集運算式的結果為原子 ok。如果測試已停用,則巨集不會產生任何程式碼,除了原子 ok 之外,並且不會執行命令。

  • cmd(CommandString) - 將 CommandString 作為外部命令執行。除非傳回的狀態值為 0 (表示成功),否則會產生資訊性例外;否則,巨集運算式的結果為命令產生的輸出,以純文字字串形式呈現。輸出已正規化為在所有平台上使用單個 LF 字元作為換行符號。

    此巨集在夾具的設定和清除區段中很有用,例如,用於建立和刪除檔案或執行類似的作業系統特定任務,以確保測試系統收到任何失敗的通知。

    特定於 Unix 的範例

       {setup,
        fun () -> ?cmd("mktemp") end,
        fun (FileName) -> ?cmd("rm " ++ FileName) end,
        ...}

除錯巨集

為了協助除錯,EUnit 定義了幾個有用的巨集,可將訊息直接列印到主控台 (而不是標準輸出)。此外,這些巨集都使用相同的基本格式,其中包括它們所在檔案和行號,這使得某些開發環境 (例如,在 Emacs 緩衝區中執行 Erlang 時) 可以簡單地按一下訊息,並直接跳到程式碼中的對應行。

如果在包含 EUnit 標頭檔之前定義了巨集 NODEBUG,則這些巨集將不起作用;請參閱編譯控制巨集以了解詳細資訊。

  • debugHere - 只會列印一個標記,顯示目前的檔案和行號。請注意,這是一個沒有引數的巨集。結果始終為 ok

  • debugMsg(Text) - 輸出訊息 Text (它可以是純文字字串、IO 清單,或只是一個原子)。結果始終為 ok

  • debugFmt(FmtString, Args) - 此函式會像 io:format(FmtString, Args) 一樣格式化文字,並像 debugMsg 一樣輸出它。結果始終為 ok

  • debugVal(Expr) - 列印 Expr 的原始碼及其目前值。例如,?debugVal(f(X)) 可能會顯示為「f(X) = 42」。(大型詞彙會截斷為巨集 EUNIT_DEBUG_VAL_DEPTH 指定的深度,預設值為 15,但使用者可以覆寫。)結果始終為 Expr 的值,因此此巨集可以包裝在任何運算式周圍,以在啟用除錯的情況下編譯程式碼時顯示其值。

  • debugVal(Expr, Depth) - 與 debugVal(Expr) 類似,但會列印截斷到給定深度的詞彙。

  • debugTime(Text,Expr) - 列印 Text 和計算 Expr 的實際時間。結果始終為 Expr 的值,因此此巨集可以包裝在任何運算式周圍,以在啟用除錯的情況下編譯程式碼時顯示其執行時間。例如,List1 = ?debugTime("sorting", lists:sort(List)) 可能會顯示為「sorting: 0.015 s」。

EUnit 測試表示法

EUnit 以資料形式表示測試和測試集合的方式靈活、強大且簡潔。本節將詳細說明此表示法。

簡單的測試物件

簡單的測試物件 是下列其中之一

  • 一個零元函式值 (即,一個不帶引數的 fun)。範例

       fun () -> ... end
       fun some_function/0
       fun some_module:some_function/0
  • 一個 tuple {test, ModuleName, FunctionName},其中 ModuleNameFunctionName 是原子,引用函式 ModuleName:FunctionName/0

  • (已過時) 一對原子 {ModuleName, FunctionName},等同於 {test, ModuleName, FunctionName},如果沒有其他更符合的條件。這可能會在未來的版本中移除。

  • 一對 {LineNumber, SimpleTest},其中 LineNumber 是一個非負整數,而 SimpleTest 是另一個簡單的測試物件。LineNumber 應指示測試的原始碼行號。這樣的配對通常只透過 ?_test(...) 巨集建立;請參閱基本巨集

簡而言之,一個簡單的測試物件包含一個不帶任何引數的函式 (可能帶有一些額外的中繼資料,例如行號)。該函式的執行結果不是成功,返回某個值 (該值會被忽略),就是失敗,拋出一個例外。

測試集和深層列表

透過將一系列測試物件放入列表中,可以輕鬆建立測試集。如果 T_1, ..., T_N 是個別的測試物件,則 [T_1, ..., T_N] 是一個由這些物件組成的測試集 (依該順序)。

測試集可以用相同的方式結合:如果 S_1, ..., S_K 是測試集,則 [S_1, ..., S_K] 也是一個測試集,其中 S_i 的測試會排在 S_(i+1) 的測試之前,適用於每個子集 S_i

因此,測試集的主要表示形式是深層列表,而一個簡單的測試物件可以被視為一個僅包含單一測試的測試集;T[T] 之間沒有區別。

模組也可以用來表示測試集;請參閱下方基本元素中的 ModuleName

標題

任何測試或測試集 T 都可以使用標題註釋,方法是將其包裝在一對 {Title, T} 中,其中 Title 是一個字串。為方便起見,任何通常使用元組表示的測試,都可以簡單地將標題字串作為第一個元素,也就是寫成 {"The Title", ...},而不是像 {"The Title", {...}} 那樣加入額外的元組包裝器。

基本元素

以下是基本元素,它們不包含其他測試集作為引數

  • ModuleName::atom() - 單一原子表示模組名稱,等同於 {module, ModuleName}。這通常用於呼叫 eunit:test(some_module)

  • {module, ModuleName::atom()} - 這會從指定模組的匯出測試函數中組成測試集,也就是那些名稱以 _test_test_ 結尾且引數個數為零的函數。基本上,..._test() 函數會變成簡單的測試,而 ..._test_() 函數會變成產生器。

    此外,EUnit 也會尋找另一個名稱為 ModuleName 加上後綴 _tests 的模組,如果該模組存在,則該模組中的所有測試也會被新增。(如果 ModuleName 已經包含後綴 _tests,則不會執行此操作。) 例如,規格 {module, mymodule} 將執行模組 mymodulemymodule_tests 中的所有測試。通常,_tests 模組應該只包含使用主模組公開介面的測試案例 (不包含其他程式碼)。

  • {application, AppName::atom(), Info::list()} - 這是一個正常的 Erlang/OTP 應用程式描述符,如同在 .app 檔案中找到的。產生的測試集包含在 Infomodules 條目中列出的模組。

  • {application, AppName::atom()} - 這會透過查詢應用程式的 .app 檔案 (請參閱 {file, FileName}),從屬於指定應用程式的所有模組建立測試集;如果沒有此類檔案,則會測試應用程式的 ebin 目錄中的所有物件檔案 (請參閱 {dir, Path});如果該目錄不存在,則會使用 code:lib_dir(AppName) 目錄。

  • Path::string() - 單一字串表示檔案或目錄的路徑,等同於 {file, Path}{dir, Path},具體取決於 Path 在檔案系統中指的是什麼。

  • {file, FileName::string()} - 如果 FileName 有一個表示物件檔案的後綴 (.beam),EUnit 會嘗試從指定檔案重新載入模組並進行測試。否則,該檔案會被視為包含測試規格的文字檔案,將使用標準程式庫函數 file:path_consult/2 讀取。

    除非檔案名稱是絕對路徑,否則會先相對於目前目錄搜尋檔案,然後使用正常的搜尋路徑 (code:get_path())。這表示可以直接使用典型 "app" 檔案的名稱,而無需路徑,例如 "mnesia.app"

  • {dir, Path::string()} - 這會測試指定目錄中的所有物件檔案,如同它們是使用 {file, FileName} 個別指定的一樣。

  • {generator, GenFun::(() -> Tests)} - 呼叫產生器函數 GenFun 以產生測試集。

  • {generator, ModuleName::atom(), FunctionName::atom()} - 呼叫函數 ModuleName:FunctionName() 以產生測試集。

  • {with, X::any(), [AbstractTestFun::((any()) -> any())]} - 將值 X 分散到列表中的一元函數上,將它們變成零元測試函數。AbstractTestFun 類似於一般的測試函數,但它接受一個引數而不是零個引數 - 它基本上缺少一些資訊才能成為正確的測試。實際上,{with, X, [F_1, ..., F_N]} 等同於 [fun () -> F_1(X) end, ..., fun () -> F_N(X) end]。如果您的一元抽象測試函數已經實作為正確的函數,這會特別有用:{with, FD, [fun filetest_a/1, fun filetest_b/1, fun filetest_c/1]} 等同於 [fun () -> filetest_a(FD) end, fun () -> filetest_b(FD) end, fun () -> filetest_c(FD) end],但更加精簡。另請參閱下方的固定裝置

控制

以下表示形式控制測試的執行方式和位置

  • {spawn, Tests} - 在單獨的子處理序中執行指定的測試,而目前的測試處理序會等待其完成。這對於需要新的隔離處理序狀態的測試很有用。(請注意,EUnit 始終會自動啟動至少一個這樣的子處理序;測試永遠不會由呼叫者自己的處理序執行。)

  • {spawn, Node::atom(), Tests} - 類似於 {spawn, Tests},但在給定的 Erlang 節點上執行指定的測試。

  • {timeout, Time::number(), Tests} - 在給定的逾時時間內執行指定的測試。時間以秒為單位;例如,60 表示一分鐘,而 0.1 表示十分之一秒。如果超過逾時時間,將強制終止未完成的測試。請注意,如果逾時時間設定在固定裝置周圍,則會包含設定和清除的時間,如果觸發逾時時間,則會突然終止整個固定裝置 (而不執行清除)。單一測試的預設逾時時間為 5 秒。

  • {inorder, Tests} - 以嚴格的順序執行指定的測試。另請參閱 {inparallel, Tests}。預設情況下,測試不會標記為 inorderinparallel,但可能會按照測試框架選擇的方式執行。

  • {inparallel, Tests} - 並行執行指定的測試 (如果可能)。另請參閱 {inorder, Tests}

  • {inparallel, N::integer(), Tests} - 類似於 {inparallel, Tests},但同時執行的子測試不超過 N 個。

固定裝置

"固定裝置" 是執行特定測試集所必需的某種狀態。EUnit 對固定裝置的支援讓您能夠輕鬆地為測試集在本機設定此類狀態,並在測試集完成後自動將其清除,無論結果如何 (成功、失敗、逾時等)。

為了使描述更簡單,我們先列出一些定義

| Setup | () -> (R::any()) | | -------------- | ------------------------------- | ---------------------------------------------- | ---------------------- | | SetupX | (X::any()) -> (R::any()) | | Cleanup | (R::any()) -> any() | | CleanupX | (X::any(), R::any()) -> any() | | Instantiator | ((R::any()) -> Tests) | {with, [AbstractTestFun::((any()) -> any())]} | | Where | local | spawn | {spawn, Node::atom()} |

(這些會在下方更詳細地說明。)

以下表示形式指定測試集的固定裝置處理

  • {setup, Setup, Tests | Instantiator}

  • {setup, Setup, Cleanup, Tests | Instantiator}

  • {setup, Where, Setup, Tests | Instantiator}

  • {setup, Where, Setup, Cleanup, Tests | Instantiator} - setup 會為執行所有指定的測試設定單一固定裝置,並在之後選擇性執行拆解。引數將在下方詳細說明。

  • {node, Node::atom(), Tests | Instantiator}

  • {node, Node::atom(), Args::string(), Tests | Instantiator} - node 的作用類似於 setup,但內建了一個行為:它會在測試期間啟動一個從屬節點。原子 Node 的格式應為 nodename@full.machine.name,而 Args 則是新節點的可選參數;詳情請參閱 slave:start_link/3

  • {foreach, Where, Setup, Cleanup, [Tests | Instantiator]}

  • {foreach, Setup, Cleanup, [Tests | Instantiator]}

  • {foreach, Where, Setup, [Tests | Instantiator]}

  • {foreach, Setup, [Tests | Instantiator]} - foreach 用於設定一個 fixture(測試夾具),並可選擇在之後拆除它,這個動作會針對每個指定的測試集重複執行。

  • {foreachx, Where, SetupX, CleanupX, Pairs::[{X::any(), ((X::any(), R::any()) -> Tests)}]}

  • {foreachx, SetupX, CleanupX, Pairs}

  • {foreachx, Where, SetupX, Pairs}

  • {foreachx, SetupX, Pairs} - foreachx 的作用類似於 foreach,但它使用一個配對列表,其中每個配對包含一個額外的參數 X 和一個擴展的實例化函式。

Setup 函式會在任何指定的測試執行之前執行,而 Cleanup 函式則會在不再執行任何指定的測試時執行,無論原因為何。Setup 函式不接收任何參數,並返回一個值,這個值會原封不動地傳遞給 Cleanup 函式。Cleanup 函式應執行任何必要的操作,並返回一些任意值,例如原子 ok。(SetupXCleanupX 函式類似,但會接收一個額外的參數:一些值 X,這取決於上下文。)如果沒有指定 Cleanup 函式,則會使用一個沒有任何作用的虛擬函式。

Instantiator 函式會接收與 Cleanup 函式相同的值,也就是 Setup 函式返回的值。它的行為應與產生器(請參閱Primitives)類似,並返回一個測試集,其測試已使用給定的值實例化。一個特殊的情況是語法 {with, [AbstractTestFun]},它代表一個實例化函式,該函式會將值分配給單元函式的列表;詳情請參閱Primitives{with, X, [...]}

Where 參數控制指定的測試如何執行。預設值為 spawn,表示目前的進程處理設定和拆解,而測試則在子進程中執行。{spawn, Node} 的作用類似於 spawn,但會在指定的節點上執行子進程。local 表示目前的進程將處理設定/拆解和執行測試 - 缺點是如果測試逾時導致進程被終止,則不會執行拆解;因此,對於像檔案操作這類持久性的夾具,請避免使用此設定。一般來說,只有在以下情況下才應使用 local

  • 設定/拆解需要由將執行測試的進程執行;
  • 如果進程被終止,則不需要執行進一步的拆解(也就是說,設定沒有影響到進程外部的狀態)

延遲產生器

有時,在測試開始之前不產生整個測試描述集可能會比較方便;例如,如果您想要產生大量的測試,而這些測試會佔用太多空間而無法一次全部保存在記憶體中。

編寫一個產生器相當容易,該產生器每次被呼叫時,若已完成則產生一個空列表,否則產生一個包含單一測試案例的列表,以及一個將產生其餘測試的新產生器。這示範了基本模式

   lazy_test_() ->
       lazy_gen(10000).

   lazy_gen(N) ->
       {generator,
        fun () ->
            if N > 0 ->
                   [?_test(...)
                    | lazy_gen(N-1)];
               true ->
                   []
            end
        end}.

當 EUnit 為了執行測試而遍歷測試表示時,在執行前一個測試之前,不會呼叫新的產生器來產生下一個測試。

請注意,使用輔助函式(例如上述的 lazy_gen/1 函式)來編寫這種遞迴產生器最容易。如果您不想讓函式命名空間雜亂,並且可以輕鬆編寫這類程式碼,也可以使用遞迴函式來編寫。