编写测试#

测试对于提高程序的质量和可维护性很重要。它们验证程序的行为,也作为规范,以避免随时间的推移出现退步。

MoonBit 提供测试支持,使编写测试更加简单和容易。

测试块#

MoonBit 提供测试代码块,用于编写内联测试用例。例如:

test "test_name" {
  assert_eq(1 + 1, 2)
  assert_eq(2 + 2, 4)
  debug_inspect([1, 2, 3], content="[1, 2, 3]")
}

测试代码块本质上是一个类型为 () -> Unit raise Error 的函数:它返回 Unit,并可能抛出 Error。它在执行 moon test 期间被调用,并通过构建系统输出测试报告。assert_eq 函数来自标准库;如果断言失败,它会打印错误消息并终止测试。字符串 "test_name" 用于标识测试用例,是可选的。

由 moon test 启动的测试以模块根目录作为当前工作目录。测试中的相对路径应从模块根目录解析,而不是从包目录或测试文件所在目录解析。

如果测试名称以 "panic" 开头,表示测试的预期行为是触发 panic,只有在触发 panic 时测试才会通过。例如:

test "panic_test" {
  let _ : Int32 = Option::None.unwrap()
}

快照测试#

在指定预期值时,编写测试可能会很繁琐。因此,MoonBit 提供了三种快照测试。所有这些都可以使用 moon test --update 自动插入或更新。

快照 Show#

我们可以使用 inspect(x, content="x") 来检查实现了 Show 特征的任何内容。正如我们之前提到的,Show 是一个可以派生的内建特征,提供了 to_string来打印数据结构的内容。标记参数 content 可以省略,因为 moon test --update 会自动插入:

struct X {
  x : Int32
} derive(Debug)

pub extend X with Debug::{to_repr}

test "show snapshot test" {
  debug_inspect({ x: 10 }, content="{ x: 10 }")
}

快照 JSON#

派生的 Show 特征的问题是它不能对输出进行格式化,导致输出非常长。

解决方案是使用 @json.inspect(x, content=x)。其好处是生成的内容是一个 JSON 结构,在格式化后更易读。

enum Rec {
  End
  Really_long_name_that_is_difficult_to_read(Rec)
} derive(Debug, ToJson)

pub extend Rec with Debug::{to_repr}

pub extend Rec with ToJson::{to_json}

test "json snapshot test" {
  let r = Really_long_name_that_is_difficult_to_read(
    Really_long_name_that_is_difficult_to_read(
      Really_long_name_that_is_difficult_to_read(End),
    ),
  )
  debug_inspect(
    r,
    content="Really_long_name_that_is_difficult_to_read(Really_long_name_that_is_difficult_to_read(Really_long_name_that_is_difficult_to_read(End)))",
  )
  json_inspect(r, content=[
    "Really_long_name_that_is_difficult_to_read",
    [
      "Really_long_name_that_is_difficult_to_read",
      ["Really_long_name_that_is_difficult_to_read", "End"],
    ],
  ])
}

也可以实现自定义 ToJson 来保留必要的信息。

快照任何内容#

有时我们不仅要记录一个数据结构,还要记录整个过程的输出。

可以使用完整的快照测试使用 @test.T::write 和 @test.T::writeln 记录任何内容:

test "record anything" (t : @test.Test) {
  t.write("Hello, world!")
  t.writeln(" And hello, MoonBit!")
  t.snapshot(filename="record_anything.txt")
}

这将在该包的 __snapshot__ 下创建一个具有给定文件名的文件:

Hello, world! And hello, MoonBit!

这也可以用于测试应用程序的输出,无论是创建图像、视频还是一些自定义数据。

请注意,@test.T::snapshot 应该在测试块的末尾使用,因为它总是引发异常。

基于性质的测试#

基于示例的测试检查特定输入,而基于性质的测试描述大量生成的输入都应满足的关系。在普通的 test 块中使用标准库的 quickcheck 包即可,无需添加额外的模块依赖。对于 _test.mbt 文件中的示例,在 moon.pkg 中添加以下导入:

