Extend Rust's Testing

本文内容来源:《Rust for Rustaceans》第六章

Rust testing mechanism

testing harness

rust中常用的测试分为两类,内置在代码文件的单元测试,和独立的tests目录的集成测试。当运行cargo test时,cargo会传一个--test标志给rustc,告诉它要产出一个测试二进制,依据cfg使得测试可以依据特定条件触发,同时生成一个test harness——一个会在运行时触发所有test函数的,精心生成的main函数

harness 用一个过程宏把所有#[test]标记的函数转化为一个测试描述符,然后暴露给main函数。harness 会扫描所有函数、运行、捕获结果并打印输出

对于集成测试,唯一的区别是它们会被编译成独立的crate,只能访问main模块的pub部分,不包含#[cfg(test)]标识的函数。同时,对于每个tests目录下的文件,会生成一个独立的harness,不包括子目录里的,他们被默认为辅助代码。

如果想要把tests/的子目录下的文件也编译成一个harness,则需要把文件名命名为main.rs

自定义harness

以上内容是cargo test的默认行为,但不是定死的,在cargo.toml里设置:

[[test]]
name = "custom"
path = "tests/custom.rs"
harness = false

就可以关闭自动生成的main函数,然后从处理命令行参数开始,自己定义一切。

对于每个集成测试,这个配置是分离的,即可以部分测试自定义,其余按照默认行为

集成测试的自定义harness主要用于benchmarks

default test harness arguments

提供的参数分为两部分,前一部分给cargo 后一部分给程序harness,中间以--分隔

以下是五个常用的harness参数:

  • nocapture 捕获程序输出,也是我平时用的最多的,harness默认不输出测试函数的打印内容,此参数是要求harness正常输出测试函数
  • test-thread=1 设置测试线程数,比如强制单线程,harness默认是并行进行多个测试的
  • skip 跳过符合指定条件的测试
  • ignored harness支持识别#[ignored]标记,默认跳过它们,仅在收到--ignored下执行,用于一些要求特殊的测试
  • list 不执行,只列举

cfg(test)

测试时有一个可用的configration flag名为test,借助它可以实现测试和实际运行时使用不同的代码

[cfg(test)]的作用范围是它紧邻的下一项,可以是一个模块(比如mod tests) 一个函数或者结构体,甚至是一行声明或者一行实际代码

mocking

当我们写一个需要外部依赖的函数,比如一个网络连接客户端,如果用定死的类型,测试就只能走真实的网络,引入了若干不必要的变量的同时,可控性也在下降,既麻烦又难用、极其不优雅,更理想的实现是让我可以轻易的控制输入的每一个字节

于是我们就需要mock,mock在rust中通常通过泛型实现,比如:

// 定义一个特征:凡是能获取温度的,都要实现这个方法
pub trait WeatherApi {
    fn get_temperature(&self) -> i32;
}

// 为我们需要的结构体定义此特征 
pub struct RealNetworkClient;

impl WeatherApi for RealNetworkClient {
    fn get_temperature(&self) -> i32 {
        // 这里会写几十行代码:发起真实的 HTTP 请求,解析 JSON...
        println!("正在连接真实气象局...");
        15 // 假设真实网络返回 15 度
    }
}

// 应用Generics(泛型)
pub struct AirConditioner<T: WeatherApi> {
    api: T,
}

// 使用时,只需要应用这个泛型即可
impl<T: WeatherApi> AirConditioner<T> {
    pub fn check_and_turn_on_heater(&self) -> bool {
        let temp = self.api.get_temperature();
        temp < 10 // 低于 10 度返回 true(开暖气)
    }
}

// 测试时,可以很容易地把真实客户端换成mock的
#[cfg(test)]
mod tests {
    use super::*;

    // 制造一个假的网络客户端
    struct MockNetwork {
        mocked_temp: i32, // 一个完全可控的字段
    }

    // 让假网络也实现 WeatherApi
    impl WeatherApi for MockNetwork {
        fn get_temperature(&self) -> i32 {
            self.mocked_temp 
        }
    }

    #[test]
    fn test_heater_turns_on_when_cold() {
        let fake_api = MockNetwork { mocked_temp: 5 }; 
        let ac = AirConditioner { api: fake_api };

        assert_eq!(ac.check_and_turn_on_heater(), true);
    }
}

