强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

17 - 测试

第 17 章:测试 — EUnit、Common Test、PropEr

测试是构建可靠系统的关键。本章学习 Erlang 的三大测试框架:EUnit、Common Test 和 PropEr。


17.1 EUnit

17.1.1 基本用法

%% math_utils.erl
-module(math_utils).
-export([add/2, factorial/1]).
-include_lib("eunit/include/eunit.hrl").  %% 引入 EUnit

add(A, B) -> A + B.

factorial(0) -> 1;
factorial(N) when N > 0 -> N * factorial(N - 1).

%% ===== 测试 =====

add_test() ->
    ?assertEqual(5, add(2, 3)),
    ?assertEqual(0, add(-1, 1)),
    ?assertEqual(0, add(0, 0)).

factorial_test() ->
    ?assertEqual(1, factorial(0)),
    ?assertEqual(1, factorial(1)),
    ?assertEqual(120, factorial(5)).

factorial_negative_test() ->
    ?assertError(function_clause, factorial(-1)).

17.1.2 常用断言宏

作用
?assertEqual(Expected, Actual)值相等
?assertNotEqual(Val1, Val2)值不等
?assertMatch(Pattern, Expr)模式匹配
?assertNotMatch(Pattern, Expr)不匹配
?assert(Expr)表达式为 true
?assertNot(Expr)表达式为 false
?assertError(Pattern, Expr)抛出 error
?assertExit(Pattern, Expr)抛出 exit
?assertThrow(Pattern, Expr)抛出 throw
?assertException(Class, Pattern, Expr)通用异常
_test()测试函数名后缀
_test_()测试生成器后缀

17.1.3 测试生成器

%% 使用 _test_ 后缀自动生成测试
add_test_() ->
    [
        ?_assertEqual(5, add(2, 3)),
        ?_assertEqual(0, add(-1, 1)),
        ?_assertEqual(4, add(2, 2))
    ].

%% 测试用例描述
factorial_test_() ->
    [
        {"0! = 1", ?_assertEqual(1, factorial(0))},
        {"1! = 1", ?_assertEqual(1, factorial(1))},
        {"5! = 120", ?_assertEqual(120, factorial(5))}
    ].

17.1.4 setup 和 teardown

%% 测试前 setup,测试后 teardown
setup_test_() ->
    {setup,
     fun() ->
         %% Setup: 创建临时资源
         ets:new(test_table, [set, public, named_table])
     end,
     fun(_State) ->
         %% Teardown: 清理
         ets:delete(test_table)
     end,
     fun(_State) ->
         %% 测试用例
         [
            ?_assertEqual(0, ets:info(test_table, size))
         ]
     end}.

17.1.5 运行 EUnit

# rebar3 运行
rebar3 eunit

# 运行特定模块
rebar3 eunit --module=math_utils

# 在 Shell 中运行
eunit:test(math_utils).

17.2 Common Test

17.2.1 基本结构

%% my_SUITE.erl
-module(my_SUITE).
-include_lib("common_test/include/ct.hrl").

%% 导出
-export([all/0, groups/0, init_per_suite/1, end_per_suite/1,
         init_per_testcase/2, end_per_testcase/2]).

%% 测试用例
-export([test_add/1, test_factorial/1, test_error/1]).

%% 必须导出:返回测试用例列表
all() ->
    [test_add, test_factorial, test_error].

%% 可选:测试分组
groups() ->
    [{math_tests, [test_add, test_factorial]},
     {error_tests, [test_error]}].

%% Suite 级别 setup
init_per_suite(Config) ->
    ct:log("Starting test suite~n"),
    [{started, true} | Config].

end_per_suite(_Config) ->
    ct:log("Test suite finished~n"),
    ok.

%% 每个测试用例的 setup
init_per_testcase(test_add, Config) ->
    [{operation, add} | Config];
init_per_testcase(_TestCase, Config) ->
    Config.

end_per_testcase(_TestCase, _Config) ->
    ok.

%% ===== 测试用例 =====

test_add(Config) ->
    true = proplists:get_value(started, Config),
    5 = my_module:add(2, 3),
    0 = my_module:add(-1, 1),
    {comment, "Add tests passed"}.

test_factorial(_Config) ->
    1 = my_module:factorial(0),
    120 = my_module:factorial(5),
    ok.

test_error(_Config) ->
    try my_module:factorial(-1) of
        _ -> {fail, "Should have thrown error"}
    catch
        error:function_clause -> ok
    end.

17.2.2 运行 Common Test

