2020年4月27日

rust11 自動化測試

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 的測試函數是用來驗證非測試程式碼是否是期望的結果。測試函數體通常執行以下三種操作:

  1. 設定任何所需的數據或狀態
  2. 執行需要測試的代碼
  3. 驗證 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 格式列印參數,這表示被比較的值必需實現 PartialEqDebug 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 中的少量代碼也就會正常工作且不需要測試。

References

The Rust Programming Language

中文版

中文版 2

沒有留言:

張貼留言