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

Erlang/OTP 完全指南 / 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 - 分布式