查看源码 EUnit - Erlang 的轻量级单元测试框架

EUnit 是一个 Erlang 的单元测试框架。它功能强大且灵活,易于使用,并且语法开销很小。

EUnit 基于由 Beck 和 Gamma 开创的面向对象语言单元测试框架的思想(以及 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 中编写单元测试非常容易。但是,有几种不同的编写方式,因此我们从最简单的开始

EUnit 将名称以..._test()结尾的函数识别为简单的测试函数 - 它不带任何参数,并且其执行要么成功(返回 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宏会很方便(有关详细信息,请参见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() 将会在该模块中运行所有定义的 EUnit 测试。您不需要为测试函数编写 -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 表达式和一个源行号中,将其转换为“测试对象”。从技术上讲,这与 {?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 宏。如果在包含 EUnit 头文件 *之前* 定义了 TEST(即使也定义了 NOTEST),则将在启用 EUnit 的情况下编译代码。

  • NOTEST - 只要在编译时 *禁用* EUnit,此宏始终定义为 (true,除非用户先前定义了另一个值)。(比较 TEST 宏。)

    此宏也可用于条件编译,但更常用于禁用测试:如果在包含 EUnit 头文件 *之前* 定义了 NOTEST,并且 *未* 定义 TEST,则将在禁用 EUnit 的情况下编译代码。另请参阅 禁用测试

  • NOASSERT - 如果定义了此宏,则在禁用测试时,断言宏将不起作用。请参阅断言宏。启用测试后,断言宏始终自动启用,并且无法禁用。

  • ASSERT - 如果定义了此宏,它将覆盖 NOASSERT 宏,强制断言宏始终启用,而不管其他设置如何。

  • 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(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 宏等效于使用 ClassPattern 分别为 errorexitthrowassertException

    示例

       ?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 字符作为换行符。

    此宏在 fixture 的设置和清理部分非常有用,例如,用于创建和删除文件或执行类似操作系统特定的任务,以确保测试系统了解任何故障。

    一个特定于 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 () -> ... end
       fun some_function/0
       fun some_module:some_function/0
  • 一个元组 {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 之前排序,而 S_(i+1) 的测试在之后排序。

因此,测试集的主要表示形式是深层列表,而一个简单测试对象可以被视为仅包含单个测试的测试集; 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 文件中找到的那样。生成的测试集由 Info 中的 modules 条目中列出的模块组成。

  • {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],但更简洁。另请参阅下面的Fixture

控制

以下表示形式控制测试的执行方式和位置

  • {spawn, Tests} - 在单独的子进程中运行指定的测试,而当前测试进程等待其完成。这对于需要全新的、隔离的进程状态的测试很有用。(请注意,EUnit 总是会自动启动至少一个这样的子进程;测试永远不会由调用者自己的进程执行。)

  • {spawn, Node::atom(), Tests} - 与 {spawn, Tests} 类似,但在给定的 Erlang 节点上运行指定的测试。

  • {timeout, Time::number(), Tests} - 在给定的超时时间内运行指定的测试。时间以秒为单位;例如,60 表示一分钟,而 0.1 表示 1/10 秒。如果超过超时时间,将强制终止未完成的测试。请注意,如果在 fixture 周围设置了超时,它包括设置和清理的时间,如果触发了超时,则整个 fixture 将被突然终止(不运行清理)。单个测试的默认超时时间为 5 秒。

  • {inorder, Tests} - 以严格的顺序运行指定的测试。另请参阅 {inparallel, Tests}。默认情况下,测试不会标记为 inorderinparallel,但可以按照测试框架选择的方式执行。

  • {inparallel, Tests} - 并行运行指定的测试(如果可能)。另请参阅 {inorder, Tests}

  • {inparallel, N::integer(), Tests} - 与 {inparallel, Tests} 类似,但同时运行的子测试不超过 N 个。

Fixture

“fixture” 是运行特定测试集所必需的某种状态。EUnit 对 fixture 的支持可以轻松地为测试集本地设置此类状态,并在测试集完成时自动将其拆除,而与结果(成功、失败、超时等)无关。

为了使描述更简单,我们首先列出一些定义

| 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 用于为每个指定的测试集重复设置固定装置并可选地在之后拆卸它。

  • {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 函数)编写这种递归生成器最容易。如果您不喜欢使函数命名空间混乱并且习惯于编写此类代码,也可以使用递归的 fun 来编写。