第六章 测试

在这一章中,我们将研究如何扩展 Rust 的测试能力,以及在测试中加入哪些其他类型的测试。Rust 有许多内置的测试设施,在《Rust 编程语言》中都有详细介绍,主要由#[test] 属性和 test/目录代表。然而,随着代码库的发展和测试需求的增加,你可能需要在单个函数上添加#[test] 标签。

本章主要分为两部分。第一部分包括 Rust 测试机制,如标准测试工具(harness)和条件测试代码。第二部分介绍了评估 Rust 代码正确性的其他方法,如基准测试、提示测试和模糊测试。

Rust 测试机制

要理解 Rust 提供的各种测试机制,你必须首先理解 Rust 是如何构建和运行测试的。当你运行 cargo test --lib 时,Cargo 所做的唯一特别的事情就是把--测试标志传递给 rustc。这个标志告诉 rustc 产生一个测试二进制文件,运行所有的单元测试,而不是仅仅编译 crate 的库或二进制文件。在幕后,--测试有两个主要作用。首先,它使 cfg(test) 成为可能,这样你就可以有条件地包含测试代码(关于这一点,稍后再谈)。其次,它使编译器生成一个测试工具:一个精心生成的主函数,当你的程序运行时,会调用程序中的每个 #[test] 函数。

测试工具(The Test Harness)

编译器通过程序性宏的混合生成测试工具(harness)主函数,我们将在第 7 章中更深入地讨论这个问题,并轻轻地撒上一些魔法。基本上,测试工具将每个由 #[test] 注释的函数转化为测试描述符--这就是程序性宏的部分。然后,它将每个描述符的路径暴露给生成的主函数--这就是神奇的部分。描述符包括的信息有:测试的名称,它所设置的任何附加选项(如 #[should_panic]),等等。在其核心部分,测试工具在 crate 中迭代测试,运行它们,捕捉它们的结果,并打印出结果。因此,它还包括解析命令行参数的逻辑(比如 --testthreads=1),捕获测试输出,并行运行列出的测试,并收集测试结果。

截至目前,Rust 的开发者正在努力使测试工具生成的魔法部分成为公开可用的 API,这样开发者就可以建立自己的测试工具。这项工作仍处于实验阶段,但该提案与目前存在的模型相当吻合。需要解决的部分问题是如何确保#[test] 函数对生成的主函数可用,即使它们在私有子模块中。

集成测试(tests/ 中的测试)遵循与单元测试相同的过程,但有一个例外,它们被编译为自己独立的板块,这意味着它们只能访问主板块的公共接口,并针对没有#[cfg(test)] 编译的主板块运行。测试工具为 tests/ 中的每个文件生成。测试工具不为 tests/ 下的子目录中的文件生成,以允许你为你的测试共享子模块。

注意:如果你明确想要一个子目录下的文件的测试工具,你可以通过调用文件 main.rs 选择加入。

Rust 并不要求你使用默认的测试工具。你可以选择不使用它,而是通过在 Cargo.toml 中为特定的集成测试设置 harness = false 来实现你自己的主方法,代表测试运行器,如清单 6-1 所示。你定义的主方法将被调用来运行测试

#![allow(unused)]
fn main() {
[[test]]
name = "custom"
path = "tests/custom.rs"
harness = false

// 清单 6-1:选择不使用标准测试工具
}

没有测试工具,#[test] 周围的魔法都不会发生。相反,你需要自己写一个主函数来运行你想执行的测试代码。从本质上讲,你在写一个普通的 Rust 二进制文件,只是碰巧被货物测试所运行。该二进制文件负责处理默认工具通常做的所有事情(如果你想支持它们的话),比如命令行标志。工具属性是为每个集成测试单独设置的,所以你可以有一个使用标准工具的测试文件和一个不使用的测试文件。

默认测试工具的参数

默认的测试工具支持一些命令行参数来配置测试的运行方式。这些参数不是直接传给 cargo test 的,而是传给 Cargo 在运行 cargo test 时为你编译和运行的测试二进制。要访问这组标志,请将 -- 传给 cargo test,然后是测试二进制文件的参数。例如,要查看测试二进制文件的帮助文本,你可以运行 cargo test -- --help

通过这些命令行参数,有许多方便的配置选项。--nocapture 标志禁用了运行 Rust 测试时通常发生的输出捕获。如果你想实时观察测试的输出,而不是在测试失败后一下子观察输出,这很有用。你可以使用--test-threads 选项来限制同时运行的测试数量,如果你有一个测试挂起或出现 segfaults,而你想通过连续运行测试来找出是哪一个,这很有帮助。 还有一个--skip 选项用于跳过符合某种模式的测试,--ignored 用于运行通常会被忽略的测试(比如那些需要外部程序运行的测试),以及--list 用于仅仅列出所有可用测试。