另一个麻烦但可行的办法是利用#[cfg(test)]和#[cfg(not(test))]选择性编译,区分测试与真实环境

// 1. 正常编译时
#[cfg(not(test))] 
pub struct NetworkClient;

#[cfg(not(test))]
impl NetworkClient {
pub fn get_temperature(&self) -> i32 {
        // 真实的 HTTP 请求代码
    }
}

// 2. 测试时(cargo test),使用这段代码
#[cfg(test)] 
pub struct NetworkClient {
    pub mocked_temp: i32, // 测试专用字段
}

#[cfg(test)]
impl NetworkClient {
    pub fn get_temperature(&self) -> i32 {
        self.mocked_temp 
    }
}

// 3. 业务代码(没有任何泛型,看起来很简单)
pub struct AirConditioner {
    api: NetworkClient, 
}

impl AirConditioner {
    pub fn check_and_turn_on_heater(&self) -> bool {
        let temp = self.api.get_temperature();
        temp < 10
    }
}

其它应用

  • Test-Only APIs :测试时如果想访问一个自定义的类型的非public方法,最直接的办法是补上pub(crate)改成公开的,但如果又不想把它暴露给其它模块,同样可以用#[cfg(test)]标记
  • 数据记录 :比如统计自己写的函数在某个测试里被调用了多少次,就可以补充一个仅测试计数器

需要特别注意以上仅适用于单元测试

Doctests

如果写文档和写代码、写测试是分开的,那么难免会遇到文档说明和实际不匹配的情况,那么莫不如让文档也支持测试

Doctests和集成测试类似,只能访问public的部分,执行测试时每个doctest都会被编译到一个独立的模块,然后独立地执行,写起来像这样:

/// 将数字加一。
///
/// # Examples
///
/// ```rust 
/// let arg = 5;
/// let answer = my_crate::add_one(arg);
///
/// assert_eq!(6, answer);
/// ```
pub fn add_one(x: i32) -> i32 {
    x + 1
}

# 后的内容会在实际文档中被隐去,运行时会自动包上一层fn main() > Result<(),impl Debug>{},需要引入依赖时,可以直接使用use,想要替换默认的main函数,比如换成tokio::main,直接写即可,运行时发现已有main函数了就不会再套一层

额外属性

should_panic no_run和compile_fail

/// 演示如何删除系统根目录(极其危险)
///
/// # Examples
///
/// ```rust,no_run  <--这里 
/// use std::fs;
/// 
/// fs::remove_dir_all("/").unwrap(); 
/// ```
pub fn dangerous_operation() {}

用法类似,should_panic断言里面的代码会panic,no_run只检查编译问题不运行,compile_fail断言会编译错误(但是不提供报错的原因)

Additional Testing Tools

linting

代码规范检查,rustc自带有一些检查,比如编译时无数次告诉你哪些声明没有用到等等

但是在rust里提到Linting,大名鼎鼎的clippy是绝对绕不开的话题

运行cargo clippy即可得到一份苛刻的代码审阅报告,以及修改建议

test generation

测试生成工具,替代自己手写大量的测试

fuzzing

模糊测试,简单的说就是:生成随机的输入去看程序是否崩溃,用于测试奇怪的边界情况

用起来大致是这样的:

#![no_main]
use libfuzzer_sys::fuzz_target;

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

在内部,fuzzer会以覆盖率为指标尽可能多的探索代码中的不同路径,而非完全的随机生成

如果输入类型不是fuzzable的,比如hash table,可以使用arbitrary库把字节流转成更具体的rust类型,本质上是一个Arbitrary特征和一个arbitrary方法,建立了从随机字节到具体类型的实现

#[derive(Arbitrary)]属性可以自动实现

进一步探索涉及cargo-fuzz库

Property Based Testing

大多数时候仅仅是不崩溃还不够,更重要的是确保程序按照预期运行,于是需要Property Based Testing

流程大致是:首先写一个简单但原生于待测试代码且一定正确的函数,然后对于一个给定的输入,把它送给源代码和简化后的代码,如果两个实现的结果一致,则说明正确,这种模式在优化算法、修改标准实现等场合格外有用

同样的方法也可以用于比较用时

进一步的学习设计proptest crate

