强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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
原则说明
FIRSTFast, Independent, Repeatable, Self-validating, Timely
AAAArrange(准备)、Act(执行)、Assert(断言)
边界测试空值、零值、最大值、负值
路径覆盖正常路径和错误路径
命名测试名应描述被测行为

扩展阅读


上一节数据库操作(Caqti) 下一节性能优化与 Benchmark