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 用着還是很舒服的。

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