用法大致是:

use proptest::prelude::*;

fn super_fast_optimized_sort(arr: &mut [i32]) {
    // ... 极其复杂的逻辑 ...
}

fn naive_correct_sort(arr: &mut [i32]) {
    arr.sort();
}

proptest! {
    #[test]
    fn test_fast_sort_matches_naive_sort(
        // proptest 负责源源不断地生成各种奇怪的数组
        mut arr in any::<Vec<i32>>() 
    ) {
        let mut arr_for_naive = arr.clone();

        // 两边分别处理
        super_fast_optimized_sort(&mut arr);
        naive_correct_sort(&mut arr_for_naive);

        assert_eq!(arr, arr_for_naive);
    }
}

Test Augmentation

测试增强

设想一个场景,测试覆盖了所有部分,代码也通过了这些测试,但是运行时出现了崩溃。一般来说有两种情况,多线程竞争和unsafe UB,常规的测试手段无法处理这些情况,但是依然有工具

Miri

mid-level intermediate represnetation (MIR) 是rust代码预处理结束去掉语法糖之后,用于做借用检查的层面

miri 就是一个MIR 解释器,用于跟踪内存和指针,以捕捉UB,用法很简单cargo miri test

Loom

Loom 用于检查多线程竞争的情况,它会识别出尽可能所有的有意义的并发组合,比如获得锁的先后,然后各运行一遍测试代码

简单的说,A和B两个线程都持有同一个锁,Loom会保证测试一次A先持有,另一次B持有

Loom还会跟踪原子的访问、内存顺序和对UnsafeCell的访问,检查线程是否有不合适的访问

Performance Testing

如果代码突然慢了100倍,这显然也应该被视为一种bug,但是这个问题要比以上的更复杂,因而也无法给出一种一般意义上的写法

所以本节作者讲解了一些写performance test时可能遇到的问题和解决方法

Performance Variance 性能波动

造成性能波动的原因有很多,包括显性的CPU和内存差异和设备负载,还有隐性的比如内核版本更新引起分页策略改变,用户名长度可能改变内存的布局,甚至包括环境温度可能引发CPU降频等等,这些导致几乎不可能得到两次一模一样的Benchmark

合理的方法是测试每个Benchmark若干次然后观察指标分布

这里可靠的工具为Criterion,使用时只需要提供一个他可调用的函数去跑一个Benchmark上的迭代器即可,他会自动运行一个足够的次数,得到一个Benchmark report。虽然他也无法抹平全部的变量差异,但可以避免若干统计方法上的问题

use criterion::{black_box, criterion_group, criterion_main, Criterion};

// 1. 待测函数
fn fibonacci(n: u64) -> u64 {
    match n {
        0 | 1 => 1,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

// 2. 这是 Criterion 的配置函数
fn criterion_benchmark(c: &mut Criterion) {
    // bench_function 的第一个参数是这个测试的名字,会打印在终端里
    c.bench_function("fibonacci_20", |b| {
        // b.iter 里面的闭包,就是会被 Criterion 疯狂循环执行几千次的代码
        b.iter(|| {
            fibonacci(black_box(20))
        })
    });
}

// 3. 注册你的 benchmark 组,并生成自定义的 main 函数 (替代了默认的 harness)
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

运行cargo bench启动

Compiler Optimization 编译器优化

编译器优化在试图获取一个局部的性能时会起到反作用,比如删去一些后续没有用到的逻辑

标准做法是使用std::hint::black_box,效果是告诉编译器这里后续有一些用处,输入和输出的优化依然存在,但是不会凭空删除它们

I/O Overhead Measurement I/O测量

对于这样的代码:

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

预期可能是测量my_function的用时,实际上很可能是测出来一百万次输出的用时,他把一个二进制整数转成用于打印得到小数,给标准输出上锁,写一个UTF-8序列用至少一个syscall 然后释放锁,如果终端打印速度比较慢还会阻塞,几百个时钟周期甚至更多就消耗掉了!

类似的,如果循环里有随机数生成逻辑,也有一样的问题

幸运的是这些问题一旦意识到是可以比较容易避免的

criterion提供了不同的timing loops,迎合了各种不同的测量策略,比如把测试分为数据准备和实际执行,计时只关注后者等等