# rebar3 运行
rebar3 ct

# 运行特定 suite
rebar3 ct --suite=my_SUITE

# 查看报告
# 报告在 _build/test/logs/ 目录下

17.3 PropEr 属性测试

17.3.1 什么是属性测试?

属性测试定义程序应该满足的性质,由框架自动生成测试数据:

%% reverse_test.erl
-module(reverse_test).
-include_lib("proper/include/proper.hrl").

%% 属性:反转两次等于原列表
prop_reverse_reverse() ->
    ?FORALL(List, list(integer()),
            lists:reverse(lists:reverse(List)) =:= List).

%% 属性:排序后列表有序
prop_sort_ordered() ->
    ?FORALL(List, list(integer()),
            is_sorted(lists:sort(List))).

is_sorted([]) -> true;
is_sorted([_]) -> true;
is_sorted([A, B | Rest]) -> A =< B andalso is_sorted([B | Rest]).

%% 自定义生成器
prop_even_sum() ->
    ?FORALL({A, B}, {integer(), integer()},
            (A + B) rem 2 =:= 0 orelse true).  %% 总是 true(trivial)

17.3.2 运行 PropEr

# rebar3 配置
# rebar.config
{profiles, [
    {test, [{deps, [{proper, "1.4.0"}]}]}
]}.

# 运行
rebar3 proper

17.4 测试最佳实践

17.4.1 测试金字塔

          /\
         /  \  集成测试 (Common Test)
        /    \
       /------\
      /        \  单元测试 (EUnit)
     /----------\
    /            \  属性测试 (PropEr)
   /--------------\

17.4.2 项目测试组织

src/
├── my_module.erl
└── my_server.erl
test/
├── my_module_tests.erl    ← EUnit 测试
├── my_server_tests.erl    ← EUnit 测试
├── my_integration_SUITE.erl ← Common Test
└── proper_tests.erl       ← PropEr

17.5 实战:完整测试示例

%% calculator.erl
-module(calculator).
-export([add/2, subtract/2, multiply/2, divide/2]).

add(A, B) -> A + B.
subtract(A, B) -> A - B.
multiply(A, B) -> A * B.
divide(_A, 0) -> {error, division_by_zero};
divide(A, B) -> {ok, A / B}.
%% calculator_tests.erl
-module(calculator_tests).
-include_lib("eunit/include/eunit.hrl").

%% add 测试
add_test_() ->
    [
        ?_assertEqual(5, calculator:add(2, 3)),
        ?_assertEqual(0, calculator:add(-1, 1)),
        ?_assertEqual(3.5, calculator:add(1.5, 2.0))
    ].

%% divide 测试
divide_test_() ->
    [
        ?_assertEqual({ok, 2.0}, calculator:divide(6, 3)),
        ?_assertEqual({error, division_by_zero}, calculator:divide(1, 0))
    ].

%% property-based testing (if proper is available)
prop_add_commutative() ->
    ?FORALL({A, B}, {number(), number()},
            calculator:add(A, B) =:= calculator:add(B, A)).

17.6 Mock 和 Meck

%% 使用 Meck 库模拟外部依赖
%% rebar.config: {deps, [{meck, "0.9.2"}]}.

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

setup() ->
    meck:new(http_client, [non_strict]),  %% 模拟 http_client 模块
    meck:expect(http_client, get, fun(_Url) ->
        {ok, 200, <<"mocked response">>}
    end).

teardown(_) ->
    meck:unload(http_client).

my_server_test_() ->
    {setup,
     fun setup/0,
     fun teardown/1,
     fun(_) ->
        ?_assertEqual({ok, <<"mocked response">>},
                      my_server:fetch_data("http://example.com"))
     end}.

17.7 注意事项

⚠️ 测试陷阱

  1. 测试函数必须以 _test_test_ 结尾才能被 EUnit 发现
  2. Common Test 的 init_per_suite 必须返回 Config
  3. 测试应该是独立的,不依赖执行顺序
  4. 避免测试中使用 timer:sleep(不确定时间)
  5. EUnit 测试文件名应为 *_tests.erl

💡 最佳实践

  1. 每个模块对应一个测试模块
  2. 测试名称描述行为(test_divide_by_zero_returns_error
  3. 使用 setup/teardown 管理测试资源
  4. 关键业务逻辑使用 PropEr 做属性测试
  5. CI 中运行 rebar3 eunit && rebar3 ct

17.8 扩展阅读


上一章:16 - 错误处理 下一章:18 - 分布式