本文内容来源:《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,迎合了各种不同的测量策略,比如把测试分为数据准备和实际执行,计时只关注后者等等