Edsger W. Dijkstra 在 1972 年的文章 The Humble Programmer 中說到 「軟體測試是證明 bug 存在的有效方法,而證明其不存在時則顯得令人絕望的不足。」(Program testing can be a very effective way to show the presence of bugs, but it is hopelessly inadequate for showing their absence.)這並不意味著我們不該儘可能地測試軟體!
rust 內建了軟體測試的功能。
以下討論測試會用到的註解和 macro,執行測試的默認行為和選項,以及如何將測試組織成單元測試和集成測試。
如何撰寫測試
Rust 的測試函數是用來驗證非測試程式碼是否是期望的結果。測試函數體通常執行以下三種操作:
- 設定任何所需的數據或狀態
- 執行需要測試的代碼
- 驗證 assert 其結果是我們所期望的
以下是 Rust 提供的專門用來編寫測試的功能:test
屬性、一些 macro 和 should_panic
屬性。
測試函數解析
簡單的說,rust 測試就是帶有 test
屬性註解的函數。 attribute 屬性是 rust 的 metadata。例如 chap 5 用到的 derive
屬性。要將函數變成測試函數,要在 fn
前面加上 #[test]
,使用 cargo test
時,就會產生測試程式呼叫標記 test
屬性的函數,並得到測試報告。
cargo 產生新的 lib project 時,會自動產生測試 module 及一個測試函數。
產生 adder library project
$ cargo new adder --lib
Created library `adder` package
裡面只有一個 src/lib.rs
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
函數中以 assert_eq!
驗證 2+2 是否為 4
$ cd adder
$ cargo test
Compiling adder v0.1.0 (/Users/charley/project/adder)
Finished dev [unoptimized + debuginfo] target(s) in 7.71s
Running target/debug/deps/adder-f52d3d182f438c63
running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
ignored
是忽略的測試,measured
是性能測試
Doc-tests adder
是文件測試
增加一個失敗的測試
#[cfg(test)]
mod tests {
#[test]
fn exploration() {
assert_eq!(2 + 2, 4);
}
#[test]
fn another() {
panic!("Make this test fail");
}
}
執行測試結果
running 2 tests
test tests::exploration ... ok
test tests::another ... FAILED
failures:
---- tests::another stdout ----
thread 'tests::another' panicked at 'Make this test fail', src/lib.rs:10:9
note: Run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
利用 assert!
macro 檢查結果
assert!
macro 由 std lib 提供,可確保測試中某些條件為 true。
如果 assert!
的值為 false,就會呼叫 panic!
macro,導致測試失敗
#[derive(Debug)]
pub struct Rectangle {
length: u32,
width: u32,
}
impl Rectangle {
pub fn can_hold(&self, other: &Rectangle) -> bool {
self.length > other.length && self.width > other.width
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn larger_can_hold_smaller() {
let larger = Rectangle { length: 8, width: 7 };
let smaller = Rectangle { length: 5, width: 1 };
// can_hold 的測試,檢查一個較大的矩形確實能放得下一個較小的矩形
assert!(larger.can_hold(&smaller));
}
#[test]
fn smaller_cannot_hold_larger() {
let larger = Rectangle { length: 8, width: 7 };
let smaller = Rectangle { length: 5, width: 1 };
assert!(!smaller.can_hold(&larger));
}
}
$ cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.06s
Running target/debug/deps/adder-f52d3d182f438c63
running 2 tests
test tests::smaller_cannot_hold_larger ... ok
test tests::larger_can_hold_smaller ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
使用 assert_eq!
與 assert_ne!
測試相等
pub fn add_two(a: i32) -> i32 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_adds_two() {
assert_eq!(4, add_two(2));
}
}
assert_eq!
和 assert_ne!
在底層分別使用了 ==
和 !=
。當 assert 失敗時,這些 macro 會使用 debug 格式列印參數,這表示被比較的值必需實現 PartialEq
和 Debug
trait。所有的基本類型和大部分標準庫類型都實現了這些 trait。對於自定義的結構體和 enum,需要實作 PartialEq
才能用 assert_eq!
和 assert_ne!
判斷他們的值是否相等。需要實作 Debug
才能在 assert 失敗時列印他們的值。因為這兩個 trait 都是 derivable trait,通常可以直接在結構體或枚舉上添加 #[derive(PartialEq, Debug)]
註解。
自訂失敗訊息
可以向 assert!
、assert_eq!
和 assert_ne!
傳遞一個 optional 的失敗訊息參數,可以在測試失敗時將自訂失敗訊息列印出來。
pub fn greeting(name: &str) -> String {
format!("Hello {}!", name)
}
pub fn greeting2(name: &str) -> String {
format!("Hello !")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn greeting_contains_name() {
let result = greeting("Carol");
assert!(result.contains("Carol"));
}
#[test]
fn greeting_contains_name2() {
let result = greeting2("Carol");
// 增加錯誤的資訊
assert!(
result.contains("Carol"),
"Greeting did not contain name, value was `{}`", result
);
}
}
錯誤訊息
---- tests::greeting_contains_name2 stdout ----
thread 'tests::greeting_contains_name2' panicked at 'Greeting did not contain name, value was `Hello !`', src/lib.rs:22:6
利用 should_panic
檢查 panic
測試程式是否有正確處理錯誤。
#[should_panic]
屬性位於 #[test]
之後
pub struct Guess {
value: i32,
}
impl Guess {
pub fn new(value: i32) -> Guess {
if value < 1 || value > 100 {
panic!("Guess value must be between 1 and 100, got {}.", value);
}
Guess {
value
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn greater_than_100() {
Guess::new(200);
}
}
也可以加上自訂錯誤訊息
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "Guess value must be less than or equal to 100")]
fn greater_than_100() {
Guess::new(200);
}
}
將 Result<T, E>
用在測試
目前為止,我們的測試在失敗時就會 panic。也可以使用 Result<T, E>
編寫測試
#[cfg(test)]
mod tests {
// 會回傳 Result, 成功時是 Ok(())
// 失敗時 是帶有 String 的 Err
// 這個測試可能成功或失敗,不過是透過 Result<T, E> 來判斷結果。為此不能在對這些函數使用 #[should_panic];而是應該回傳 Err!
#[test]
fn it_works() -> Result<(), String> {
if 2 + 2 == 4 {
Ok(())
} else {
Err(String::from("two plus two does not equal four"))
}
}
}
執行測試
cargo test
在測試模式下編譯並運行生成的測試 binary code。可以指定命令行參數來改變 cargo test
的行為。cargo test --help
會提示 cargo test
的有關參數。
並行或連續執行
rust 預設會用 thread 平行執行多個測試。必須確保測試不能互相依賴,或依賴共享的資源,例如工作目錄或環境變數。
可用 cargo test -- --test-threads=1
限制測試 thread 為 1 個,這樣就不會發生資源干擾問題,但會花較多時間執行測試。
顯示函數輸出
rust 在測試通過時,預設會攔截列印到 stdout 的資料。測試中如果有呼叫 println!
,但測試通過了,在 console 不會看到 println!
的輸出。
fn prints_and_returns_10(a: i32) -> i32 {
println!("I got the value {}", a);
10
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn this_test_will_pass() {
let value = prints_and_returns_10(4);
assert_eq!(10, value);
}
#[test]
fn this_test_will_fail() {
let value = prints_and_returns_10(8);
assert_eq!(5, value);
}
}
失敗測試的輸出 I got the value 8
,會出現在輸出的測試摘要部分
running 2 tests
test tests::this_test_will_fail ... FAILED
test tests::this_test_will_pass ... ok
failures:
---- tests::this_test_will_fail stdout ----
I got the value 8
thread 'tests::this_test_will_fail' panicked at 'assertion failed: `(left == right)`
left: `5`,
right: `10`', src/lib.rs:19:9
note: Run with `RUST_BACKTRACE=1` environment variable to display a backtrace.
failures:
tests::this_test_will_fail
如果希望看到所有 stdout output,就用 --nocapture
執行測試
cargo test -- --nocapture
指定名稱進行部分測試
pub fn add_two(a: i32) -> i32 {
a + 2
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_two_and_two() {
assert_eq!(4, add_two(2));
}
#[test]
fn add_three_and_two() {
assert_eq!(5, add_two(3));
}
#[test]
fn one_hundred() {
assert_eq!(102, add_two(100));
}
}
有三個不同名稱的測試,可指定只測試一個
cargo test one_hundred
可測試包含 add 的兩個測試: addtwoandtwo, addthreeandtwo
cargo test add
加上 ignore
標記耗時的測試,可以在一般 cargo test
時,略過這些測試。
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
#[test]
#[ignore]
fn expensive_test() {
// code that takes an hour to run
}
只執行耗時的測試
cargo test -- --ignored
測試的架構
獨立的單元測試 unit test,以及集成的 integration test
單元測試
單元測試與他們要測試的程式碼共同存放在位於 src 目錄下相同的檔案中。規範是在每個檔案中建立包含測試函數的 tests
模塊,並使用 cfg(test)
標註模塊。
測試模塊的 #[cfg(test)]
註解告訴 Rust 只在執行 cargo test
時才編譯和運行測試代碼,而在運行 cargo build
時不編譯測試的部分,因不包含測試可以節省編譯時間及 binary code 的大小。集成測試因為位於另一個文件夾,所以不需要 #[cfg(test)]
註解。
#[cfg(test)]
mod tests {
#[test]
fn it_works() {
assert_eq!(2 + 2, 4);
}
}
Rust 的私有性規則確實允許你測試私有函數。internal_adder
函數並沒有標記為 pub
,不過因為測試也不過是 Rust 代碼同時 tests
也僅僅是另一個模塊,我們完全可以在測試中導入和調用 internal_adder
。
pub fn add_two(a: i32) -> i32 {
internal_adder(a, 2)
}
fn internal_adder(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn internal() {
assert_eq!(4, internal_adder(2, 2));
}
}
集成測試
在 Rust 中,集成測試對於你需要測試的 library 來說完全是外部的。也就是說它們只能呼叫 library 中的公有 API 。集成測試的目的是測試多個 library 能否一起正常工作。為了建立集成測試,你需要先建立一個 tests 目錄,接著可以隨意在這個目錄中創建任意測試文件,Cargo 會將每一個文件當作單獨的 crate 來編譯。。
tests/integration_test.rs
use adder;
#[test]
fn it_adds_two() {
assert_eq!(4, adder::add_two(2));
}
與單元測試不同,我們需要在程式一開始 use adder
。這是因為每一個 tests
目錄中的測試文件都是完全獨立的 crate,所以需要在每一個文件中導入 crate,不需要將 tests/integration_test.rs 中的任何程式碼標註為 #[cfg(test)]
。
測試了三個部分:單元測試、集成測試和文件測試
$ cargo test
Finished dev [unoptimized + debuginfo] target(s) in 0.06s
Running target/debug/deps/adder-f52d3d182f438c63
running 3 tests
test tests::add_two_and_two ... ok
test tests::add_three_and_two ... ok
test tests::one_hundred ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running target/debug/deps/integration_test-536010af9ed9a430
running 1 test
test it_adds_two ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Doc-tests adder
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
可透過 command line 限制只執行某個集成測試
cargo test --test integration_test
集成測試的子模塊
每一個 tests 目錄中的檔案都被編譯為單獨的 crate
建立一個 tests/common.rs 並建立一個名叫 setup
的函數
tests/common.rs
pub fn setup() {
// 編寫特定測試所需的代碼
}
測試結果中看到一個新的對應 common.rs 文件的測試結果部分
Running target/debug/deps/common-a63652fdc45c0080
running 0 tests
為了不讓 common
出現在測試輸出中,我們將建立 tests/common/mod.rs ,而不是 tests/common.rs 。這是一種 Rust 的命名規範,這樣命名告訴 Rust 不要將 common
看作一個集成測試文件。將 setup
函數代碼移動到 tests/common/mod.rs 並刪除 tests/common.rs 文件之後,測試輸出中將不會出現這一部分。tests 目錄中的子目錄不會被作為單獨的 crate 編譯或作為一個測試結果部分出現在測試輸出中。
一旦有了 tests/common/mod.rs,就可以將其作為模塊以便在任何集成測試文件中使用。這裡是一個 tests/integration_test.rs 中調用 setup
函數的 it_adds_two
測試的例子:
use adder;
mod common;
#[test]
fn it_adds_two() {
common::setup();
assert_eq!(4, adder::add_two(2));
}
binary crate 的集成測試
如果項目是 binary crate 且只包含 src/main.rs 而沒有 src/lib.rs,這樣就不可能在 tests 目錄建立集成測試並使用 extern crate
導入 src/main.rs 中定義的函數。只有 library crate 才會向其他 crate 暴露了可供呼叫和使用的函數;二進制 crate 只意在單獨運行。
為什麼 Rust 二進制項目的結構明確採用 src/main.rs 呼叫 src/lib.rs 中的邏輯的方式?因為通過這種結構,集成測試可以透過 extern crate
測試 library crate 中的主要功能了,而如果這些重要的功能沒有問題的話,src/main.rs 中的少量代碼也就會正常工作且不需要測試。