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.

看到 donepassed 了吗?这就是安全感。

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).

setupcleanup 在每个 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. 参考

Copyright © zhzluke96 2020 all right reserved,powered by Gitbook该文件修订时间: 2026-06-30 16:08

results matching ""

    No results matching ""