请记住,这些参数都是由默认的测试工具实现的,所以如果你禁用它(用 harness = false),你就必须在你的主函数中自己实现你需要的参数。

没有工具的集成测试主要用于基准测试,我们将在后面看到,但当你想运行不符合标准的 "一个功能,一个测试 "模式的测试时,它们也很有用。例如,你会经常看到无工具测试用于模糊器、模型检查器和需要自定义全局设置的测试(如在 WebAssembly 下或与自定义目标一起工作时)。

#[cfg(test)]

当 Rust 为测试而构建代码时,它设置了编译器配置标志 test,然后你可以用条件编译来使代码被编译出来,除非它是专门被测试的。从表面上看,这似乎很奇怪:难道你不想测试与生产中的代码完全相同的代码吗?你想,但在测试时有专门的代码可以让你写出更好、更彻底的测试,在几个方面。

嘲讽 (MOCKING)

在编写测试时,你经常想严格控制你所测试的代码,以及你的代码可能与之交互的任何其他类型。例如,如果你正在测试一个网络客户端,你可能不想在一个真实的网络上运行你的单元测试,而是想直接控制 "网络 "发射的字节和时间。或者,如果你在测试一个数据结构,你希望你的测试使用的类型允许你控制每个方法在每次调用时返回什么。你可能还想收集一些指标,比如某个方法被调用的频率,或者某个字节序列是否被发射出来。

这些 "假的"类型和实现被称为模拟(mocks),它们是任何广泛的单元测试套件的一个关键 feature。虽然你经常可以手动完成这种控制所需的工作,但如果有一个库为你处理大部分琐碎的细节,那就更好了。这就是自动化模拟的作用。一个模拟库将有生成具有特定属性或签名的类型(包括函数)的设施,以及在测试执行期间控制和反省这些生成的项目的机制。

Rust 中的 Mocking 通常是通过泛型发生的--只要你的程序、数据结构、框架或工具在你可能想要 Mock 的东西上是泛型的(或需要一个 trait 对象),你就可以使用 Mock 库来生成符合要求的类型,将那些泛型参数实例化。然后,通过用生成的模拟类型实例化你的通用结构来编写单元测试,你就可以开始比赛了!

在泛型不方便或不合适的情况下,比如你想避免让你的类型的某一方面对用户来说是泛型的,你可以把你想模拟的状态和行为封装在一个专门的结构中。然后你可以生成该结构及其方法的模拟版本,并根据 cfg(test) 或类似 cfg(feature = "test_mock_foo" 的测试专用特性,使用条件编译来使用真实或模拟的实现。)

目前,在 Rust 社区,还没有一个单一的嘲弄库,甚至没有一个单一的嘲弄方法,成为唯一的答案。据我所知,最广泛、最彻底的嘲讽库是 mockall crate,但它仍在积极开发中,而且还有许多其他竞争者。

只测试 api (Test-Only APIs)

首先,只有测试的代码允许你向你的(单元)测试暴露额外的方法、字段和类型,这样测试不仅可以检查公共 API 的行为是否正确,还可以检查内部状态是否正确。例如,考虑来自 hashbrownHashMap 类型,它是实现标准库 HashMap 的 crate。HashMap 类型实际上只是 RawTable 类型的一个封装,RawTable 实现了大部分的哈希表逻辑。假设在对一个空地图进行 HashMap::insert 操作后,你想检查地图中的一个桶是否为非空,如清单 6-2 所示。

#![allow(unused)]
fn main() {
#[test]
fn insert_just_one() {
    let mut m = HashMap::new();
    m.insert(42, ());
    let full = m.table.buckets.iter().filter(Bucket::is_full).count();
    assert_eq!(full, 1);
}

// 清单 6-2:一个访问不可访问的内部状态的测试,因此不能编译。
}

这段代码将不能按原样编译,因为虽然测试代码可以访问 HashMap 的私有表字段,但它不能访问 RawTable 的同样私有的桶字段,因为 RawTable 生活在不同的模块中。 我们可以通过使桶字段可见性 pub(crate) 来解决这个问题,但我们真的不希望 HashMap 能够一般地接触桶,因为它可能意外地破坏 RawTable 的内部状态。即使是将水桶设为只读也会有问题,因为 HashMap 中的新代码可能会开始依赖于 RawTable 的内部状态,从而使未来的修改更加困难。

