Skip to main content

第 18 章:测试(Testing)

原文:Anil Madhavapeddy and Yaron Minsky, Real World OCaml: Functional Programming for the Masses, Second Edition, Chapter 18。维护者已确认本书为开源书籍,可翻译并发布用于学习研究。

本章的目标,是教你如何在 OCaml 中编写有效测试,并展示一些有帮助的工具。在测试语境中,工具尤其重要,因为阻碍人们写足够多测试的因素之一,就是测试工作本身的乏味。但只要工具合适,写测试就可以变得轻量而有趣。

这一点很重要,因为当测试变得有趣时,你就会写更多测试;而充分测试是构建可靠且可演进软件的关键要素。人们有时会以为,像 OCaml 这样拥有丰富且表达力很强的类型系统的语言,对测试的需求会少一些。但从某种意义上说,情况恰好相反。类型实际上能让你的测试投入获得更多价值:一方面,类型系统会自动强制许多琐碎性质成立,让你不必测试它们;另一方面,类型的刚性也意味着代码常常具有一种“咔嗒拼合”的性质,数量相对较少的测试就能大幅提高你对代码行为符合预期的信心。

在开始介绍测试工具之前,值得先暂停一下,思考我们到底希望从测试中获得什么。

理想情况下,测试应该:

  • 易于编写和运行。创建测试并接入开发流程时,应该只需要最少的样板代码。理想情况下,应该让测试在每个拟议变更上自动运行,防止人们意外破坏构建。
  • 易于更新。如果代码变化后测试很难调整,那么测试本身就会变成另一种技术债。
  • 快速,这样不会拖慢开发流程。
  • 确定性。如果失败很可能只是随机故障,就很难认真对待测试失败。你希望测试失败能可信地指示问题,而这需要确定性。
  • 可理解。好的测试应该容易阅读,而且失败位置应该局部而具体,这样才能容易找到并修复失败测试标出的错误。

没有任何测试框架能保证你的测试满足这些性质。但你选择的工具会在所有这些方面提供帮助,或者制造阻碍。

随着本章继续介绍一些可用工具,你应该能看到每种工具如何推动这些目标。

18.1 内联测试(Inline Tests)

通往良好测试环境的第一步,是让测试易于设置和运行。为此,我们会展示如何使用 ppx_inline_test 编写测试。它允许你通过带有特殊标注的 let 绑定,在库中的任何模块里添加测试。

要在库中使用内联测试,需要做两件事:

  • 告诉 Dune 这个库中会出现内联测试。
  • 启用 ppx_inline_test 作为预处理器。

第一件事通过添加 (inline_tests) 声明完成,第二件事通过把 ppx_inline_test 加入预处理器集合完成。下面是得到的 dune 文件。

(library
(name foo)
(libraries base stdio)
(inline_tests)
(preprocess (pps ppx_inline_test)))

完成这些之后,这个库中的任何模块都可以承载测试。我们创建一个名为 test.ml 的文件,里面包含一个测试。

open Base

let%test "rev" =
List.equal Int.equal (List.rev [ 3; 2; 1 ]) [ 1; 2; 3 ]

如果等号右侧的表达式求值为 true,测试就通过。内联测试不会随着模块实例化自动运行,而是被注册给测试运行器来运行。

$ dune runtest

因为测试成功通过,所以不会生成任何输出。但如果我们破坏这个测试:

open Base

let%test "rev" =
List.equal Int.equal (List.rev [ 3; 2; 1 ]) [ 3; 2; 1 ]

运行它时就会看到错误。

$ dune runtest
File "test.ml", line 3, characters 0-74: rev is false.

FAILED 1 / 1 tests
[1]

