Course Builder个人学习工作台 · 本地模式
OCaml:从类型推导到可测试的命令行工具

14 · 测试:验证行为与边界条件

目标:为公开解析接口建立一组可重复运行、能够发现真实回归的测试。

测试不应只是把实现重写一遍。更有价值的是固定用户依赖的行为:空文件允许、顺序保留、原始行号准确、范围端点合法、坏输入有解释、总量正确。内部以后可以改递归写法,只要这些行为不变,测试就应该继续通过。

抽象类型不会妨碍测试。我们通过 parse_text 创建记录,再通过公开访问函数观察它们。这样测试约束的是使用方式,没有依赖 record 在内存里的样子。

实验:一个不依赖第三方框架的测试程序

所有命令在 studylog/ 执行:

mkdir -p test

创建 test/dune:

(test
 (name core_test)
 (libraries studylog))

创建 test/core_test.ml:

module Core = Studylog.Core

let expect_ok text =
  match Core.parse_text text with
  | Ok sessions -> sessions
  | Error message -> failwith ("unexpected error: " ^ message)

let expect_error text expected =
  match Core.parse_text text with
  | Error actual -> assert (actual = expected)
  | Ok _ -> failwith "expected an error"

let () =
  assert (expect_ok "" = []);
  assert (expect_ok "\n \n\t" = []);
  let sessions = expect_ok " OCaml | 25 \r\n\nTypes|35\r\n" in
  let pairs = List.map (fun s -> (Core.topic s, Core.minutes s)) sessions in
  assert (pairs = [("OCaml", 25); ("Types", 35)]);
  assert (Core.total sessions = 60);
  assert (Core.summary sessions = "sessions=2 minutes=60");
  assert (Core.total (expect_ok "A|1\nB|1440") = 1441);
  expect_error "A|1\n\nB|bad" "line 3: minutes must be an integer";
  expect_error "A|0" "line 1: minutes must be between 1 and 1440";
  expect_error "A|1441" "line 1: minutes must be between 1 and 1440";
  expect_error " |25" "line 1: empty topic";
  expect_error "A|2|3" "line 1: expected topic|minutes";
  expect_error "A|bad\nB|0" "line 1: minutes must be an integer";
  print_endline "core tests passed"

module Core = Studylog.Core 是模块别名,缩短重复路径,没有复制模块状态。两个辅助函数把失败变成测试进程的失败;它们不属于产品代码。

先预测忘记 List.rev 时哪个断言会失败,再运行:

dune runtest

首次执行可观察到:

core tests passed

Dune 可能在外层显示测试名或日志前缀;程序本身打印上面这一行。输入未变时,Dune 会复用已成功的结果,第二次运行可能没有新输出。需要重新执行时用:

dune runtest --force

证明测试不是装饰

  1. 临时把 Core.parse_text 中的 Ok (List.rev acc) 改为 Ok acc。
  2. 执行 dune runtest,应失败在 pairs 的顺序断言。只检查长度或总量的测试发现不了这个错误。
  3. 恢复实现,再跑测试。若“破坏后也通过”,先确认改的是 lib/core.ml,并确认测试真的运行了。

独立练习:补充三个行为:空输入的 summary 是 sessions=0 minutes=0;主题大小写保留;仅含分钟空白时返回整数错误。不要通过修改实现让测试跳过失败。

参考测试

在测试文件现有 let () 之后追加:

let () =
  assert (Core.summary (expect_ok "") = "sessions=0 minutes=0");
  let sessions = expect_ok "OCaml|1\nocaml|2" in
  assert (List.map Core.topic sessions = ["OCaml"; "ocaml"]);
  expect_error "A|  " "line 1: minutes must be an integer";
  print_endline "extra core tests passed"

dune runtest --force 应出现两条成功消息。本课练习测试可以保留,因为它们不改变公开接口。

若看到 Assert_failure,根据文件与行号定位具体断言,先比较输入和期望值,不要直接删断言。本课程使用默认开发构建来运行断言测试;不要添加 -noassert,否则部分验收逻辑会被移除。如果 Dune 报告入口缺失,确认配置中的 core_test 对应 core_test.ml。

本课结束新增 test/dune、test/core_test.ml。以后修改接口或解析逻辑后先运行这些测试。

参考资料