文章目录

Prolog 测试:plunit 框架

发布于 2026-04-06 03:25:42 · 浏览 10 次 · 评论 0 条

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 配合 assertionshould_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 提供了 setupcleanup 属性来处理这些需求。

setup 在每个测试用例执行前运行:

test(create_resource, [setup(init_resource), cleanup(free_resource)]) :-
    use_resource(Result),
    Result == ok.

这段代码中,init_resource 在测试前调用,free_resource 在测试后调用,无论测试成功还是失败。

如果某个资源需要在整个测试套件期间只初始化一次,使用 setupcleanup 结合一次性标记:

:- 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_1test_2 这种无意义的命名。好的命名如 empty_list_reverse_returns_empty_list 自解释了测试的目的。

测试应该保持独立。避免测试之间共享可变状态。如果必须共享,确保每个测试在执行前将状态恢复到已知的一致状态。

针对边界条件编写测试。空列表、单个元素列表、超大数值、特殊原子(如 []truefail)都是容易出错的地方。


常见问题与调试技巧

当测试失败时,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/2aggregate_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,几秒钟内就能获得反馈。这种快速反馈循环是保持代码质量的关键实践。

评论 (0)

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

扫一扫,手机查看

扫描上方二维码,在手机上查看本文