18.1.1 使用 test_eq 得到更可读的错误(More Readable Errors with test_eq

刚才看到的测试输出有一个问题:它没有显示与失败测试相关的数据,因此问题出现时更难诊断和修复。可以通过抛出异常来报告测试失败,而不是返回 false,以此修复这个问题。随后,这个异常就可以用于报告哪里出了错。

为此,我们会把测试声明改成使用 let%test_unit 而不是 let%test,这样测试主体就不再需要返回 bool。我们还会使用 [%test_eq] 语法;给定一个类型,它会生成代码来测试相等性,并在参数不相等时抛出有意义的异常。

为了使用 [%test_eq],需要添加 ppx_assert 语法扩展,因此需要相应调整 dune 文件。

(library
(name foo)
(libraries base stdio)
(preprocess
(pps ppx_inline_test ppx_assert))
(inline_tests))

下面是新测试的样子。

open Base

let%test_unit "rev" =
[%test_eq: int list] (List.rev [ 3; 2; 1 ]) [ 3; 2; 1 ]

现在运行测试,看看输出是什么样。

$ dune runtest
File "test.ml", line 3, characters 0-79: rev threw
(duniverse/ppx_assert/runtime-lib/runtime.ml.E "comparison failed"
((1 2 3) vs (3 2 1) (Loc test.ml:4:13))).
Raised at Ppx_assert_lib__Runtime.test_eq in file "duniverse/ppx_assert/runtime-lib/runtime.ml", line 95, characters 22-69
Called from Foo__Test.(fun) in file "test.ml", line 4, characters 13-21

FAILED 1 / 1 tests
[1]

可以看到,导致比较失败的数据会被打印出来,同时还会打印栈回溯。遗憾的是,在这个例子中,回溯大多只是干扰。这是用异常报告测试失败的一个缺点。

18.1.2 测试应该放在哪里?(Where Should Tests Go?)

内联测试框架允许你把测试放进库中的任何 .ml 文件。但你能做某件事,并不意味着就应该这么做。

把测试直接放在正在构建的库中当然有一些好处。首先,它允许你把某个函数的测试直接放在该函数定义之后,在某些场景中这有助于可读性。这种方式还允许你测试那些没有通过外部接口暴露出来的代码方面。

虽然乍看起来这很有吸引力,但把测试放进库中有几个缺点。

  • 可读性。把所有测试都直接包含在应用代码中,可能让应用代码本身更难阅读。为了避免让应用代码杂乱,人们可能因此写得太少。
  • 膨胀。如果测试作为库的一部分编写,意味着库的每个用户在生产应用中都必须链接这些测试代码。即使这些代码不会运行,它仍然会增加可执行文件体积。它还可能要求依赖生产环境并不需要的库,从而降低代码可移植性。
  • 测试心态。在库内部编写测试,让你可以针对实现的任何部分写测试,而不只是暴露出来的 API。这种自由很有用,但也可能把你带到错误的测试心态中。围绕公共 API 编写的测试,通常更能测试代码的根本行为,也更能经受实现重构。此外,让测试位于外部的纪律会要求你编写能以这种方式测试的代码,从而推动更好的设计。

基于所有这些原因,我们建议把大部分测试放在专门为测试创建的 test-only 库中。确实有一些正当理由把少量测试直接放进生产库里,例如需要访问某些重要但很难暴露的功能才能完成测试。但这类情况非常例外。

注:为什么内联测试不能放在可执行文件里?

到目前为止,我们只讨论了把测试放进库中。可执行文件又如何呢?事实证明,不能直接这么做,因为 Dune 不支持在直接属于可执行文件的源文件中使用 inline_tests 声明。

这背后有个好理由:ppx_inline_test 测试运行器需要实例化包含测试的模块。如果这些模块有顶层副作用,那会是一场灾难,因为你并不希望测试框架触发这些顶层效果。

那么,如何测试属于可执行文件的代码?解决办法是把程序拆成两部分:一个目录包含库,库中放程序逻辑但不包含顶层效果;另一个目录包含可执行文件,它链接这个库,并负责启动代码。

18.2 Expect 测试(Expect Tests)

目前展示的测试大多是在给定场景中检查某些具体性质。不过有时,你想做的不是测试这个或那个性质,而是捕获并展示代码的行为。Expect 测试正是为此而生。

18.2.1 基本机制(Basic Mechanics)

使用 expect 测试时,源文件同时指定要执行的代码以及期望输出。运行 expect 测试时,期望输出与实际生成输出之间的任何差异都会被报告为测试失败。

下面是一个用这种风格编写的简单测试。这个测试会生成输出(通过调用 print_endline),但至少目前,这个输出还没有被捕获在源文件中。

open! Base
open Stdio

let%expect_test "trivial" = print_endline "Hello World!"

注:openopen!

在这个例子中,我们使用 open! 而不是 open,因为恰好没有使用 Base 中的任何值,所以编译器会警告说有一个未使用的 open。

但由于 Base 实际上是我们的标准库,我们仍然希望保持它打开,因为我们希望接下来写的任何新代码都能找到 Base 中的模块,而不是普通标准库中的模块。open 末尾的感叹号会抑制这个警告。

一个合理的惯用法是:打开 Base 这样的库时总是使用 open!,这样就不必选择何时使用 !、何时不使用。

如果运行测试,会看到我们写的源文件与一个已修正版本之间的 diff。修正版本现在包含了一个 [%expect] 子句,其中记录了输出。注意,如果可用,Dune 会使用 patdiff 工具来生成更易读的 diff。可以用 opam 安装 patdiff

$ dune runtest
patdiff (internal) (exit 1)
(cd _build/default && rwo/_build/install/default/bin/patdiff -keep-whitespace -location-style omake -ascii test.ml test.ml.corrected)
------ test.ml
++++++ test.ml.corrected
File "test.ml", line 5, characters 0-1:
|open! Base
|open Stdio
|
|let%expect_test "trivial" =
-| print_endline "Hello World!"
+| print_endline "Hello World!";
+| [%expect {| Hello World! |}]
[1]

Expect 测试运行器还会创建一个带有捕获输出的文件,在原文件名末尾追加 .corrected。如果这个新输出看起来正确,就可以提升(promote)它,也就是把 corrected 文件复制覆盖原始源文件。dune promote 命令正是做这件事。提升后,源文件如下。

open Base
open Stdio

let%expect_test "trivial" =
print_endline "Hello World!";
[%expect {| Hello World! |}]

现在再次运行测试,就会看到它通过。

$ dune runtest

这个例子中只有一个 expect 块,但系统支持多个 expect 块:

open Base
open Stdio

let%expect_test "multi-block" =
print_endline "Hello";
[%expect {| Hello |}];
print_endline "World!";
[%expect {| World! |}]

18.2.2 Expect 测试有什么用?(What Are Expect Tests Good For?)

乍看起来,为什么要使用 expect 测试并不明显。为什么下面这样:

open Base
open Stdio

let%expect_test _ =
print_s [%sexp (List.rev [ 3; 2; 1 ] : int list)];
[%expect {| (1 2 3) |}]

会比这样更可取?

open Base

let%test "rev" =
List.equal Int.equal (List.rev [ 3; 2; 1 ]) [ 1; 2; 3 ]

事实上,对这样的例子来说,expect 测试并不更好:当完整写出具体示例既容易又方便时,上面这种基于简单示例的测试就很好。并且,正如本章稍后会讨论的,如果有一组清晰的谓词想要测试,而且示例可以自然地随机生成,那么性质测试通常是最佳选择。

Expect 测试闪光的地方,是当你想让系统行为的某些方面可见,而这些行为又很难用谓词捕获时。这比乍看起来更有用。下面通过几个不同示例场景来说明原因。

18.2.3 探索式编程(Exploratory Programming)

当处于探索模式时,expect 测试尤其有帮助:你正在通过摆弄数据来解决问题,而没有事先清晰的规格。

这种类型的常见编程任务之一是网页抓取(web scraping),其目标通常是从网页中提取一些有用信息。找到正确提取方式往往需要反复试错。

下面代码完成了这类数据提取。它使用 lambdasoup 包遍历一段 HTML,并吐出嵌在其中的一些数据。具体来说,这个函数的目标是生成文档中链接里出现的主机集合。

open Base
open Stdio

let get_href_hosts soup =
Soup.select "a[href]" soup
|> Soup.to_list
|> List.map ~f:(Soup.R.attribute "href")
|> Set.of_list (module String)

可以用 expect 测试展示这个函数在示例页面上的行为。

let%expect_test _ =
let example_html =
{|
<html>
Some random <b>text</b> with a
<a href="http://ocaml.org/base">link</a>.
And here's another
<a href="http://github.com/ocaml/dune">link</a>.
And here is <a>link</a> with no href.
</html>|}
in
let soup = Soup.parse example_html in
let hrefs = get_href_hosts soup in
print_s [%sexp (hrefs : Set.M(String).t)]

注:引用字符串(Quoted Strings)

上面的例子使用了一种新的字符串字面量语法,称为引用字符串。下面是一个例子。

# {|This is a quoted string|};;
- : string = "This is a quoted string"

这种语法的优点是,可以不用普通字符串字面量通常要求的转义,直接写出内容。看下面的例子:

# {|This is a literal quote: "|};;
- : string = "This is a literal quote: \""

可以看到,包含的引号不需要转义,不过顶层回显回来的字符串版本使用普通字符串字面量语法,因此那里显示的引号会被转义。

引用字符串在编写包含另一门语言文本的字符串时特别有用,例如 HTML。使用引用字符串时,可以直接粘贴一段其他源语言的片段,它通常无需修改就能工作。

一个棘手角落是:如果需要在引用字符串中包含字面量 |}。诀窍是可以通过添加任意标识符来改变引用字符串的定界符,从而确保该定界符不会出现在字符串主体中。

