Rust学习笔记之测试、文档和基准

测试是保证软件质量的关键一环,这一节主要讲 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
如果你像看到打印语句输出,可以使用 cargo test test_with_fixture -- --nocapture

cargo test
-- 是必要的,因为我们想要传递 --nocapture 标志到测试运行。-- 标志着 cargo 自身参数的结束,接下来的参数会被传递到被 cargo 调用的二进制执行文件里。

文档

文档在任何软件中都是至关重要的,无论代码写得多好,没有人喜欢看别人写的代码。文档就是告诉使用者 API 的用法、参数要求并且适当给出例子。在 Github 上,一个良好的 README.md 可以很好的提高项目的可发现性。

文档被划分成两个级别,并用不同的注释符号标记:

  • 条目级别:比如结构体(struct)、enum、函数、trait。对於单行注释,可以使用 ///,如果是多行注释,可以使用 /** 开头,并使用 */ 结束。
  • 模块级别:比如 main.rs、lib.rs 或者其它的模块。使用 //! 作为单行测试,多行注释由/*! 开头,*/ 结束。

在文档注释里,可以使用 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 test
doc test

基准测试

随着软件的用户规模的增加,对性能的要求就成为一个难以避免的问题。除了分析代码,进行算法复杂度的估计,还可以通过基准测试来完成对代码性能的评估,并找出瓶颈所在。基准测试通常在最后一个阶段进行,找出性能缺陷的地方。

新建一个项目: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

很不幸,基准测试(benchmark test)是一个不稳定的特性,还没有得到 Rust 的长期稳定支持,因此要使用这个功能,需要使用一个 nightly 版本的编译器。不过,Rust 很方便,只需要简单的两条命令就可以切换到目前还在开发的版本,一条用于更新,一条用于切换编译器:

rustup update nightly
rustup override set nightly

接下来,仍然运行 cargo bench
cargo bench

注意一下,在 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 中编写两个不同版本的斐波那契数函数(f0=0,f1=1f_0=0, f_1=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 作了关联。

运行 cargo bench
slow test

可以看到这里花费了102.20ns,把基准闭包里的 slow_fibonacci 改成 fast_fibonacci,再使用 cargo bench

Unit Test

效率提升非常明显,迭代版本只需要 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
unit 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);
  }
}

运行 cargo test
test

为了生成自定义的文档,在 lib.rs 的开头添加:

#![doc(html_logo_url = "https://d30y9cdsu7xlg0.cloudfront.net/png/411962-200.png")]

可以使用 cargo doc --open,最终文档页面如下:

文档

小结

测试很重要,Cargo 用着还是很舒服的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章