OCaml 教程 / 测试框架 Alcotest/OUnit
测试框架 Alcotest/OUnit
测试是软件工程的核心实践。OCaml 有多个优秀的测试框架,本节覆盖 OUnit、Alcotest 和 QCheck。
OUnit 基础
OUnit 是 OCaml 的 xUnit 风格测试框架。
opam install ounit2
(* test_math.ml *)
open OUnit2
let test_add _ =
assert_equal 3 (1 + 2)
let test_subtract _ =
assert_equal 2 (5 - 3)
let test_multiply _ =
assert_equal 12 (3 * 4)
let test_divide _ =
assert_equal 3 (6 / 2)
let test_divide_by_zero _ =
assert_raises (Division_by_zero) (fun () -> 1 / 0)
let suite =
"数学运算" >::: [
"加法" >:: test_add;
"减法" >:: test_subtract;
"乘法" >:: test_multiply;
"除法" >:: test_divide;
"除零异常" >:: test_divide_by_zero;
]
let () = run_test_tt_main suite
| 函数 | 说明 | 示例 |
|---|---|---|
assert_equal | 等值断言 | assert_equal 3 (1+2) |
assert_bool | 布尔断言 | assert_bool "positive" (x > 0) |
assert_raises | 异常断言 | assert_raises ... (fun () -> ...) |
>:: | 测试用例 | "name" >:: test_fn |
>::: | 测试套件 | "name" >::: [tests] |
Alcotest 框架
Alcotest 提供更友好的测试输出和更多功能。
opam install alcotest
(* 基本测试 *)
let test_hello () =
let greeting = "Hello, World!" in
Alcotest.(check string) "相同字符串" "Hello, World!" greeting
let test_numbers () =
Alcotest.(check int) "加法" 3 (1 + 2);
Alcotest.(check int) "乘法" 12 (3 * 4)
let test_floats () =
Alcotest.(check (float 0.001)) "近似相等" 3.14159 (2.0 *. asin 1.0)
let test_bools () =
Alcotest.(check bool) "真值" true (1 > 0)
let test_lists () =
Alcotest.(check (list int)) "列表相等" [1;2;3] (List.init 3 (fun i -> i + 1))
let test_options () =
Alcotest.(check (option int)) "有值" (Some 42) (Some 42);
Alcotest.(check (option int)) "无值" None None
let test_results () =
let ok_check = Alcotest.result Alcotest.int Alcotest.string in
Alcotest.(check ok_check) "成功" (Ok 42) (Ok 42);
Alcotest.(check ok_check) "失败" (Error "oops") (Error "oops")
let () =
Alcotest.run "我的测试" [
"基础测试", [
Alcotest.test_case "问候" `Quick test_hello;
Alcotest.test_case "数字" `Quick test_numbers;
Alcotest.test_case "浮点" `Quick test_floats;
Alcotest.test_case "布尔" `Quick test_bools;
];
"集合测试", [
Alcotest.test_case "列表" `Quick test_lists;
Alcotest.test_case "选项" `Quick test_options;
Alcotest.test_case "结果" `Quick test_results;
];
]
| 测试速度 | 说明 | 使用场景 |
|---|---|---|
Quick | 快速测试 | 大多数单元测试 |
Slow | 慢速测试 | 集成测试、IO 测试 |
💡 提示:Alcotest 自动显示彩色输出,失败时会高亮显示期望值和实际值的差异。
测试组织
(* 按模块组织测试 *)
(* lib/math.ml *)
let add a b = a + b
let sub a b = a - b
let mul a b = a * b
let div a b = if b = 0 then raise Division_by_zero else a / b
(* test/test_math.ml *)
open Lib.Math
let test_add () =
Alcotest.(check int) "0+0" 0 (add 0 0);
Alcotest.(check int) "1+2" 3 (add 1 2);
Alcotest.(check int) "负数" (-1) (add 1 (-2))
let test_sub () =
Alcotest.(check int) "5-3" 2 (sub 5 3)
let test_mul () =
Alcotest.(check int) "3*4" 12 (mul 3 4)
let test_div () =
Alcotest.(check int) "6/2" 3 (div 6 2)
let test_div_zero () =
Alcotest.check_raises "除零" Division_by_zero (fun () -> ignore (div 1 0))
let suite = [
"add", [
Alcotest.test_case "基础" `Quick test_add;
];
"sub", [
Alcotest.test_case "基础" `Quick test_sub;
];
"mul", [
Alcotest.test_case "基础" `Quick test_mul;
];
"div", [
Alcotest.test_case "基础" `Quick test_div;
Alcotest.test_case "除零" `Quick test_div_zero;
];
]
(* test/main.ml *)
let () =
Alcotest.run "项目测试" (
Test_math.suite @
Test_string.suite @
Test_list.suite
)
# dune 文件
(* test/dune *)
(test
(name main)
(libraries alcotest lib))
测试套件
(* 带 setup/teardown 的测试套件 *)
let with_temp_file f =
let filename = Filename.temp_file "test" ".txt" in
Fun.protect ~finally:(fun () -> Sys.remove filename)
(fun () -> f filename)
let with_temp_dir f =
let dir = Filename.temp_file "test" "_dir" in
Sys.remove dir; (* 删除文件,创建同名目录 *)
Unix.mkdir dir 0o755;
Fun.protect ~finally:(fun () ->
let _ = Sys.command (Printf.sprintf "rm -rf %s" dir) in ()
) (fun () -> f dir)
let test_file_write () =
with_temp_file (fun filename ->
let oc = open_out filename in
output_string oc "hello";
close_out oc;
let ic = open_in filename in
let content = input_line ic in
close_in ic;
Alcotest.(check string) "文件内容" "hello" content
)
let test_file_read_nonexistent () =
Alcotest.check_raises "文件不存在" (Sys_error "")
(fun () -> ignore (open_in "/nonexistent/file.txt"))
(* 数据库测试套件 *)
let with_test_db f =
let db = create_test_db () in
Fun.protect ~finally:(fun () -> destroy_test_db db)
(fun () -> f db)
let test_user_crud () =
with_test_db (fun db ->
let user = create_user db "Alice" "[email protected]" in
Alcotest.(check string) "用户名" "Alice" user.name;
let found = find_user db user.id in
Alcotest.(check (option user_testable)) "查找" (Some user) found;
delete_user db user.id;
let deleted = find_user db user.id in
Alcotest.(check (option user_testable)) "已删除" None deleted
)
⚠️ 注意:始终在 finally 中清理资源(临时文件、数据库连接等),避免测试污染。
属性测试(QCheck)
QCheck 是 OCaml 的属性测试库(类似 Haskell QuickCheck)。
opam install qcheck-alcotest
open QCheck2
(* 生成器 *)
let gen_int = Gen.int_range (-1000) 1000
let gen_string = Gen.string_size (Gen.int_range 1 100) Gen.char
let gen_list = Gen.list_size (Gen.int_range 0 50) gen_int
(* 自定义生成器 *)
type tree = Leaf | Node of tree * int * tree
let rec gen_tree size =
if size <= 0 then Gen.pure Leaf
else
Gen.frequency [
1, Gen.pure Leaf;
3, Gen.bind (gen_tree (size / 2)) (fun left ->
Gen.bind gen_int (fun v ->
Gen.bind (gen_tree (size / 2)) (fun right ->
Gen.pure (Node (left, v, right)))));
]
(* 属性测试 *)
let test_reverse_reverse =
Test.make ~name:"reverse(reverse(l)) = l"
(list int)
(fun l -> List.rev (List.rev l) = l)
let test_sort_idempotent =
Test.make ~name:"排序是幂等的"
(list int)
(fun l ->
let sorted = List.sort compare l in
List.sort compare sorted = sorted)
let test_sort_length =
Test.make ~name:"排序保持长度"
(list int)
(fun l ->
List.length (List.sort compare l) = List.length l)
let test_string_length =
Test.make ~name:"字符串长度非负"
string
(fun s -> String.length s >= 0)
let test_append_length =
Test.make ~name:"append 长度之和"
(pair (list int) (list int))
(fun (l1, l2) ->
List.length (l1 @ l2) = List.length l1 + List.length l2)
(* 使用 Alcotest 运行 QCheck *)
let () =
let open Alcotest in
run "QCheck 测试" [
"列表属性", [
QCheck_alcotest.to_alcotest test_reverse_reverse;
QCheck_alcotest.to_alcotest test_sort_idempotent;
QCheck_alcotest.to_alcotest test_sort_length;
QCheck_alcotest.to_alcotest test_append_length;
];
"字符串属性", [
QCheck_alcotest.to_alcotest test_string_length;
];
]
| QCheck 组件 | 说明 |
|---|---|
Test.make | 创建属性测试 |
Gen.* | 数据生成器 |
list int | 生成 int 列表 |
pair a b | 生成 (a, b) 元组 |
Print.* | 反例打印函数 |
💡 提示:属性测试擅长发现边界情况。手动编写测试时容易忽略空列表、负数、超长字符串等边界条件。
Fuzz 测试
(* 简单的 Fuzz 测试框架 *)
let fuzz ~name ~gen ~f ~iterations =
Printf.printf "Fuzz: %s (%d iterations)\n" name iterations;
for i = 1 to iterations do
let input = gen () in
try
ignore (f input)
with exn ->
Printf.printf " FAIL at iteration %d: %s\n" i (Printexc.to_string exn);
(* 可以将失败用例保存到文件 *)
let oc = open_out (Printf.sprintf "fuzz_failure_%d.txt" i) in
Printf.fprintf oc "Input: %s\n" (Printexc.to_string input);
close_out oc
done;
Printf.printf " PASS\n"
(* JSON 解析器 fuzz *)
let fuzz_json_parser () =
fuzz ~name:"JSON 解析器"
~gen:(fun () ->
(* 生成随机字符串 *)
let len = Random.int 1000 in
let buf = Buffer.create len in
for _ = 1 to len do
Buffer.add_char buf (Char.chr (Random.int 256))
done;
Buffer.contents buf)
~f:(fun s ->
try ignore (Yojson.Safe.from_string s)
with Yojson.Json_error _ -> ())
~iterations:10000
⚠️ 注意:Fuzz 测试不应因合法的解析错误而失败。使用 try...with 捕获预期的异常。
Mock 与存根
(* Mock 模块签名 *)
module type USER_SERVICE = sig
val get_user : int -> string option
val create_user : string -> int
end
(* 真实实现 *)
module RealUserService : USER_SERVICE = struct
let get_user id =
(* 从数据库查询 *)
Some (Printf.sprintf "User_%d" id)
let create_user name =
(* 插入数据库 *)
Hashtbl.hash name
end
(* Mock 实现 *)
module MockUserService : USER_SERVICE = struct
let users = Hashtbl.create 16
let next_id = ref 1
let get_user id = Hashtbl.find_opt users id
let create_user name =
let id = !next_id in
incr next_id;
Hashtbl.add users id name;
id
end
(* 使用 Mock 测试 *)
let test_create_and_get_user () =
let module Service = MockUserService in
let id = Service.create_user "Alice" in
let user = Service.get_user id in
Alcotest.(check (option string)) "创建的用户" (Some "Alice") user
(* 存根模块 *)
module StubLogger = struct
let messages = ref []
let log msg = messages := msg :: !messages
let clear () = messages := []
let get_messages () = List.rev !messages
end
let test_logging () =
StubLogger.clear ();
my_function_that_logs ();
let messages = StubLogger.get_messages () in
Alcotest.(check int) "日志条数" 2 (List.length messages)
代码覆盖率(bisect_ppx)
opam install bisect_ppx
(* dune 文件 *)
(*
(library
(name mylib)
(preprocess (pps bisect_ppx))
(libraries ...))
*)
(* 运行测试并收集覆盖率 *)
let () =
Bisect.Runtime.write_coverage_data ()
# 生成覆盖率报告
BISECT_ENABLE=yes dune test
bisect-ppx-report html
bisect-ppx-report summary
# 打开报告
open _coverage/index.html
CI 集成
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup OCaml
uses: ocaml/setup-ocaml@v2
with:
ocaml-compiler: 5.1
- name: Install dependencies
run: opam install . --deps-only --with-test
- name: Build
run: opam exec -- dune build
- name: Test
run: opam exec -- dune test
- name: Coverage
run: |
opam install bisect_ppx
BISECT_ENABLE=yes opam exec -- dune test
opam exec -- bisect-ppx-report summary
# opam 文件中的测试依赖
# depends: [
# "alcotest" {with-test}
# "qcheck-alcotest" {with-test}
# ]
测试最佳实践
(* 1. 测试命名清晰 *)
let test_list_empty_after_filter () =
Alcotest.(check (list int)) "过滤后为空"
[] (List.filter (fun x -> x > 10) [1;2;3])
(* 2. 测试独立,不依赖执行顺序 *)
let test_counter () =
let counter = ref 0 in (* 每次测试创建新的 *)
incr counter;
Alcotest.(check int) "计数" 1 !counter
(* 3. 测试边界条件 *)
let test_divide_large_numbers () =
Alcotest.(check int) "大数除法" 1 (max_int / max_int)
let test_empty_list () =
Alcotest.(check (list int)) "空列表" [] []
let test_single_element () =
Alcotest.(check (list int)) "单元素" [1] [1]
(* 4. 测试错误路径 *)
let test_parse_invalid_json () =
Alcotest.check_raises "无效 JSON"
(Yojson.Json_error "Unexpected end of input")
(fun () -> ignore (Yojson.Safe.from_string "{"))
(* 5. 使用自定义类型检查 *)
let user_testable =
Alcotest.testable
(fun fmt u -> Format.fprintf fmt "{id=%d; name=%s}" u.id u.name)
(=)
let test_user () =
let user = { id = 1; name = "Alice" } in
Alcotest.(check user_testable) "用户" { id = 1; name = "Alice" } user
(* 6. 快照测试 *)
let test_json_output () =
let user = { id = 1; name = "Alice" } in
let json = user_to_yojson user |> Yojson.Safe.pretty_to_string in
let expected = {|{
"id": 1,
"name": "Alice"
}|} in
Alcotest.(check string) "JSON 输出" expected json
| 原则 | 说明 |
|---|---|
| FIRST | Fast, Independent, Repeatable, Self-validating, Timely |
| AAA | Arrange(准备)、Act(执行)、Assert(断言) |
| 边界 | 测试空值、零值、最大值、负值 |
| 路径 | 覆盖正常路径和错误路径 |
| 命名 | 测试名应描述被测行为 |
扩展阅读
上一节:数据库操作(Caqti) 下一节:性能优化与 Benchmark