# {xxx|This is how you quote a {|quoted string|}|xxx};;
- : string = "This is how you quote a {|quoted string|}"

如果运行测试,会看到输出并不完全符合预期。

$ dune runtest
patdiff (internal) (exit 1)
...
------ test.ml
++++++ test.ml.corrected
File "test.ml", line 24, characters 0-1:
| |> List.map ~f:(Soup.R.attribute "href")
| |> Set.of_list (module String)
|
|let%expect_test _ =
| let example_html = {|
| <html>
| Some random <b>text</b> with a
| <a href="http://ocaml.org/base">link</a>.
| And here's another
| <a href="http://github.com/ocaml/dune">link</a>.
| And here is <a>link</a> with no href.
| </html>|}
| in
| let soup = Soup.parse example_html in
| let hrefs = get_href_hosts soup in
-| print_s [%sexp (hrefs : Set.M(String).t)]
+| print_s [%sexp (hrefs : Set.M(String).t)];
+| [%expect {| (http://github.com/ocaml/dune http://ocaml.org/base) |}]
[1]

问题在于,我们没能从 URI 字符串中提取主机。也就是说,结果里出现的是 http://github.com/ocaml/dune,而不是 github.com。可以使用 uri 库解析字符串并提取主机来修复这一点。下面是修改后的代码。

let get_href_hosts soup =
Soup.select "a[href]" soup
|> Soup.to_list
|> List.map ~f:(Soup.R.attribute "href")
|> List.filter_map ~f:(fun uri -> Uri.host (Uri.of_string uri))
|> Set.of_list (module String)

再次运行测试,会看到输出现在变成了应该有的样子。

$ dune runtest
patdiff (internal) (exit 1)
...
------ test.ml
++++++ test.ml.corrected
File "test.ml", line 26, characters 0-1:
| |> Set.of_list (module String)
|
|let%expect_test _ =
| let example_html = {|
| <html>
| Some random <b>text</b> with a
| <a href="http://ocaml.org/base">link</a>.
| And here's another
| <a href="http://github.com/ocaml/dune">link</a>.
| And here is <a>link</a> with no href.
| </html>|}
| in
| let soup = Soup.parse example_html in
| let hrefs = get_href_hosts soup in
| print_s [%sexp (hrefs : Set.M(String).t)];
-| [%expect {| (http://github.com/ocaml/dune http://ocaml.org/base) |}]
+| [%expect {| (github.com ocaml.org) |}]
[1]

这种探索式工作流的一个好处是,一旦代码工作起来,就可以把开发代码时使用的示例留下来,作为永久测试。

18.2.4 可视化复杂行为(Visualizing Complex Behavior)

Expect 测试可以用来检查系统的动态行为。我们走读一个简单例子:限速器(rate limiter)。限速器的职责,是限制系统消费某个特定资源的速率。下面是一个库的 mli,它定义了一个简单滚动窗口风格限速器的逻辑;目标是确保不存在任何指定周期长度的时间窗口,其中发生的事件数超过指定数量。

open Core

type t

val create : now:Time_ns.t -> period:Time_ns.Span.t -> rate:int -> t
val maybe_consume : t -> now:Time_ns.t -> [ `Consumed | `No_capacity ]

可以通过运行一些示例来展示系统行为。首先,写几个辅助函数,让示例更短、更易读。

open Core

let start_time =
Time_ns.of_string_with_utc_offset "2021-06-01 7:00:00Z"

let limiter () =
Rate_limiter.create
~now:start_time
~period:(Time_ns.Span.of_sec 1.)
~rate:2

let consume lim offset =
let result =
Rate_limiter.maybe_consume
lim
~now:(Time_ns.add start_time (Time_ns.Span.of_sec offset))
in
printf
"%4.2f: %s\n"
offset
(match result with
| `Consumed -> "C"
| `No_capacity -> "N")

这里定义了三个值:start_time,也就是示例开始时的时间点;limiter,一个用一些合理默认值构造全新 Limiter.t 对象的函数;以及 consume,它会尝试消费一个资源。

值得注意的是,consume 不只是更新限速器,还会打印出结果标记,也就是消费成功还是失败。

现在可以使用这些辅助函数,看看限速器在简单场景中的行为。首先,尝试在时间零消费三次;然后等待半秒再次消费;再等待半秒,再试一次。

let%expect_test _ =
let lim = limiter () in
let consume offset = consume lim offset in
(* Exhaust the rate limit, without advancing the clock. *)
for _ = 1 to 3 do
consume 0.
done;
[%expect {| |}];
(* Wait until a half-second has elapsed, try again *)
consume 0.5;
[%expect {| |}];
(* Wait until a full second has elapsed, try again *)
consume 1.;
[%expect {| |}]

运行测试并接受提升后,会把执行轨迹包含进来。

let%expect_test _ =
let lim = limiter () in
let consume offset = consume lim offset in
(* Exhaust the rate limit, without advancing the clock. *)
for _ = 1 to 3 do
consume 0.
done;
[%expect {|
0.00: C
0.00: C
0.00: C |}];
(* Wait until a half-second has elapsed, try again *)
consume 0.5;
[%expect {| 0.50: C |}];
(* Wait until a full second has elapsed, try again *)
consume 1.;
[%expect {| 1.00: C |}]

然而,上面的结果并不是预期结果。特别是,尽管违反了每秒 2 次的速率限制,所有 consume 调用都成功了。这是因为实现中存在一个 bug。实现中有一个队列,记录消费事件发生的时间,我们用下面这个函数清空队列:

let rec drain_old_events t =
match Queue.peek t.events with
| None -> ()
| Some time ->
if Time_ns.Span.( < ) (Time_ns.diff t.now time) t.period
then (
ignore (Queue.dequeue_exn t.events : Time_ns.t);
drain_old_events t)

但这里比较方向反了:应该丢弃早于限制周期的事件,而不是更年轻的事件。修复后,就会看到轨迹按预期运行。

let%expect_test _ =
let lim = limiter () in
let consume offset = consume lim offset in
(* Exhaust the rate limit, without advancing the clock. *)
for _ = 1 to 3 do
consume 0.
done;
[%expect {|
0.00: C
0.00: C
0.00: N |}];
(* Wait until a half-second has elapsed, try again *)
consume 0.5;
[%expect {| 0.50: N |}];
(* Wait until a full second has elapsed, try again *)
consume 1.;
[%expect {| 1.00: C |}]

这个测试之所以可读,一个原因是我们费心让代码保持短小、易读。这部分来自有用辅助函数的创建,另一部分来自 expect 块捕获数据时使用了简洁且没有噪音的格式。

18.2.5 端到端测试(End-to-End Tests)

到目前为止看到的 expect 测试都是自包含的,不执行 I/O,也不与系统资源交互。因此,这些测试运行很快,而且完全确定。

这是一个很好的理想状态,但并不总是能够达到,尤其是当你想对程序运行更多端到端测试时。不过,即使需要运行涉及多个进程相互交互并使用真实 I/O 的测试,expect 测试仍然是有用工具。

为了看看如何构建这类测试,我们将为第 17 章“使用 Async 的并发编程(Concurrent Programming with Async)”中开发的回显服务器写一些测试。

先在回显服务器实现旁边创建一个新的测试目录,并放一个 dune 文件。

(library
(name echo_test)
(libraries core async)
(preprocess (pps ppx_jane))
(inline_tests (deps ../bin/echo.exe)))

重要的是最后一行,其中在 inline_tests 声明中声明了对回显服务器二进制的依赖。还要注意,我们没有逐个选择有用预处理器,而是使用了总括性的 ppx_jane 包,它把一组有用扩展打包在一起。

完成之后,下一步是编写一些辅助函数。这里不展示实现,但下面是 Helpers 模块的签名。注意,launch 函数中有一个参数,可用于启用回显服务器中把接收到的文本转为大写的功能。

open! Core
open Async

(** Launches the echo server *)
val launch : port:int -> uppercase:bool -> Process.t Deferred.t

(** Connects to the echo server, returning a reader and writer for
communicating with the server. *)
val connect : port:int -> (Reader.t * Writer.t) Deferred.t

(** Sends data to the server, printing out the result *)
val send_data : Reader.t -> Writer.t -> string -> unit Deferred.t

(** Kills the echo server, and waits until it exits *)
val cleanup : Process.t -> unit Deferred.t

有了这些,就可以编写一个测试:启动服务器,通过 TCP 连接到它,然后发送一些数据并显示结果。

open! Core
open Async
open Helpers

let%expect_test "test uppercase echo" =
let port = 8081 in
let%bind process = launch ~port ~uppercase:true in
Monitor.protect
(fun () ->
let%bind r, w = connect ~port in
let%bind () = send_data r w "one two three\n" in
let%bind () = [%expect] in
let%bind () = send_data r w "one 2 three\n" in
let%bind () = [%expect] in
return ())
~finally:(fun () -> cleanup process)

注意,我们在想看到数据的地方放了一些 expect 标注,但还没有填入内容。现在可以运行测试看看会发生什么。不过结果并不如希望的那样。

$ dune runtest
Entering directory 'rwo/_build/default/book/testing/examples/erroneous/echo_test_original'
patdiff (internal) (exit 1)
(cd _build/default && rwo/_build/install/default/bin/patdiff -keep-whitespace -location-style omake -ascii test/test.ml test/test.ml.corrected)
------ test/test.ml
++++++ test/test.ml.corrected
File "test/test.ml", line 11, characters 0-1:
|open! Core
|open Async
|open Helpers
|
|let%expect_test "test uppercase echo" =
| let port = 8081 in
| let%bind process = launch ~port ~uppercase:true in
| Monitor.protect (fun () ->
| let%bind (r,w) = connect ~port in
| let%bind () = send_data r w "one two three\n" in
-| let%bind () = [%expect] in
+| let%bind () = [%expect.unreachable] in
| let%bind () = send_data r w "one 2 three\n" in
-| let%bind () = [%expect] in
+| let%bind () = [%expect.unreachable] in
| return ())
| ~finally:(fun () -> cleanup process)
+|[@@expect.uncaught_exn {|
+| (monitor.ml.Error
+| (Unix.Unix_error "Connection refused" connect 127.0.0.1:8081)
+| ("<backtrace elided in test>" "Caught by monitor Tcp.close_sock_on_error")) |}]
[1]

出了什么问题?问题在于连接失败了,因为发起连接时,回显服务器还没有完成服务器设置。可以在连接前添加一秒延迟,用 Async 的 Clock.after 来修复它。修改后,测试会通过,并得到期望结果。

open! Core
open Async
open Helpers

let%expect_test "test uppercase echo" =
let port = 8081 in
let%bind process = launch ~port ~uppercase:true in
Monitor.protect (fun () ->
let%bind () = Clock.after (Time.Span.of_sec 1.) in
let%bind (r,w) = connect ~port in
let%bind () = send_data r w "one two three\n" in
let%bind () = [%expect{| ONE TWO THREE |}] in
let%bind () = send_data r w "one 2 three\n" in
let%bind () = [%expect{| ONE 2 THREE |}] in
return ())
~finally:(fun () -> cleanup process)

问题是修复了,但这个解决方案应该让你不太舒服。首先,为什么一秒是正确超时时间,而不是半秒或十秒?等待时间是在降低非确定性失败可能性与保持测试性能之间做出的某种平衡,而这是一种有点尴尬的取舍。

可以改进它:移除 Clock.after 调用,转而在 connect 测试辅助函数中添加重试循环。

let rec connect ~port =
match%bind
Monitor.try_with (fun () ->
Tcp.connect
(Tcp.Where_to_connect.of_host_and_port
{ host = "localhost"; port }))
with
| Ok (_, r, w) -> return (r, w)
| Error _ ->
let%bind () = Clock.after (Time.Span.of_sec 0.01) in
connect ~port

这段代码里仍然有超时,因为重试之前会稍等一下。但这个超时非常激进,所以最多不会无谓浪费超过 10 毫秒。这意味着测试通常会快速运行;但如果运行较慢(也许是因为机器在大构建期间负载很高),测试仍然会通过。

这里的教训是:在运行执行真实 I/O 的程序时,保持测试确定性很快就会变得凌乱。只要可能,就应该组织代码,让大部分代码可以在不连接真实运行服务器的情况下测试。但如果确实需要这么做,expect 测试仍然能用于这个目的。

18.2.6 如何写出好的 Expect 测试(How to Make a Good Expect Test)

综合这些例子,可以得到一些构建良好 expect 测试的指南:

  • 编写辅助函数,帮助你更简洁地设置测试场景。
  • 编写自定义 pretty-printer,只暴露测试中需要看到的信息。这会让测试更易读,也会在与测试无关的细节变化时,最大限度减少不必要的 churn。
  • 以确定性为目标。理想情况下,通过组织代码,让它可以在不直接与外部世界交互的情况下接受检验,因为外部世界通常是非确定性的来源。但如果必须交互,也要小心避免那些在性能压力下会崩溃的超时和其他权宜手段。

18.3 使用 Quickcheck 做性质测试(Property Testing with Quickcheck)

许多测试不过是用简单断言装饰的个别示例,用来检查这个或那个性质。性质测试是这种方法的有用扩展,它允许你用少量程序员投入,探索代码行为中更大的一部分。

基本想法很简单。一个性质测试需要两样东西:一个函数,它接收示例输入并检查某个给定性质在该示例上成立;以及一种生成随机示例的方式。测试随后会检查谓词是否在许多随机生成的示例上成立。

仅用目前学过的工具,也可以写性质测试。这个例子会检查连接三个操作的一个看似显然的不变量:

  • Int.sign:计算表示整数符号的 Sign.t,即 PositiveNegativeZero
  • Int.neg:对一个数取负
  • Sign.flip:翻转 Sign.t,也就是把 Positive 映射到 Negative,反之亦然

我们想检查的不变量是:任意整数 x 的负数的符号,等于 x 的符号翻转。

下面是这个测试的简单实现。

open Base

let%test_unit "negation flips the sign" =
for _ = 0 to 100_000 do
let x = Random.int_incl Int.min_value Int.max_value in
[%test_eq: Sign.t]
(Int.sign (Int.neg x))
(Sign.flip (Int.sign x))
done

如你所料,测试会通过。

$ dune runtest

实现中必须做出的一个选择,是用哪种概率分布来选择示例。这看起来可能不像一个重要问题,但它确实重要。谈到测试时,并非所有概率分布都一样。

事实上,我们做出的选择存在问题:从完整整数集合中均匀随机选择整数。这会让零和一这样的有趣特殊情况,与其他所有整数拥有相同概率。考虑到整数数量巨大,测试到这些特殊情况的概率相当低,这看起来是个问题。

这正是 Quickcheck 可以提供帮助的地方。Quickcheck 是一个帮助自动构建测试分布的库。我们来尝试用它改写上面的例子。注意这里打开 Core,因为 CoreQuickcheck 有良好集成,辅助函数已经集成进多数常见模块中。也有一个独立的 Base_quickcheck 库,可以在不使用 Core 时使用。

open Core

let%test_unit "negation flips the sign" =
Quickcheck.test
~sexp_of:[%sexp_of: int]
(Int.gen_incl Int.min_value Int.max_value)
~f:(fun x ->
[%test_eq: Sign.t]
(Int.sign (Int.neg x))
(Sign.flip (Int.sign x)))

注意,我们没有显式说明应该测试多少个示例。Quickcheck 有一个内置默认值,可以通过可选参数覆盖。

运行测试会揭示一个事实:我们一直测试的性质并不对所有输出成立,如下所示。

$ dune runtest
File "test.ml", line 3, characters 0-244: negation flips the sign threw
("Base_quickcheck.Test.run: test failed" (input -4611686018427387904)
(error
((duniverse/ppx_assert/runtime-lib/runtime.ml.E "comparison failed"
(Neg vs Pos (Loc test.ml:7:19))))))

FAILED 1 / 1 tests
[1]

触发异常的示例是 -4611686018427387904,也就是 Int.min_value,它是 Int.t 类型的最小值。这揭示了一个关于整数的事实,而这个事实可能并不明显:最大 int 的绝对值比最小 int 小。

# Int.min_value;;
- : int = -4611686018427387904
# Int.max_value;;
- : int = 4611686018427387903

这意味着对 min_value 取负没有自然选择。事实证明,这里(不只是 OCaml)的标准行为是:min_value 的负数等于它自己。

# Int.neg Int.min_value;;
- : int = -4611686018427387904

Quickcheck 对特殊情况赋予更大权重的决定,让我们发现了这个意外行为。注意,在这个例子中,发现的并不真是 bug,只是我们以为会成立的性质实际上无法成立。但无论如何,Quickcheck 帮助我们更好地理解了代码行为。

18.3.1 处理复杂类型(Handling Complex Types)

测试不能只依赖简单原子类型,所以你经常会想为更复杂的类型构建概率分布。下面是一个简单例子:我们想测试 List.rev_append 的行为。这个测试会使用一个概率分布,生成整数列表对。下面示例展示了如何使用 Quickcheck 的组合子完成这件事。

open Core

let gen_int_list_pair =
let int_list_gen =
List.gen_non_empty (Int.gen_incl Int.min_value Int.max_value)
in
Quickcheck.Generator.both int_list_gen int_list_gen

let%test_unit "List.rev_append is List.append of List.rev" =
Quickcheck.test
~sexp_of:[%sexp_of: int list * int list]
gen_int_list_pair
~f:(fun (l1, l2) ->
[%test_eq: int list]
(List.rev_append l1 l2)
(List.append (List.rev l1) l2))

这里使用了 Quickcheck.Generator.both,它可以根据两个组成类型的生成器创建一个二元组生成器。

# open Core;;
# #show Quickcheck.Generator.both;;
val both :
'a Base_quickcheck.Generator.t ->
'b Base_quickcheck.Generator.t -> ('a * 'b) Base_quickcheck.Generator.t

生成器声明很简单,但也很乏味。好在 Quickcheck 附带一个 PPX,可以只根据类型声明自动创建生成器。可以用它简化代码,如下所示。

open Core

let%test_unit "List.rev_append is List.append of List.rev" =
Quickcheck.test
~sexp_of:[%sexp_of: int list * int list]
[%quickcheck.generator: int list * int list]
~f:(fun (l1, l2) ->
[%test_eq: int list]
(List.rev_append l1 l2)
(List.append (List.rev l1) l2))

它也适用于其他更复杂的数据类型,例如变体。下面是一个简单例子。

type shape =
| Circle of { radius: float }
| Rect of { height: float; width: float }
| Poly of (float * float) list
[@@deriving quickcheck];;

这会做出一系列合理的默认选择,例如以相等概率选择 CircleRectPoly。可以用标注调整它,例如指定某个变体的权重。

type shape =
| Circle of { radius: float } [@quickcheck.weight 0.5]
| Rect of { height: float; width: float }
| Poly of (float * float) list
[@@deriving quickcheck];;

注意,每种情况的默认权重是 1,所以现在 Circle 会以 0.5 / 2.5,也就是 0.2 的概率生成,而不是原生情况下的三分之一概率。

18.3.2 使用 let 语法获得更多控制(More Control with Let-Syntax)

如果与 ppx_quickcheck 相关的标注无法精确表达你想要的东西,可以利用 Quickcheck 的生成器形成 monad 这一事实,获得更多控制。这意味着它支持 bindmap 这类运算符;我们最早是在错误处理语境中介绍这些运算符的。

结合 let 语法,生成器 monad 给我们提供了一种方便方式,用于为自定义类型指定生成器。下面是前面 shape 类型的示例生成器。

# let gen_shape =
let open Quickcheck.Generator.Let_syntax in
let module G = Base_quickcheck.Generator in
let circle =
let%map radius = G.float_positive_or_zero in
Circle { radius }
in
let rect =
let%bind height = G.float_positive_or_zero in
let%map width = G.float_inclusive height Float.infinity in
Rect { height; width }
in
let poly =
let%map points =
List.gen_non_empty
(G.both G.float_positive_or_zero G.float_positive_or_zero)
in
Poly points
in
G.union [ circle; rect; poly ];;
val gen_shape : shape Base_quickcheck.Generator.t = <abstr>

在整个函数中,我们都在对概率分布做选择。例如,使用 union 运算符意味着圆形、矩形和多边形会同样可能出现。也可以使用 weighted_union 选择不同分布。此外,我们确保所有浮点值都是非负的,并且矩形宽度不小于高度。

用于构建生成器的完整 API 超出了本章范围,但如果想更精细地控制测试示例分布,值得深入阅读 API 文档。

18.4 其他测试工具(Other Testing Tools)

本章描述的测试工具覆盖了很多内容,但还有其他值得了解的工具。

18.4.1 做(大致)相同事情的其他工具(Other Tools to Do Mostly the Same Things)

下面是一些值得注意的工具,它们做的事情与本章重点介绍的测试工具大致相同。

  • Alcotest,另一个用于注册和运行测试的系统。
  • qcheck,quickcheck 的另一种实现。
  • Dune 的 cram 测试,一种用类似 shell 的语法编写的 expect-like 测试。它非常适合测试命令行工具,灵感来自 Mercurial 的测试框架。

最终更偏好哪一种,在某种程度上是品味问题。

18.4.2 模糊测试(Fuzzing)

还有一种测试工具本章没有覆盖,但值得了解:插桩引导的模糊测试(instrumentation-guided fuzzing)。可以把它看作性质测试的另一种版本,只是生成随机示例的方法非常不同。

传统模糊测试只是把随机变异的数据扔给程序,并寻找某种失败迹象,通常只是程序因段错误崩溃。这种模糊测试在生产软件中寻找 bug,尤其是安全 bug 时,效果出奇地好。但盲目随机化在能有效探索多少程序行为方面仍然相当有限。

插桩引导的模糊测试改进了这一点:它对程序插桩,然后使用插桩信息引导随机化朝向更多代码覆盖率。这个领域迄今最成功的工具是 American Fuzzy Lop,简称 AFL;OCaml 支持所需的插桩。

AFL 的效果有时好得惊人。例如,在对解析器做模糊测试时,它可以在没有指导的情况下,通过不断朝更多覆盖率方向随机化输入,构造出接近可解析的文本。

如果对 AFL 感兴趣,还有一些相关工具值得了解。

  • Crowbar 是一个 quickcheck 风格的库,可用于写下由 AFL 测试的性质。
  • Bun 是一个把 AFL 集成进持续集成流水线的库。