Prolog 测试:plunit 框架
为什么需要测试框架
Prolog 程序的调试并不像传统命令式语言那样直接。当你写了一个谓词,运行后得到的结果不符合预期,你可能需要反复调用、逐步追踪,才能定位问题所在。这种方式在小程序中还能应付,但随着程序规模扩大,逻辑复杂度提升,单纯依靠手动测试会变得越来越困难且不可靠。
plunit 是 Prolog 自带的单元测试框架,它将测试过程标准化、自动化。你可以用声明式的方式定义测试用例,让机器自动执行并报告结果。这不仅节省了重复劳动的时间,更重要的是建立了可重复执行的测试套件——当你修改代码后,只需运行一次测试,就能快速确认修改是否引入了新的错误。
加载与基本结构
plunit 是 SWI-Prolog 和 SICStus Prolog 自带的库,使用前需要先加载。在你的测试文件或主程序文件中添加以下指令:
:- use_module(library(plunit)).
plunit 的测试以模块为单位组织。每个测试套件(test suite)是一个命名的测试模块,包含若干测试用例。标准的项目结构通常是将测试代码放在单独的文件中,例如对于 my_module.pl,对应的测试文件命名为 my_module_tests.pl。
测试文件的基本结构如下:
:- module(my_module_tests, [test/1]).
:- use_module(library(plunit)).
:- use_module(my_module).
:- begin_tests(套件名称).
% 测试用例写在这里
:- end_tests(套件名称).
其中 套件名称 是一个原子(atom),用于标识这个测试套件。:- begin_tests/1 和 :- end_tests/1 是 plunit 的内置指令,分别标记测试套件的起始和结束位置。
编写第一个测试用例
测试用例使用 test/1 谓词定义。test/1 的参数是一个复合项,指定测试的名称和可选属性。一个最基本的测试用例只包含名称:
:- begin_tests(my_predicate).
test(add_one_plus_one_is_two) :-
add_one(1, Result),
Result == 2.
:- end_tests(my_predicate).
这个测试用例名为 add_one_plus_one_is_two,它调用 add_one(1, Result),然后检查 Result 是否等于 2。==/2 是 Prolog 的相等性断言(项完全匹配)。
运行测试需要调用 run_tests/1:
?- run_tests(my_predicate).
% PL-Unit: my_predicate
% .
% test passed
true.
如果测试失败,plunit 会输出详细的错误信息,包括失败的具体断言和调用栈。
断言类型详解
plunit 提供了多种断言谓词,以适应不同的测试场景。理解这些断言的语义区别,能帮助你写出更精确的测试。
相等性断言
==/2 检查两个项是否完全相同,不进行变量绑定:
test(term_equality) :-
X = foo(1, 2),
X == foo(1, 2).
如果 X 已经绑定为 foo(1, 2),这个测试通过;如果 X 还是未绑定状态,测试失败。
=/2(合一)则允许变量绑定:
test(term_unification) :-
X = foo(1, 2),
foo(1, 2) = X. % 这会成功,因为合一允许绑定
数值断言
对于数值比较,plunit 提供了专门的断言:
test(numeric_tests) :-
5 =:= 2 + 3, % 相等
5 =\= 2 * 2, % 不等
5 > 3, % 大于
5 < 7, % 小于
5 >= 5, % 大于等于
3 =< 5. % 小于等于
这些断言会对两侧表达式求值后进行比较,适用于整数和浮点数。
成员与列表断言
列表是 Prolog 中的核心数据结构,针对列表的断言使用频率很高:
test(member_check) :-
member(3, [1, 2, 3, 4, 5]).
如果 3 是列表 [1, 2, 3, 4, 5] 的成员,测试通过。
permutation/2 检查两个列表是否为彼此的排列:
test(list_permutation) :-
permutation([1, 2, 3], [3, 1, 2]).
排序断言
sorted/1 断言列表是按标准序排序的:
test(list_is_sorted) :-
sorted([1, 2, 2, 3, 5]).
这个测试在列表严格递增(或非递减)时通过。
抛出异常
对于应该抛出异常的谓词,使用 throw/1 配合 assertion 或 should_throw:
test(should_raise_error) :-
thrown_error(Error),
Error = error(evaluation_error(zero_divisor), _).
% 或者更简洁的写法:
test(divide_by_zero) :-
should_throw(error(evaluation_error(zero_divisor), _)) +
(X is 1/0).
设置与清理操作
很多测试需要前置条件或后续清理。plunit 提供了 setup 和 cleanup 属性来处理这些需求。
setup 在每个测试用例执行前运行:
test(create_resource, [setup(init_resource), cleanup(free_resource)]) :-
use_resource(Result),
Result == ok.
这段代码中,init_resource 在测试前调用,free_resource 在测试后调用,无论测试成功还是失败。
如果某个资源需要在整个测试套件期间只初始化一次,使用 setup 和 cleanup 结合一次性标记:
:- begin_tests(db_tests).
test(insert_record) :-
with_mutex(db_mutex, insert_record_impl).
test(read_record) :-
with_mutex(db_mutex, read_record_impl).
:- end_tests(db_tests).
对于需要创建临时文件或数据库的场景,setup 负责创建环境,cleanup 负责销毁,确保测试之间互不干扰。
测试参数与条件执行
test/1 的参数可以包含多个属性,控制测试的行为。
否定测试
fail 属性表示测试应该失败:
test(nonexistent_element, [fail]) :-
member(99, [1, 2, 3]).
这个测试验证 99 不在列表 [1, 2, 3] 中——如果 member(99, [1, 2, 3]) 成功(找到 99),测试反而失败。
条件跳过
option 属性允许根据条件跳过测试:
test(database_feature, [condition(db_available)]) :-
db_query(Result),
Result == expected.
当 db_available 谓词失败时,整个测试被跳过,适用于可选依赖或平台特定功能。
随机化测试顺序
random(true) 让同一测试套件中的用例以随机顺序执行,有助于发现测试之间的依赖关系:
test(order_independent, [random(true)]) :-
% 测试逻辑
true.
如果测试之间存在隐藏的状态依赖,随机顺序通常能更快暴露问题。
多重解测试
solve(X) 属性指定测试应该恰好找到 X 个解:
test(exactly_three_solutions, [solve(3)]) :-
member(X, [a, b, c, d, e]),
X \= d,
X \= e.
这个测试验证恰好找到 3 个满足条件的解。如果找到的解数量不等于 3,测试失败。
运行测试的几种方式
在 SWI-Prolog 中有多种运行测试的方法。
直接在 REPL 中加载测试文件并运行:
?- [my_module_tests].
true.
?- run_tests.
% PL-Unit: my_predicate
% .:
test add_one_plus_one_is_two
* Failure: add_one(1, Result)
| Result = 2
| But: got 3
...
false.
如果只想运行特定套件,传入套件名称:
?- run_tests(my_predicate).
使用 run_tests/0 运行所有已加载的测试套件。
在命令行中运行整个项目:
swipl -g "run_tests, halt" -l your_test_file.pl
这条命令加载测试文件、执行所有测试、然后退出。对于持续集成环境非常有用。
组织测试代码的最佳实践
良好的测试代码组织能让项目更易于维护。
将测试与实现分离。实现代码放在 src/ 目录,测试代码放在 tests/ 目录。主入口文件通过 make_tests/0 或类似谓词统一运行所有测试:
run_all_tests :-
ensure_loaded(library(plunit)),
run_tests.
每个测试用例应该有明确的意图。名称应该描述测试的场景和期望结果,避免 test_1、test_2 这种无意义的命名。好的命名如 empty_list_reverse_returns_empty_list 自解释了测试的目的。
测试应该保持独立。避免测试之间共享可变状态。如果必须共享,确保每个测试在执行前将状态恢复到已知的一致状态。
针对边界条件编写测试。空列表、单个元素列表、超大数值、特殊原子(如 []、true、fail)都是容易出错的地方。
常见问题与调试技巧
当测试失败时,plunit 会显示详细的追踪信息。仔细阅读错误输出,通常能直接定位问题所在。如果信息不够详细,可以在测试用例中添加 writeln/1 打印中间变量:
test(debugging_example) :-
compute_something(Input),
writeln('Result: '), writeln(Result),
Result == expected.
注意这些 writeln 只在测试失败时显示额外信息,成功时不会输出。
如果测试莫名其妙地通过或失败,检查变量命名。Prolog 中以大写字母开头的标识符是变量,如果拼写不一致,可能会导致意外的成功(变量未绑定时与任何值都匹配):
% 错误:变量未绑定
test(buggy) :-
foo(X, Y),
X == Y. % 如果 X 和 Y 都是变量,这总是成功
% 正确:绑定变量
test(correct) :-
foo(Bar, Baz),
Bar == Baz.
对于涉及浮点数的比较,使用近似相等而非精确相等:
test(floating_point) :-
computed_value(X),
abs(X - 0.1) < 1e-10.
直接用 == 比较浮点数通常会失败,因为浮点运算存在精度误差。
高级用法:测试生成式代码
如果你的谓词本身是非确定性的(返回多个解),测试需要验证所有解是否符合预期。forall/2 和 aggregate_all/3 是常用的工具:
test(all_solutions_valid) :-
forall(generator(X, Y), (valid(X), valid(Y))).
这段代码验证 generator/2 生成的每一个解都满足 valid/1。
aggregate_all/3 用于统计或验证聚合属性:
test(correct_solution_count) :-
aggregate_all(count, (member(X, [1,2,3]), X > 1), Count),
Count == 2.
这个测试验证列表中有两个大于 1 的元素。
如果需要逐个检查解并收集错误信息,可以使用 findall/3 配合断言:
test(validate_all_solutions) :-
findall(Issue, (solution(S), (invalid(S) -> Issue = S; true)), Issues),
Issues == [].
plunit 将测试从繁琐的手动验证中解放出来,让你能专注于代码逻辑本身。建立完善的测试套件后,修改代码时只需运行 run_tests/0,几秒钟内就能获得反馈。这种快速反馈循环是保持代码质量的关键实践。

暂无评论,快来抢沙发吧!