import {
  "moonbitlang/core/quickcheck",
} for "test"

检查性质#

加零应当保持整数不变:

test "adding zero preserves an integer" {
  @quickcheck.check((x : Int32) => x + 0 == x, count=100, seed=2026)
}

使用 moon test 运行这个测试。check 会生成输入,并逐一调用性质函数。返回 true 表示该用例通过;返回 false 或抛出错误会使 check 报告失败。这里,count 指定检查 100 个用例,seed 使运行可以复现。生成的 Int32 值并不限于较小的数,也可能接近该类型的极限。

输入类型必须实现 Arbitrary 来生成值、Shrink 来简化失败的输入,以及 Debug 来显示这些输入。常见类型(包括 Int32)已提供这些实现。性质函数应保持确定性,并避免修改输入。

理解反例#

那么加一呢?x + 1 > x 对数学中的整数成立,但对 Int32 也成立吗?0、1、100 等较小的示例都会通过。下面使用 report 寻找反例。它返回结果而不是抛出测试失败,因此可以用快照记录失败报告,同时让演示本身保持为通过的测试:

test "adding one does not always increase an integer" {
  let report = @quickcheck.report(
    (x : Int32) => x + 1 > x,
    count=100,
    seed=2026,
  )
  debug_inspect(
    report,
    content=(
      #|Falsified(
      #|  counterexample=2147483647,
      #|  tests=99,
      #|  size=98,
      #|  shrinks=0,
      #|  shrink_attempts=31,
      #|)
    ),
  )
}

Falsified 表示性质函数返回了 false。反例是 2147483647,即最大的 Int32。加一会溢出并回绕到 -2147483648,所以结果变小了,而非变大。这是对固定宽度整数运算的错误假设,不是加法实现的缺陷。

QuickCheck 还会尝试仍然导致失败的更简单输入,这个过程称为缩减(shrinking)。这里 shrinks=0:没有更小的输入能保留这个失败,因为只有最大的 Int32 不满足该性质。可选参数 max_shrinks 限制检查的候选输入数量;一般而言,缩减并不保证找到最小的反例。如果性质不成立时应当让测试套件失败,请使用 check 而不是 report。

保留回归测试#

要复现一次运行,需要保持种子、设置、性质函数以及工具链和库版本不变。不同版本生成的用例和缩减结果可能不同。一旦反例揭示了缺陷或错误的预期,就应将该具体输入保留在基于示例的测试中,并写明正确的预期行为:

test "adding one at the Int32 boundary" {
  let x : Int32 = 2147483647
  assert_eq(x + 1, -2147483648)
  assert_eq(x.to_int64() + 1, 2147483648L)
}

第二个断言在加一之前转换为 Int64,其范围足以容纳这次计算的结果。如果在 Int32 加法完成后再转换结果就太晚了:溢出已经发生。

两种测试都应保留:回归测试始终检查已发现的用例,而性质测试可以通过其他种子和设置探索更多输入。生成的用例全部通过,并不能证明性质对所有输入都成立。

关于自定义生成器及更多选择有效性质的方法,请参阅 MoonBit 中基于性质的测试。

黑盒测试和白盒测试#

在开发库时,验证用户是否可以正确使用它是很重要的。例如,可能会忘记使类型或函数公开。这就是为什么 MoonBit 提供了黑盒测试,允许开发人员了解其他人的感受。

  • 能够访问包中所有成员的测试称为白盒测试,因为我们可以看到一切。这样的测试可以内联定义,也可以在文件中定义,文件名以 _wbtest.mbt 结尾。

  • 只能访问包中公开成员的测试称为黑盒测试。这样的测试需要在文件中定义,文件名以 _test.mbt 结尾。

The WhiteBox test files (_wbtest.mbt) imports the packages defined in the import and wbtest-import sections of the package configuration (moon.pkg, or legacy moon.pkg.json).

The BlackBox test files (_test.mbt) imports the current package and the packages defined in the import and test-import sections of the package configuration (moon.pkg, or legacy moon.pkg.json).