测试是保证软件质量的关键一环,这一节主要讲 Cargo 还有怎么写测试,也包括如何为代码写文档,如何评估代码的性能。
基本上单元测试都会通过断言来判断是否输出相同的预期结果:
布尔值:
assert!(true); // 最简单的断言
assert!(a==b, "{} was not equal to {}", a, b);
let a = 23;
let b = 87;
assert_eq!(a, b, "{} and {} are not equal", a, b); // 相等
assert_ne!(); // 不等
debug_assert!
:类似于 assert!
,但这个不是放在专门测试代码里的,是放在业务代码里的。在默认的 Debug 开发下,可以给出一些断言来验证执行过程中的结果的正确性。
单元测试是轻量级的,可以快速进行的测试,针对都是一个独立的小功能进行测试,例如函数。
最简单的 Rust 单元测试:
// first_unit_test.rs
#[test]
fn basic_test() {
assert!(true);
}
生成二进制可执行文件:rustc --test first_unit_test.rs
。
运行该二进制可执行文件,结果:
➜ rust_projects ./first_unit_test
running 1 test
test basic_test ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
所有的测试默认是并行的,除非运行的时候通过环境变量指定测试的时候只要一个线程:RUST_TEST_THREADS=1
。
RUST_TEST_THREADS=1 ./first_unit_test
测试越来越复杂的时候,我们希望能把测试代码和程序逻辑代码分开。那么可以把所有与测试相关的代码放到一个模块里。这时候可以使用使用 #[cfg(test)]
。
cfg
用于条件编译,也不是仅仅可以用在测试代码里。它可以通过 flag 决定包含或者排除该部分代码。在 #[cfg(test)]
中,flag 就是 test
。意思就是说,只有运行 cargo test
的时候,测试代码才会被包含和编译进去。
比如说,编写一个函数用于生成测试用例,但你肯定不希望这个函数被包含在实际代码里。
先用 cargo 创建一个项目 cargo new unit_test --lib
,之后 lib.rs 代码修改如下(可以看到创建项目生成的样例里的测试就有 #[cfg(test)]
):
fn sum(a: i8, b: i8) -> i8 {
a + b
}
#[cfg(test)]
mod tests {
fn sum_inputs_outputs() -> Vec<((i8, i8), i8)> {
vec![((1, 1), 2), ((0, 0), 0), ((2, -2), 0)]
}
#[test]
fn test_sum() {
for (input, output) in sum_inputs_outputs() {
assert_eq!(crate::sum(input.0, input.1), output);
}
}
}
sum_inputs_outputs
用来生成输入和输出的测试用例,被用在 test_sum()
中。但是,要注意到这个函数没有 #[test]
注解。#[test]
属性可以使得代码不在最终发布的代码中。然而,sum_inputs_outputs
没有标记 #[test]
,但它放在了 test
模块中。
有一些情况下,你可能希望针对特定的输入是不能通过测试(失败)的,那么可能就想要测试框架可以断言在这种情况下是失败的。Rust 提供了一个 #[should_panic]
就是干这活的。
#[test]
#[should_panic]
fn this_panics() {
assert_eq!(1, 2);
}
如果从持续集成,或者说敏捷开发的角度,是不应该忽略测试的,更不应该随便删除测试代码。如果你确定不想使用某个测试,或者说整个测试特别的重量级(或许应该考虑重构测试了),那么可以使用 #[ignore]
。
pub fn silly_loop() {
for _ in 1..1_0000_0000 {}
}
#[cfg(test)]
mod tests {
#[test]
#[ignore]
fn test_silly_loop() {
crate::silly_loop();
}
}
单元测试可以用来测试私有接口和独立模块,集成测试则更像端到端的黑盒测试,针对的是公有的接口。从代码角度,两种并没有太大的区别。
新建一个库项目:
cargo new integration_test --lib
Rust 期望所有的集成测试都应该放在 tests/
文件夹中,所以创建一个 tests 目录,在 lib.rs 中写上一个简单的 sum
函数:
// integration_test/src/lib.rs
fn sum(a: i8, b: i8) -> i8 {
a + b
}
在 tests 文件夹中新建 sum.rs 文件,并写下第一个测试:
use integration_test::sum;
#[test]
fn sum_test() {
assert_eq!(sum(6, 8), 14);
}
使用 cargo test
运行测试,会发现 sum
默认是私有的,不能直接调用。加上给 lib.rs 中的 sum
函数加上 pub
后,再运行测试,就可以发现可以通过了。
这个例子太微不足道了,但它与单元测试的区别就在于它的用法就和外部的使用者一样,其实是不关心代码实现的,它不能用来测试私有的方法。
有时候可能需要有测试之前的准备工作,比如打开文件、连接数据库资源;测试之后也有清理资源,比如关闭文件、断开数据库或服务器连接。这些准备工作可以抽象出来成为单独的模块,提高代码的抽象程度和可读性,避免代码冗余。
建立一个 common.rs:
// integration_test/tests/common.rs
pub fn setup() {
println!("Setting up fixture");
}
pub fn teardown() {
println!("Tearing down");
}
为了在 sum.rs 中使用这些代码,首先使用 mod
声明:
// integration_test/tests/sum.rs
use integration_test::sum;
mod common;
use common::{setup, teardown};
#[test]
fn test_with_fixture() {
setup();
assert_eq!(sum(7, 14), 21);
teardown();
}
#[test]
fn sum_test() {
assert_eq!(sum(6, 8), 14);
}
如果你使用 cargo test
会发现,println!
的内容似乎没有被打印出来。那是因为默认这些内容都会被捕获,并不会直接输出来:
如果你像看到打印语句输出,可以使用 cargo test test_with_fixture -- --nocapture
:
--
是必要的,因为我们想要传递 --nocapture
标志到测试运行。--
标志着 cargo 自身参数的结束,接下来的参数会被传递到被 cargo 调用的二进制执行文件里。
文档在任何软件中都是至关重要的,无论代码写得多好,没有人喜欢看别人写的代码。文档就是告诉使用者 API 的用法、参数要求并且适当给出例子。在 Github 上,一个良好的 README.md 可以很好的提高项目的可发现性。
文档被划分成两个级别,并用不同的注释符号标记:
///
,如果是多行注释,可以使用 /**
开头,并使用 */
结束。//!
作为单行测试,多行注释由/*!
开头,*/
结束。在文档注释里,可以使用 Markdown 语法,比如:
```let a = 23; ```
它也将成为文档测试(documentation test)的一部分。
上面这些符号其实是 #[doc="...."]
的语法糖,这些符号也会被解析成这个文档属性。
要生成文档,可以使用 cargo doc
。生成的文档在 target/doc/ 文件夹里。要查看文档,使用一个简单的 HTTP 服务器是很不错的选择,比如在 doc/ 文件夹下运行Python:
python3 -m http.server 8080
接着访问,http://localhost:8080/integration_test/ ,一种更好方法是使用 --open
,直接使用默认浏览器打开文档页面。
cargo doc --open
Crate级别的属性:
#![doc(html_logo_url = "image url")]
:允许在文档页面的左上角添加 Logo。#![doc(html_root_url = "https://...")]
:允许设置文档页面的 URL.#![doc(html_playground_url = "https://play.rustlang.org/"]
允许放置运行按钮在代码示例中,这样可以直接通过 Rust playground 在线运行代码。条目级别属性:
#[doc(hidden)]
:隐藏文档,如果你不想让用户看到某一部分文档,可以使用它去忽略相应的文档。#[doc(include)]
:从其它文件里包含相应的文档,它可以使得代码和文档分离,适合代码或文档很长的时候。更多的内容,可以参考官方文档。
一个比较好的实践是在提供文档的同时提供运行示例。然而,随着代码的变更,可能原先的示例会发生改变。如果这些示例可以自动的运行,也就和测试无异。
新建一个项目 doctest_demo:cargo new doctest_demo --lib
//! This crate provides functionality for addding things.
//!
//! # Examples
//! ```
//! use doctest_demo::sum;
//!
//! let work_a = 4;
//! let work_b = 34;
//! let total_work = sum(work_a, work_b);
//! ```
//!
//! Sum two arguments
//!
//! # Examples
//!
//! ```
//! assert_eq!(doctest_demo::sum(1, 1), 2);
//! ```
pub fn sum(a: i8, b: i8) -> i8 {
a + b
}
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
随着软件的用户规模的增加,对性能的要求就成为一个难以避免的问题。除了分析代码,进行算法复杂度的估计,还可以通过基准测试来完成对代码性能的评估,并找出瓶颈所在。基准测试通常在最后一个阶段进行,找出性能缺陷的地方。
新建一个项目:cargo new bench_example --lib
在 lib.rs 中写入:
#![feature(test)]
extern crate test;
use test::Bencher;
pub fn do_nothing_slowly() {
println!(".");
for _ in 1..1000_0000 {}
}
pub fn do_nothing_fast() {}
#[bench]
fn bench_nothing_slowly(b: &mut Bencher) {
b.iter(|| do_nothing_slowly());
}
#[bench]
fn bench_nothing_fast(b: &mut Bencher) {
b.iter(|| do_nothing_fast());
}
#[bench]
自然是把一个函数标记为基准测试。使用 cargo bench
,会发现出现以下问题。
很不幸,基准测试(benchmark test)是一个不稳定的特性,还没有得到 Rust 的长期稳定支持,因此要使用这个功能,需要使用一个 nightly 版本的编译器。不过,Rust 很方便,只需要简单的两条命令就可以切换到目前还在开发的版本,一条用于更新,一条用于切换编译器:
rustup update nightly
rustup override set nightly
注意一下,在 do_nothing_slowly
函数中,使用了 println!
打印一个点,如果没有这一条语句,编译器会对空循环体进行优化,两个函数就变成了一样的性能。
正如我们前面看到的,基准测试只能用在 nightly 版本,稳定版本还没有支持这样的功能。不过,社区有第三方包已经给我们提供了一个不错的选择。一个非常流行的 crate 就是 criterion-rs。这个包非常容易使用,而且提供了很多细节信息。Criterion.rs 比内建的基准框架提供了更多的统计报告。
新建一个项目:
cargo new criterion_demo --lib
在 Cargo.toml 中添加:
[dev-dependencies]
criterion = "0.1"
[[bench]]
namee = "fibonacci"
harness = false
除了引入一个依赖,还增加了一个新的部分 [[bench]]
,指明基准测试被命名为 fibonacci 并且不使用内建的基准(harness = false
)。
现在在 lib.rs 中编写两个不同版本的斐波那契数函数( f 0 = 0 , f 1 = 1 f_0=0, f_1=1 f0=0,f1=1),一个是低效的递归,一个是高效的迭代:
// criterion_demo/src/lib.rs
pub fn slow_fibonacci(nth: usize) -> u64 {
if nth <= 1 {
return nth as u64;
} else {
return slow_fibonacci(nth - 1) + slow_fibonacci(nth - 2);
}
}
pub fn fast_fibonacci(nth: usize) -> u64 {
let mut a = 0;
let mut b = 1;
for _ in 1..nth {
b = a + b;
a = b - a;
}
b
}
criterion-rs 要求基准测试放在 benches/ 文件夹,因此在项目的根文件夹下建立一个 benches 文件夹,并创建一个 fibonacci.rs 文件:
// criterion_demo/benches/fibonacci.rs
#[macro_use]
extern crate criterion;
extern crate criterion_demo;
use criterion::Criterion;
use criterion_demo::{fast_fibonacci, slow_fibonacci};
fn fibonacci_benchmark(c: &mut Criterion) {
c.bench_function("fibonacci 8", |b| b.iter(|| slow_fibonacci(8)));
}
criterion_group!(fib_bench, fibonacci_benchmark);
criterion_main!(fib_bench);
首先是声明和要求相应的 crate,然后引入我们编写的 fibonacci 函数。#[marco_use]
表明会使用 crate 中的宏,默认情况下这是不暴露的。criterion_group!
把 fibonacci_benchmark
同 fib_bench 作了关联。
可以看到这里花费了102.20ns,把基准闭包里的 slow_fibonacci
改成 fast_fibonacci
,再使用 cargo bench
:
效率提升非常明显,迭代版本只需要 4.9869 ns,而且下面还显示了一个人性化的信息:Performance has improved. 明确告诉你性能改善。
接下来,用一个简单的程序综合实践一下上述内容。
创建一个项目:cargo new logic_gates --lib
在 lib.rs 中写入:
//! This is a logic gates simulation crate built to demonstate writing unit tests and integration tests
// logic_gates/src/lib.rs
pub fn and(a: u8, b: u8) -> u8 {
unimplemented!()
}
pub fn xor(a: u8, b: u8) -> u8 {
unimplemented!()
}
#[cfg(test)]
mod tests {
use crate::{xor, and};
#[test]
fn test_and() {
assert_eq!(1, and(1, 1));
assert_eq!(0, and(0, 1));
assert_eq!(0, and(1, 0));
assert_eq!(0, and(0, 0));
}
#[test]
fn test_xor() {
assert_eq!(1, xor(1, 0));
assert_eq!(0, xor(0, 0));
assert_eq!(0, xor(1, 1));
assert_eq!(1, xor(0, 1));
}
}
不用运行测试,因为函数体压根还没写。这只是遵循测试驱动的理念,先写好测试。接下来,再实现函数体:
/// Implements a boolean `and` gate taking as input two bits and returns a bit as output
pub fn and(a: u8, b: u8) -> u8 {
match (a, b) {
(1, 1) => 1,
_ => 0,
}
}
/// Implements a boolean `xor` gate taking as input two bits and returning a bit as output
pub fn xor(a: u8, b: u8) -> u8 {
match (a, b) {
(1, 0) | (0, 1) => 1,
_ => 0,
}
}
运行 cargo test
:
很好,都通过了。接下来,使用这两个门实现半加器来实现集成测试。建立一个 tests 文件夹,并在该文件夹下建立 half_adder.rs:
// logic_gates/tests/half_adder.rs
use logic_gates::{and, xor};
pub type Sum = u8;
pub type Carry = u8;
pub fn half_adder_input_output() -> Vec<((u8, u8), (Sum, Carry))> {
vec![
((0, 0), (0, 0)),
((0, 1), (1, 0)),
((1, 0), (1, 0)),
((1, 1), (0, 1)),
]
}
/// This function implements a half adder using primitive gates
fn half_adder(a: u8, b: u8) -> (Sum, Carry) {
(xor(a, b), and(a, b))
}
#[test]
fn one_bit_adder() {
for (inn, out) in half_adder_input_output() {
let (a, b) = inn;
println!("Testing: {}, {} -> {:?}", a, b, out);
assert_eq!(half_adder(a, b), out);
}
}
为了生成自定义的文档,在 lib.rs 的开头添加:
#![doc(html_logo_url = "https://d30y9cdsu7xlg0.cloudfront.net/png/411962-200.png")]
可以使用 cargo doc --open
,最终文档页面如下:
测试很重要,Cargo 用着还是很舒服的。