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 注意事项
⚠️ 测试陷阱
- 测试函数必须以
_test或_test_结尾才能被 EUnit 发现 - Common Test 的
init_per_suite必须返回 Config - 测试应该是独立的,不依赖执行顺序
- 避免测试中使用 timer:sleep(不确定时间)
- EUnit 测试文件名应为
*_tests.erl
💡 最佳实践
- 每个模块对应一个测试模块
- 测试名称描述行为(
test_divide_by_zero_returns_error) - 使用 setup/teardown 管理测试资源
- 关键业务逻辑使用 PropEr 做属性测试
- CI 中运行
rebar3 eunit && rebar3 ct