測試是保證軟件質量的關鍵一環,這一節主要講 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 可以很好的提高項目的可發現性。
文檔被劃分成兩個級別,並用不同的註釋符號標記:
- 條目級別:比如結構體(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
:
基準測試
隨着軟件的用戶規模的增加,對性能的要求就成爲一個難以避免的問題。除了分析代碼,進行算法複雜度的估計,還可以通過基準測試來完成對代碼性能的評估,並找出瓶頸所在。基準測試通常在最後一個階段進行,找出性能缺陷的地方。
新建一個項目: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
接下來,仍然運行 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 中編寫兩個不同版本的斐波那契數函數(),一個是低效的遞歸,一個是高效的迭代:
// 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
:
可以看到這裏花費了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);
}
}
運行 cargo test
:
爲了生成自定義的文檔,在 lib.rs 的開頭添加:
#![doc(html_logo_url = "https://d30y9cdsu7xlg0.cloudfront.net/png/411962-200.png")]
可以使用 cargo doc --open
,最終文檔頁面如下:
小結
測試很重要,Cargo 用着還是很舒服的。