解决办法是使用#[cfg(test)]。我们可以给 RawTable 添加一个方法,只允许在测试时访问 buckets,如清单 6-3 所示,从而避免在其余的代码中添加脚步声。然后,清单 6-2 中的代码可以被更新为调用 buckets() 而不是访问私有的 buckets 字段。

#![allow(unused)]
fn main() {
impl RawTable {
    #[cfg(test)]
    pub(crate) fn buckets(&self) -> &[Bucket] {
        &self.buckets
    }
}

// 清单 6-3:使用 `#[cfg(test)]` 来使内部状态在测试环境中可被访问。
}

测试断言的簿记

拥有只在测试期间存在的代码的第二个好处是,你可以增强程序以执行额外的运行时簿记,然后可以被测试检查。例如,设想你正在编写你自己的标准库中的 BufWriter 类型的版本。当测试它时,你想确保 BufWriter 不会不必要地发出系统调用。最明显的方法是让 BufWriter 跟踪它在底层 Write 上调用了多少次写入。然而,在生产中,这个信息并不重要,而且保持跟踪会带来(边际的)性能和内存开销。通过 #[cfg(test)],你可以让记账只在测试时发生,如清单 6-4 中所示。

#![allow(unused)]
fn main() {
struct BufWriter<T> {
    #[cfg(test)]
    write_through: usize,
    // other fields...
} 

impl<T: Write> Write for BufWriter<T> {
    fn write(&mut self, buf: &[u8]) -> Result<usize> {
    // ...
    if self.full() {
        #[cfg(test)]
        self.write_through += 1;
        let n = self.inner.write(&self.buffer[..])?;
        // ...
    }
}

// 清单 6-4:使用#[cfg(test)] 将记账限制在测试环境中。
}

请记住,test 只为被编译为测试的 crate 而设置。对于单元测试,这就是被测试的 crate,正如你所期望的那样。然而,对于集成测试,它是被编译为测试的集成测试二进制文件--你正在测试的 crate 只是被编译为一个库,所以不会有测试设置。

文档测试

文档注释中的 Rust 代码片段会自动作为测试案例运行。由于 doctests 出现在你的 crate 的公共文档中,并且用户可能会模仿它们所包含的内容,所以它们被作为集成测试运行。这意味着 doctests 不能访问私有字段和方法,并且测试不设置在主 crate 的代码上。每个测试都被编译为它自己的专用 crate,并在隔离状态下运行,就像用户把测试复制到他们自己的程序中一样。

在幕后,编译器对测试进行了一些预处理,使其更加简洁。最重要的是,它自动在你的代码周围添加一个 fn main。这使得测试只关注用户可能关心的重要部分,比如实际使用你的库的类型和方法的部分,而不包括不必要的模板。

你可以通过在 doctest 中定义你自己的 fn main 来选择不使用这种自动包装。你可能想这样做,例如,如果你想用 #[tokio::main] async fn main 这样的方法写一个异步 main 函数,或者你想在 doctest 中添加额外的模块。

要在你的测试中使用 ? 操作符,你通常不必使用自定义的主函数,因为 rustdoc 包括一些启发式方法,如果你的代码看起来像使用了 ? (例如,如果它以 Ok(()) 结尾),就将返回类型设置为 Result<(), impl Debug>。如果类型推理让你对函数的错误类型感到为难,你可以通过改变 doctest 的最后一行来明确类型,像这样Ok::<(), T>(())来消除歧义。

Doctests 有一些额外的功能,当你为更复杂的接口编写文档时,这些功能会很方便。首先是隐藏个别行的能力。如果你在测试的某一行前面加上 #,那么当测试被编译和运行时,该行就会被包括在内,但它不会被包括在文档中生成的代码片段中。这可以让你轻松地隐藏那些对当前例子不重要的细节,例如为虚拟类型实现 trait 或生成值。如果你想展示一连串的例子,而不是每次都显示相同的引导代码,这也很有用。清单 6-5 给出了一个带有隐藏行的测试的例子。

#![allow(unused)]
fn main() {
/// Completely frobnifies a number through I/O.
///
/// In this first example we hide the value generation.
/// ```
/// # let unfrobnified_number = 0;
/// # let already_frobnified = 1;
/// assert!(frobnify(unfrobnified_number).is_ok());
/// assert!(frobnify(already_frobnified).is_err());
/// ```
///
/// Here's an example that uses ? on multiple types
/// and thus needs to declare the concrete error type,
/// but we don't want to distract the user with that.
/// We also hide the use that brings the function into scope.
/// ```
/// # use mylib::frobnify;
/// frobnify("0".parse()?)?;
/// # Ok::<(), anyhow::Error>(())
/// ```
///
/// You could even replace an entire block of code completely,
/// though use this _very_ sparingly:
/// ```
/// # /*
/// let i = ...;
/// # */
/// # let i = 42;
/// frobnify(i)?;
/// ```
fn frobnify(i: usize) -> std::io::Result<()> {

// 清单 6-5:用 `#` 隐藏测试中的行数
}

注意:小心使用这一功能;如果用户复制粘贴了一个例子,但由于你隐藏了必要的步骤而无法工作,这可能会让他们感到沮丧。

#[test] 函数非常相似,doctests 也支持修改 doctest 运行方式的属性。这些属性紧跟在用于表示代码块的三连击之后,多个属性可以用逗号分隔。

和测试函数一样,你可以指定 should_panic 属性来表示某个测试中的代码在运行时应该惊慌失措,或者指定 ignore 来检查代码段,只有在货物测试运行时使用--ignored 标志。你也可以使用 no_run 属性来表示一个给定的 doctest 应该编译但不应该运行。

属性 compile_fail 告诉 rustdoc,文档示例中的代码不应该被编译。这向用户表明某种特定的使用是不可能的,并作为一个有用的测试,提醒你在你的库的相关方面发生变化时要更新文档。你也可以使用这个属性来检查某些静态属性对你的类型是否成立。清单 6-6 显示了一个例子,说明你如何使用 compile_fail 来检查一个给定的类型没有实现 Send,这对于维护不安全代码的安全保证可能是必要的。

# struct MyNonSendType(std::rc::Rc<()>);
fn is_send<T: Send>() {}
is_send::<MyNonSendType>();

// 清单 6-6:用 `compile_fail` 测试代码是否编译失败

compile_fail 是一个相当粗糙的工具,因为它没有给出代码不能编译的原因。例如,如果代码因为缺少分号而不能编译,compile_fail 测试会显得很成功。出于这个原因,你通常只想在确定测试确实没有编译出预期的错误后再添加这个属性。如果你需要对编译错误进行更精细的测试,例如在开发宏时,可以看看 trybuild crate。

其他测试工具

测试有很多内容,而不仅仅是运行测试函数,看看它们是否产生了预期的结果。对测试技术、方法和工具的彻底调查超出了本书的范围,但有一些关键的 Rust 特定部分,在你扩展你的测试剧目时你应该知道。

Linting

你可能不认为 linter 的检查是测试,但在 Rust 中,它们往往可以是测试。Rust 的 linter clippy 把它的一些 lints 归类为正确性 lints。这些线程可以捕捉那些可以编译但几乎可以肯定是错误的代码模式。一些例子是 a = b; b = a,它不能交换 abstd::mem::forget(t),其中 t 是一个引用;以及 for x in y.next(),它将只迭代 y 中的第一个元素。如果你还没有把 clippy 作为你 CI 管道的一部分来运行,你可能应该这样做。

Clippy 还附带了一些其他的提示,虽然这些提示通常很有帮助,但可能比你所希望的更有意见。例如,type_complexity lint,默认是打开的,如果你在程序中使用一个特别复杂的类型,比如 Rc<Vec<Vec<Box<(u32, u32, u32, u32)>>>>,它会发出警告。虽然该警告鼓励你写出更容易阅读的代码,但你可能会发现它太过迂腐而没有广泛的用途。如果你的代码的某些部分错误地触发了一个特定的警告,或者你只是想允许它的一个特定实例,你可以用 #[allow(clippy::name_of_lint)] 来选择不对那段代码进行警告。

Rust 编译器也以警告的形式提供了它自己的提示,尽管这些提示通常更倾向于编写成语代码而不是检查正确性。相反,编译器中的正确性提示被简单地视为错误(看看 rustc -W help 的列表)。

注意:不是所有的编译器警告都是默认启用的。那些默认禁用的警告通常还在完善中,或者更多的是关于风格而不是内容。一个很好的例子是 "习惯用语 Rust 2018 版" 警告,你可以用 #![warn(rust_2018_idioms)] 启用它。当这个提示被启用时,编译器会告诉你是否你没有利用 Rust 2018 版带来的变化。当你开始一个新项目时,你可能想养成启用其他一些提示的习惯,如 missing_docsmissing_debug_implementations,它们分别警告你是否忘记记录 crate 中的任何公共项目或为任何公共类型添加 Debug 实现。

测试生成(Test Generation)

编写一个好的测试套件是一项艰巨的工作。即使你做了这些工作,你写的测试也只是测试你在写这些测试时考虑的特定行为集。幸运的是,你可以利用一些测试生成技术来开发更好和更彻底的测试。这些测试生成输入,供你用来检查你的应用程序的正确性。存在许多这样的工具,每一个都有自己的优势和劣势,所以在这里我只介绍这些工具使用的主要策略:模糊测试和属性测试。

模糊测试(Fuzzing)

关于 fuzzing 的书已经写了一整本,但在高层次上,这个想法很简单:给你的程序生成随机输入,看看它是否崩溃。如果程序崩溃了,那就是一个错误。例如,如果你正在编写一个 URL 解析库,你可以通过系统地生成随机字符串并将它们扔给解析函数,直到它崩溃为止,来对你的程序进行模糊测试。天真地做,这将需要一段时间来产生结果:如果模糊器从 a 开始,然后是 b,然后是 c,以此类推,它将需要很长时间来产生一个棘手的 URL,如 http://[:]。 在实践中,现代模糊器使用代码覆盖率指标来探索你的代码中的不同路径,这让他们比真正随机选择的输入更快地达到更高的覆盖度。

模糊器能很好地发现你的代码不能正确处理的奇怪角落情况。它们不需要你做什么设置:你只需将模糊器指向一个接受 "可模糊 "输入的函数,然后它就开始了。例如,清单 6-7 显示了一个如何对 URL 解析器进行模糊测试的例子。

#![allow(unused)]
fn main() {
libfuzzer_sys::fuzz_target!(|data: &[u8]| {
    if let Ok(s) = std::str::from_utf8(data) {
        let _ = url::Url::parse(s);
    }
});

// 清单 6-7:用 libfuzzer 对一个 URL 解析器进行模糊处理
}

模糊器将产生半随机的输入到闭包中,任何形成有效 UTF-8 字符串的输入都将被传递给解析器。 注意,这里的代码并不检查解析是否成功,而是寻找解析器因违反内部不变性而恐慌或崩溃的情况。

模糊器一直在运行,直到你终止它,所以大多数模糊工具都有一个内置机制,在探索了一定数量的测试案例后停止。如果你的输入不是像哈希表那样的琐碎的可模糊的类型,你通常可以使用像 Arbitrary 这样的工具来把模糊器产生的字节串变成更复杂的 Rust 类型。这感觉就像魔法一样,但在引擎盖下,它实际上是以一种非常简单的方式实现的。Crate 定义了一个 Arbitrary trait,它有一个方法,即 arbitrary,可以从随机字节的源头构造实现类型。像 u32bool 这样的原始类型从输入中读取必要数量的字节来构造自己的有效实例,而像 HashMapBTreeSet 这样更复杂的类型从输入中产生一个数字来决定其长度,然后在其内部类型上调用 Arbitrary 的次数。甚至还有一个属性,#[derive(Arbitrary)],它通过在每个包含的类型上调用 Arbitrary 来实现 Arbitrary! 要进一步探索模糊处理,我建议从 cargo-fuzz 开始。

基于属性的测试 (Property-Based Testing)

有时你不仅要检查你的程序是否崩溃,而且要检查它是否做了预期的事情。你的加法函数没有惊慌失措,这很好,但如果它告诉你 add(1, 4) 的结果是 68,它可能还是错的。这就是基于属性的测试发挥作用的地方;你描述了一些你的代码应该坚持的属性,然后属性测试框架生成输入并检查这些属性是否确实成立。

使用基于属性的测试的一个常见方法是,首先写一个你想测试的代码的简单但天真的版本,你相信它是正确的。然后,对于一个给定的输入,你把这个输入同时给你想测试的代码和简化但天真的版本。如果两个实现的结果或输出是相同的,你的代码就是好的,这就是你要找的正确性属性,但如果不是,你很可能发现了一个错误。你也可以使用基于属性的测试来检查与正确性没有直接关系的属性,比如一个实现的操作所花费的时间是否比另一个少。共同的原则是,你希望真实版本和测试版本之间的任何结果差异都是有参考价值的,并且是可操作的,这样每一次失败都可以让你做出改进。天真的实现可能是你试图替换或增强的标准库中的一个(如 std::collections::VecDeque),也可能是你试图优化的算法的一个简单版本(如天真与优化的矩阵乘法)。

如果这种生成输入直到满足某些条件的方法听起来很像模糊测试,那是因为它是--比我更聪明的人认为,模糊测试 "只是 "基于属性的测试,你要测试的属性是 "它不会崩溃"。

基于属性的测试的一个缺点是,它更依赖于所提供的输入描述。模糊测试会不断尝试所有可能的输入,而属性测试则倾向于由开发者的注释来指导,如 "0 到 64 之间的数字 "或 "包含三个逗号的字符串"。这使得属性测试能更快地达到模糊测试可能需要很长时间才能随机遇到的情况,但它确实需要手工操作,并可能错过重要但小众的错误输入。然而,随着模糊器和属性测试人员的关系越来越密切,模糊器也开始获得这种基于约束的搜索能力。

如果你对基于属性的测试生成感到好奇,我建议从 proptest crate 开始。

测试操作序列

由于模糊器和属性测试器允许你生成任意的 Rust 类型,你并不局限于测试 crate 中的单个函数调用。例如,如果你想测试某种类型的 Foo 在执行特定的操作序列时的行为是否正确,你可以定义一个列举操作的枚举,并使你的测试函数接受一个 Vec<Operation>。然后你可以实例化一个 Foo,并对该 Foo 一个接一个地执行每个操作。大多数测试器都支持最小化输入,所以如果发现有违反属性的输入,它们甚至会搜索仍然违反属性的最小操作序列。

扩张测试(Test Augmentation)

比方说,你有一个宏伟的测试套件,而且你的代码通过了所有的测试。这是很光荣的。但是,有一天,其中一个通常可靠的测试莫名其妙地失败了,或者因为分割故障而崩溃了。这类非确定性测试失败有两个常见的原因:竞争条件,只有当两个操作以特定的顺序发生在不同的线程上时,你的测试才可能失败,以及不安全代码中的未定义行为,例如,如果一些不安全的代码从未初始化的内存中读取一个特定的值。

用普通的测试来捕捉这类错误可能很困难--通常你对线程调度、内存布局和内容或其他随机的系统因素没有足够的底层控制来编写一个可靠的测试。你可以在一个循环中多次运行每个测试,但如果坏的情况足够罕见或不可能,即使这样也可能抓不到错误。幸运的是,有一些工具可以帮助增强你的测试,使捕捉这些类型的错误更加容易。

其中第一个是惊人的工具 Miri,它是 Rust 的中级中间表示法(MIR)的解释器。MIR 是 Rust 的一个内部简化表示,它可以帮助编译器找到优化和检查属性,而不必考虑 Rust 本身的所有语法糖。通过 Miri 运行你的测试就像运行 cargo miri test 一样简单。Miri 解释你的代码,而不是像普通的二进制文件那样编译和运行,这使得测试的运行速度相当慢。但作为回报,Miri 可以在你的每一行代码的执行过程中跟踪整个程序状态。这使得 Miri 能够检测并报告你的程序是否表现出某些类型的未定义行为,比如未初始化的内存读取、在数值被丢弃后的使用,或者越界指针访问等。与其让这些操作产生奇怪的程序行为,有时可能只导致可观察到的测试失败(如崩溃),Miri 会在它们发生时发现它们,并立即告诉你。

例如,考虑清单 6-8 中非常不健全的代码,它创建了对一个值的两个独占引用。

#![allow(unused)]
fn main() {
let mut x = 42;
let x: *mut i32 = &mut x;let (x1, x2) = unsafe { (&mut *x, &mut *x) };
println!("{} {}", x1, x2)

// 清单 6-8:Miri 检测到的疯狂的不安全代码是不正确的
}

在写这篇文章的时候,如果你通过 Miri 运行这段代码,你会得到一个错误,准确地指出了问题所在。

#![allow(unused)]
fn main() {
error: Undefined Behavior: trying to reborrow for Unique at alloc1383, but parent
tag <2772> does not have an appropriate item in the borrow stack
--> src/main.rs:4:6
|
4 | let (x1, x2) = unsafe { (&mut *x, &mut *x) };
| ^^ trying to reborrow for Unique at alloc1383, but parent tag <2772> does
not have an appropriate item in the borrow stack
}

注意:Miri 仍在开发中,其错误信息并不总是最容易理解的。这是一个正在积极解决的问题,所以当你读到这篇文章时,错误输出可能已经有了很大的改善。

另一个值得关注的工具是 Loom,这是一个聪明的库,它试图确保你的测试在运行时有每个相关的交错并发操作。在高水平上,Loom 跟踪所有的跨线程同步点,并反复运行你的测试,每次都会调整线程从这些同步点出发的顺序。因此,如果线程 A 和线程 B 都使用同一个 MutexLoom 会确保测试运行一次时,A 先使用,另一次时 B 先使用。Loom 还跟踪原子访问、内存顺序和对 UnsafeCell 的访问(我们将在第 9 章讨论),并检查线程是否对它们进行了不适当的访问。如果测试失败了,Loom 可以给你一个确切的清单,说明哪些线程以什么顺序执行,这样你就可以确定崩溃是如何发生的。

性能测试 (Performance Testing)

编写性能测试是很困难的,因为通常很难准确地建立一个反映真实世界使用情况的工作负载模型。但有这样的测试是很重要的;如果你的代码突然运行慢了 100 倍,那真的应该被认为是一个 bug,但如果没有性能测试,你可能不会发现这种回归。如果你的代码运行速度快了 100 倍,这也可能表明有什么地方不对劲。这两种情况都是将自动化性能测试作为 CI 的一部分的很好的理由--如果性能在任何一个方向上发生剧烈变化,你都应该知道。

与功能测试不同,性能测试没有一个共同的、定义明确的输出。一个功能测试要么成功,要么失败,而性能测试可能会给你一个吞吐量数字,一个延迟曲线,一个处理的样本数量,或任何其他可能与有关应用程序相关的指标。 此外,一个性能测试可能需要在一个循环中运行一个函数几十万次,或者它可能需要在一个多核盒子的分布式网络中运行几个小时。由于这个原因,我们很难谈论如何在一般意义上编写性能测试。相反,在本节中,我们将探讨在 Rust 中编写性能测试时可能遇到的一些问题,以及如何缓解这些问题。有三个特别常见的陷阱经常被忽视,即性能差异、编译器优化和 I/O 开销。让我们依次来探讨这些问题。

性能差异(Performance Variance)

性能的变化有很多原因,许多因素会影响一个特定的机器指令序列的运行速度。有些是显而易见的,比如 CPU 和内存的时钟速度,或者机器的负载情况,但许多因素则更为微妙。例如,你的内核版本可能会改变分页性能,你的用户名的长度可能会改变内存的布局,房间里的温度可能会导致 CPU 时钟下降。最终,如果你运行一个基准测试两次,你会得到相同的结果,这是非常不可能的。事实上,你可能会观察到明显的差异,即使你使用的是相同的硬件。或者,从另一个角度来看,你的代码可能变得更慢或更快,但由于基准测试环境的不同,这种影响可能是看不见的。

没有完美的方法来消除性能结果中的所有差异,除非你碰巧能够在高度多样化的机器上反复运行基准测试。即便如此,重要的是尽量处理这种测量差异,以便从基准给我们的嘈杂测量中提取信号。在实践中,我们对抗差异的最好的朋友是多次运行每个基准,然后看一下测量值的分布,而不仅仅是一个单一的。Rust 有一些工具可以帮助我们做到这一点。例如,不要问 "这个函数平均运行了多长时间?"像 hdrhistogram 这样的工具箱使我们能够查看 "运行时间的哪个范围涵盖了我们观察到的 95%的样本 "这样的统计数据。为了更加严格,我们可以使用统计学中的空假设检验等技术来建立一些信心,即测量的差异确实对应于真实的变化,而不仅仅是噪音。

关于统计假设检验的讲座超出了本书的范围,但幸运的是,这些工作大部分已经由其他人完成。例如,criterion crate 为你做了所有这些,甚至更多。你所要做的就是给它一个函数,它可以调用这个函数来运行你的基准的一次迭代,它将运行适当的次数,以保证结果的可靠性。然后,它产生一个基准报告,其中包括结果的总结,对异常值的分析,甚至还有随时间变化的趋势的图形表示。当然,它不能消除只是在特定的硬件配置上进行测试的影响,但它至少可以对跨执行的可测量的噪音进行分类。

编译器优化

现在的编译器真的很聪明。他们消除死代码,在编译时计算复杂的表达式,解开循环,并执行其他黑暗的魔法来榨取我们代码中的每一滴性能。通常这很好,但是当我们试图测量某段代码的速度时,编译器的聪明才智会给我们带来无效的结果。例如,以清单 6-9 中的 Vec::push 为基准的代码为例。

#![allow(unused)]
fn main() {
let mut vs = Vec::with_capacity(4);
let start = std::time::Instant::now();
for i in 0..4 {
    vs.push(i);
}
println!("took {:?}", start.elapsed());

// 清单 6-9:一个可疑的快速性能基准测试
}

如果你用 godbolt.org 或 cargo-asm 之类的软件看一下在发布模式下编译的这段代码的汇编输出,你会立即注意到有些问题:对 Vec::with_capacityVec::push 的调用,乃至整个 for 循环,都不见踪影。它们已经被完全优化了。编译器意识到,代码中没有任何东西真正需要执行向量操作,因此将它们作为死代码消除了。当然,编译器完全有权利这样做,但对于基准测试来说,这并不是特别有用。为了避免基准测试中的这类优化,标准库提供了 std::hint::black_box。这个函数一直是很多人争论和困惑的话题,在写这篇文章的时候还在等待稳定化,但它非常有用,值得在这里讨论一下。在其核心部分,它只是一个身份函数(一个接收 x 并返回 x 的函数),告诉编译器假设函数的参数是以任意(合法)的方式使用的。它并不阻止编译器对输入参数进行优化,也不阻止编译器对返回值的使用方式进行优化。相反,它鼓励编译器实际计算函数的参数(假设它将被使用),并将结果存储在 CPU 可以访问的地方,这样 black_box 就可以带着计算值被调用。编译器可以自由地,比如说,在编译时计算输入参数,但它仍然应该把结果注入到程序中。

这个函数是我们对许多(尽管不是全部)基准测试需求的全部需要。例如,我们可以对清单 6-9 进行注释,使向量访问不再被优化掉,如清单 6-10 所示。

#![allow(unused)]
fn main() {
let mut vs = Vec::with_capacity(4);
let start = std::time::Instant::now();
for i in 0..4 {
    black_box(vs.as_ptr());
    vs.push(i);
    black_box(vs.as_ptr());
}
println!("took {:?}", start.elapsed());

// 清单 6-10:清单 6-9 的修正版
}

我们已经告诉编译器,假设在循环的每个迭代中,在调用 push 之前和之后,都以任意的方式使用 vs。这迫使编译器按顺序执行每个 push,而不合并或以其他方式优化连续的调用,因为它必须假定在每次调用之间 vs 可能发生 "无法优化的任意事情"(这是 black_box 部分)。

注意,我们使用了 vs.as_ptr() 而不是 &vs,这是因为有一个注意事项,即编译器应该假定 black_box 可以对其参数进行任何合法操作。通过共享引用突变 Vec 是不合法的,所以如果我们使用 black_box(&vs),编译器可能会注意到 vs 在循环的迭代之间不会改变,并根据这一观察进行优化。

I/O 开销的测量 (I/O Overhead Measurement)

在编写基准时,很容易意外地测量错误的东西。例如,我们经常想实时获得有关基准运行情况的信息。为了做到这一点,我们可能会编写清单 6-11 中那样的代码,目的是测量 my_function 的运行速度。

#![allow(unused)]
fn main() {
let start = std::time::Instant::now();
for i in 0..1_000_000 {
    println!("iteration {}", i);
    my_function();
} 
println!("took {:?}", start.elapsed());

// 清单 6-11:我们在这里真正的基准是什么?
}

这看起来似乎达到了目的,但实际上,它并没有真正衡量 my_function 的速度。相反,这个循环最可能的是告诉我们打印一百万个数字需要多长时间。循环主体中的 println! 在幕后做了很多工作:它把一个二进制整数变成十进制数字以便打印,锁定标准输出,使用至少一个系统调用写出一串 UTF-8 代码点,然后释放标准输出锁。 不仅如此,如果你的终端打印出它收到的输入的速度很慢,系统调用可能会阻塞。那是一个很大的周期!而调用 my_function 的时间可能就显得苍白了。

当你的基准使用随机数时也会发生类似的事情。如果你在一个循环中运行 my_function(rand::random()),你很可能主要是在测量生成一百万个随机数的时间。对于获取当前时间、读取配置文件或启动新线程,情况也是一样的--相对而言,这些事情都需要很长的时间,最终可能掩盖了你真正想要测量的时间。

幸运的是,一旦你意识到这个特殊的问题,往往很容易解决。确保你的基准测试循环的主体除了你想测量的特定代码外几乎什么都不包含。所有其他的代码应该在基准测试开始之前或者在基准测试的测量部分之外运行。如果你在使用 criterion,看看它提供的不同的计时循环--它们都是为了满足需要不同测量策略的基准测试情况而存在的!

总结

在这一章中,我们非常详细地探讨了 Rust 提供的内置测试功能。我们还研究了一些在测试 Rust 代码时很有用的测试设施和技术。 这是本书中最后一章,重点讨论 Rust 中级使用的更高层次的问题。从下一章的声明性和过程性宏开始,我们将更多地关注 Rust 代码。下一页见!