1. 单元测试
一句话概括:
plunit—— 把谓词包在begin_tests/end_tests里,run_tests一键验证。再也不用人肉 debug。学完这章,你能用 plunit 给你的 Prolog 代码写测试,改代码不再靠感觉。 前置:高阶谓词, 模块化 | 难度:★★ | 后续:—
代码改出 bug 了怎么办?
你肯定遇到过这个场景:加了一个功能,原来跑得好好的程序突然不对了。看了十分钟代码——"逻辑没问题啊"。然后又看了十分钟——"擦,这里写错了"。
靠眼睛检查代码,就像用鼻子听音乐——不是不行,但工具错了。
我们需要单元测试。
1.1. 从最痛苦的例子开始
假设你写了阶乘:
fact(0, 1).
fact(N, F) :-
N1 is N - 1,
fact(N1, F1),
F is N * F1.
看起来没问题对吧?跑了几个例子也没报错。然后某天你改成尾递归优化版:
fact_tail(N, F) :-
fact_acc(N, 1, F).
fact_acc(0, Acc, Acc).
fact_acc(N, Acc, F) :-
N1 is N - 1,
Acc1 is Acc * N,
fact_acc(N1, Acc1, F).
调用 fact_tail(5, X) — X = 120,对的。够了?不,你漏了边界条件——fact_tail(0, X) 是什么?
……你可能一开始根本没想到要检查这个。
机器检查和人检查的区别就在这:人只会检查自己想到的,机器会检查你写下的所有断言。
1.2. plunit:Prolog 的测试框架
SWI-Prolog 自带 plunit。不用装任何东西。
最简结构:
:- begin_tests(math).
test(add) :- 1 + 1 =:= 2.
test(sub, [fail]) :- 1 - 1 =:= 3.
:- end_tests(math).
保存 test_math.pl,加载运行:
?- run_tests.
% PL-Unit: math .. done
% 2 tests passed
true.
看到 done 和 passed 了吗?这就是安全感。
1.3. test/1 和 test/3
test(Name) — body 必须成功(默认断言)。
test(Name, Options) — 带选项:
| 选项 | 含义 |
|---|---|
fail |
预期失败 |
true |
预期成功(默认) |
nondet |
允许多解 |
forall(G) |
为 G 每个解运行测试 |
blocked |
跳过(标记为阻塞) |
setup(G) |
测试前执行 |
cleanup(G) |
测试后执行 |
time(T) |
超时限制 |
:- begin_tests(list).
test(reverse) :-
reverse([1,2,3], [3,2,1]).
test(member_success) :-
member(a, [a,b,c]).
test(member_fail, [fail]) :-
member(x, []).
test(member_all, [nondet]) :-
member(X, [1,2,3]).
:- end_tests(list).
1.4. 测试之前章节的谓词
拿 c6 的阶乘举例子:
:- begin_tests(factorial).
test(base) :-
factorial_tail(0, 1).
test(small) :-
factorial_tail(3, 6).
test(large, [time(0.1)]) :-
factorial_tail(1000, _).
test(negative, [fail]) :-
factorial_tail(-1, _).
test(edge_zero) :-
factorial_tail(0, 1).
:- end_tests(factorial).
[time(0.1)] 是说这个测试不能超过 0.1 秒——防止尾递归写漏了导致栈溢出。
[fail] 表示这个测试预期失败——负数的阶乘不应该有解。
1.5. forall:批量扫一遍
测试单个点不够,用 forall 批量扫:
:- begin_tests(double).
test(double_all, [forall(between(1, 100, N))]) :-
D is N * 2,
D =:= N + N.
:- end_tests(double).
1 到 100,全部验证 N*2 == N+N。一个测试顶一百个。
1.6. setup/cleanup
测试数据库操作时,每个用例前后需要清理现场:
:- dynamic db/1.
:- begin_tests(db).
setup :-
assert(db(a)),
assert(db(b)).
cleanup :-
retractall(db(_)).
test(query_all, [set(Xs = [a,b])]) :-
findall(X, db(X), Xs).
:- end_tests(db).
setup 和 cleanup 在每个 test 前后自动跑——不会让测试互相污染。
1.7. 自动化:run_tests
1.7.1. 运行全部
?- run_tests.
% PL-Unit: list .. done
% PL-Unit: factorial .. done
1.7.2. 指定测试集
?- run_tests(math).
% PL-Unit: math .. done
1.7.3. 加载时自动跑
在文件末尾加一行:
:- run_tests.
加载文件时自动执行所有测试。
1.7.4. Make 集成
test_all :-
format("Running all tests...~n"),
run_tests.
命令行调用:
swipl -q -t test_all -s test_suite.pl
1.7.5. CI 集成(GitHub Actions)
# .github/workflows/ci.yml
- name: Run Prolog tests
run: swipl -q -t "run_tests" -s tests/suite.pl
每次 push 自动跑全部测试——改了代码?先过测试再说。
1.8. 实战:测试全书谓词
写一个统一测试套件,把前面章节的核心谓词都覆盖:
%% 引入各模块
:- use_module('../code/hello/fib').
:- use_module('../drafts/hello-pl/geometry').
%% 测试套件
:- begin_tests(hello_pl_suite).
clear_m :- retractall(m(_)).
%% c1: Hello world
test(hello_world) :-
with_output_to(string(S), hello),
S == "Hello World!\n".
%% c6: Fibonacci with tabling
test(fib_base) :-
fib(0, 1), fib(1, 1).
test(fib_small) :-
fib(10, 89).
test(fib_large, [time(1)]) :-
fib(1000, _).
%% c7: Module - geometry
test(area_circle) :-
geometry:area(circle, 3, A),
abs(A - 28.274) < 0.001.
test(area_rect) :-
geometry:area(rect, (3,4), 12).
%% c8: Web server (仅测试 handler,不启动服务)
test(web_root, [blocked]) :-
true.
%% c9: 数独求解
test(sudoku_solve) :-
Puzzle = [[5,3, _, _,7, _, _, _, _]
,[6, _, _,1,9,5, _, _, _]
,[_,9,8, _, _, _, _,6, _]
,[8, _, _, _,6, _, _, _,3]
,[4, _, _,8, _,3, _, _,1]
,[7, _, _, _,2, _, _, _,6]
,[_,6, _, _, _, _,2,8, _]
,[_, _, _,4,1,9, _, _,5]
,[_, _, _, _,8, _, _,7,9]],
sudoku(Puzzle),
Puzzle = [[5,3,4,6,7,8,9,1,2],
[6,7,2,1,9,5,3,4,8],
[1,9,8,3,4,2,5,6,7],
[8,5,9,7,6,1,4,2,3],
[4,2,6,8,5,3,7,9,1],
[7,1,3,9,2,4,8,5,6],
[9,6,1,5,3,7,2,8,4],
[2,8,7,4,1,9,6,3,5],
[3,4,5,2,8,6,1,7,9]].
%% c5: Meta-interpreter
:- use_module(meta_interpreter).
test(prove_true) :-
prove(true).
test(prove_atom, [setup(clear_m), cleanup(clear_m)]) :-
assertz(m(foo)),
prove(m(foo)).
:- end_tests(hello_pl_suite).
:- run_tests.
1.9. 结果怎么看
成功时:
?- run_tests.
% PL-Unit: hello_pl_suite ......... done
% 9 tests passed
失败时:
?- run_tests.
% PL-Unit: hello_pl_suite . f ....
% ERROR: .../test: test fib_base: assertion failed
% 8 tests passed, 1 failed
看到 . f . 中间那个 f 了吗——就是你的 bug 被揪出来了。
1.10. 总结
:- begin_tests(Name)/:- end_tests(Name)定义测试集test(Name)或test(Name, Options)写用例fail/nondet/forall/setup/cleanup/blocked/time控制行为:- run_tests.自动执行- 配合 CI 实现每次提交自动跑
有了测试,改代码不再心慌。你改了一行,机器替你跑一百个断言——谁敢说这不好?
1.11. 参考
- The Power of Prolog — Testing
- SWI-Prolog manual: plunit