iT邦幫忙

2025 iThome 鐵人賽

DAY 29
0
Rust

Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計系列 第 29

(Day29) Rust 測試策略:單元、整合、性質測試

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250917/20124462KA2M7PfuNm.png

Rust 逼我成為更好的工程師:Rust 測試策略:單元、整合、性質測試

在前面的篇章中,我們理解了如何用工具診斷問題。

今天我們要探討更根本的問題:如何從一開始就確保程式碼的正確性?

關鍵洞察:測試不是為了覆蓋率,而是為了建立信心

Rust 的內建測試框架

最簡單的測試

// 測試就是一個標記了 #[test] 的函式
#[test]
fn it_works() {
    assert_eq!(2 + 2, 4);
}

// 執行測試
// cargo test

測試失敗

#[test]
fn test_failure() {
    assert_eq!(2 + 2, 5);  // 測試會失敗
}

// 輸出:
// assertion failed: `(left == right)`
//   left: `4`,
//  right: `5`

預期 panic 的測試

#[test]
#[should_panic]
fn test_panic() {
    panic!("這個測試預期會 panic");
}

#[test]
#[should_panic(expected = "除數不能為零")]
fn test_divide_by_zero() {
    divide(10, 0);  // 應該 panic 並包含特定訊息
}

fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("除數不能為零");
    }
    a / b
}

單元測試:測試最小單位

測試私有函式

// src/lib.rs
fn internal_function(x: i32) -> i32 {
    x * 2
}

pub fn public_function(x: i32) -> i32 {
    internal_function(x) + 1
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_internal() {
        // 可以測試私有函式
        assert_eq!(internal_function(5), 10);
    }
    
    #[test]
    fn test_public() {
        assert_eq!(public_function(5), 11);
    }
}

測試錯誤處理

fn parse_number(s: &str) -> Result<i32, String> {
    s.parse().map_err(|_| format!("無法解析 '{}'", s))
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_valid_number() {
        assert_eq!(parse_number("42").unwrap(), 42);
    }
    
    #[test]
    fn test_invalid_number() {
        assert!(parse_number("abc").is_err());
    }
    
    #[test]
    fn test_error_message() {
        let err = parse_number("xyz").unwrap_err();
        assert_eq!(err, "無法解析 'xyz'");
    }
}

測試邊界條件

fn safe_divide(a: i32, b: i32) -> Option<i32> {
    if b == 0 {
        None
    } else {
        Some(a / b)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_normal_division() {
        assert_eq!(safe_divide(10, 2), Some(5));
    }
    
    #[test]
    fn test_divide_by_zero() {
        assert_eq!(safe_divide(10, 0), None);
    }
    
    #[test]
    fn test_negative_numbers() {
        assert_eq!(safe_divide(-10, 2), Some(-5));
        assert_eq!(safe_divide(10, -2), Some(-5));
    }
    
    #[test]
    fn test_overflow() {
        // i32::MIN / -1 會溢位
        assert_eq!(safe_divide(i32::MIN, -1), Some(i32::MIN / -1));
    }
}

整合測試:測試公開 API

整合測試的結構

my_project/
├── src/
│   └── lib.rs
└── tests/
    ├── integration_test.rs
    └── common/
        └── mod.rs

基本整合測試

// tests/integration_test.rs
use my_project::*;

#[test]
fn test_public_api() {
    let result = public_function(5);
    assert_eq!(result, 11);
}

#[test]
fn test_workflow() {
    // 測試完整的工作流程
    let config = Config::new("test.conf").unwrap();
    let processor = Processor::new(config);
    let result = processor.process("input");
    assert_eq!(result, "expected output");
}

共享測試輔助函式

// tests/common/mod.rs
pub fn setup() -> TestEnvironment {
    TestEnvironment {
        temp_dir: create_temp_dir(),
        config: load_test_config(),
    }
}

pub struct TestEnvironment {
    pub temp_dir: PathBuf,
    pub config: Config,
}

impl Drop for TestEnvironment {
    fn drop(&mut self) {
        // 清理測試環境
        std::fs::remove_dir_all(&self.temp_dir).ok();
    }
}

// tests/integration_test.rs
mod common;

#[test]
fn test_with_setup() {
    let env = common::setup();
    // 使用測試環境
}

性質測試 (Property-based Testing)

什麼是性質測試?

不是測試具體的輸入輸出,而是測試函式應該滿足的性質。

use proptest::prelude::*;

// 性質:反轉兩次應該得到原始值
proptest! {
    #[test]
    fn test_reverse_twice(s in ".*") {
        let reversed = reverse(&s);
        let double_reversed = reverse(&reversed);
        prop_assert_eq!(s, double_reversed);
    }
}

fn reverse(s: &str) -> String {
    s.chars().rev().collect()
}

實戰案例:測試排序

use proptest::prelude::*;

fn my_sort(vec: &mut Vec<i32>) {
    vec.sort();
}

proptest! {
    #[test]
    fn test_sort_properties(mut vec in prop::collection::vec(any::<i32>(), 0..100)) {
        let original_len = vec.len();
        my_sort(&mut vec);
        
        // 性質 1:長度不變
        prop_assert_eq!(vec.len(), original_len);
        
        // 性質 2:已排序
        for i in 1..vec.len() {
            prop_assert!(vec[i - 1] <= vec[i]);
        }
        
        // 性質 3:包含所有原始元素(可以用 HashMap 計數驗證)
    }
}

測試不變式

use proptest::prelude::*;

struct BankAccount {
    balance: i64,
}

impl BankAccount {
    fn new(initial: i64) -> Result<Self, String> {
        if initial < 0 {
            return Err("初始餘額不能為負".to_string());
        }
        Ok(BankAccount { balance: initial })
    }
    
    fn deposit(&mut self, amount: i64) -> Result<(), String> {
        if amount <= 0 {
            return Err("存款金額必須為正".to_string());
        }
        self.balance = self.balance.checked_add(amount)
            .ok_or("餘額溢位".to_string())?;
        Ok(())
    }
    
    fn withdraw(&mut self, amount: i64) -> Result<(), String> {
        if amount <= 0 {
            return Err("提款金額必須為正".to_string());
        }
        if amount > self.balance {
            return Err("餘額不足".to_string());
        }
        self.balance -= amount;
        Ok(())
    }
}

proptest! {
    #[test]
    fn test_account_invariants(
        initial in 0i64..1000,
        deposits in prop::collection::vec(1i64..100, 0..10),
        withdrawals in prop::collection::vec(1i64..50, 0..10),
    ) {
        let mut account = BankAccount::new(initial).unwrap();
        
        // 執行一系列操作
        for amount in deposits {
            let _ = account.deposit(amount);
        }
        
        for amount in withdrawals {
            let _ = account.withdraw(amount);
        }
        
        // 不變式:餘額永遠不會是負數
        prop_assert!(account.balance >= 0);
    }
}

非同步測試

使用 tokio::test

use tokio;

#[tokio::test]
async fn test_async_function() {
    let result = async_function().await;
    assert_eq!(result, "expected");
}

async fn async_function() -> String {
    tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
    "expected".to_string()
}

測試超時

use tokio::time::{timeout, Duration};

#[tokio::test]
async fn test_with_timeout() {
    let result = timeout(
        Duration::from_secs(1),
        slow_function()
    ).await;
    
    assert!(result.is_ok(), "函式執行超時");
}

async fn slow_function() -> String {
    tokio::time::sleep(Duration::from_millis(100)).await;
    "done".to_string()
}

測試併發

use tokio::sync::Mutex;
use std::sync::Arc;

#[tokio::test]
async fn test_concurrent_access() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];
    
    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = tokio::spawn(async move {
            let mut num = counter.lock().await;
            *num += 1;
        });
        handles.push(handle);
    }
    
    for handle in handles {
        handle.await.unwrap();
    }
    
    let final_count = *counter.lock().await;
    assert_eq!(final_count, 10);
}

測試組織策略

策略 1:按功能分組

#[cfg(test)]
mod tests {
    use super::*;
    
    mod parsing {
        use super::*;
        
        #[test]
        fn test_parse_valid() { }
        
        #[test]
        fn test_parse_invalid() { }
    }
    
    mod validation {
        use super::*;
        
        #[test]
        fn test_validate_email() { }
        
        #[test]
        fn test_validate_phone() { }
    }
}

策略 2:測試夾具 (Test Fixtures)

struct TestFixture {
    temp_dir: PathBuf,
    db: Database,
}

impl TestFixture {
    fn new() -> Self {
        let temp_dir = create_temp_dir();
        let db = Database::connect(&temp_dir).unwrap();
        TestFixture { temp_dir, db }
    }
}

impl Drop for TestFixture {
    fn drop(&mut self) {
        std::fs::remove_dir_all(&self.temp_dir).ok();
    }
}

#[test]
fn test_with_fixture() {
    let fixture = TestFixture::new();
    // 使用 fixture.db
}

策略 3:參數化測試

#[test]
fn test_multiple_cases() {
    let test_cases = vec![
        ("input1", "output1"),
        ("input2", "output2"),
        ("input3", "output3"),
    ];
    
    for (input, expected) in test_cases {
        let result = process(input);
        assert_eq!(result, expected, "失敗於輸入: {}", input);
    }
}

測試覆蓋率

使用 tarpaulin

# 安裝
cargo install cargo-tarpaulin

# 執行覆蓋率分析
cargo tarpaulin --out Html

# 會生成 tarpaulin-report.html

覆蓋率的陷阱

// 100% 覆蓋率不等於正確

fn buggy_function(x: i32) -> i32 {
    if x > 0 {
        x + 1  // 應該是 x * 2
    } else {
        x - 1
    }
}

#[test]
fn test_buggy() {
    // 這個測試覆蓋了所有分支
    assert_eq!(buggy_function(5), 6);   // 通過(但邏輯錯誤)
    assert_eq!(buggy_function(-5), -6); // 通過
}

關鍵洞察:覆蓋率是必要條件,不是充分條件。重要的是測試正確的行為。

測試驅動開發 (TDD) 在 Rust 中的實踐

紅-綠-重構循環

// 1. 紅:先寫測試(會失敗)
#[test]
fn test_add() {
    assert_eq!(add(2, 3), 5);
}

// 2. 綠:寫最簡單的實作(通過測試)
fn add(a: i32, b: i32) -> i32 {
    a + b
}

// 3. 重構:改進程式碼品質
fn add(a: i32, b: i32) -> i32 {
    a.checked_add(b).expect("溢位")
}

// 4. 添加更多測試
#[test]
fn test_add_overflow() {
    // 測試邊界情況
}

實戰案例:實作 Stack

// 1. 先寫測試
#[cfg(test)]
mod tests {
    use super::*;
    
    #[test]
    fn test_new_stack_is_empty() {
        let stack: Stack<i32> = Stack::new();
        assert!(stack.is_empty());
    }
    
    #[test]
    fn test_push_and_pop() {
        let mut stack = Stack::new();
        stack.push(1);
        stack.push(2);
        assert_eq!(stack.pop(), Some(2));
        assert_eq!(stack.pop(), Some(1));
        assert_eq!(stack.pop(), None);
    }
}

// 2. 實作
pub struct Stack<T> {
    items: Vec<T>,
}

impl<T> Stack<T> {
    pub fn new() -> Self {
        Stack { items: Vec::new() }
    }
    
    pub fn push(&mut self, item: T) {
        self.items.push(item);
    }
    
    pub fn pop(&mut self) -> Option<T> {
        self.items.pop()
    }
    
    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }
}

總結:測試策略的層次

1. 單元測試:快速反饋

#[test]
fn test_small_unit() {
    // 測試單一函式
    // 執行快速
    // 失敗時容易定位
}

2. 整合測試:真實場景

#[test]
fn test_integration() {
    // 測試多個模組協作
    // 模擬真實使用
    // 確保介面正確
}

3. 性質測試:窮舉邊界

proptest! {
    #[test]
    fn test_property(input in strategy) {
        // 測試不變式
        // 自動生成測試案例
        // 發現意外的邊界情況
    }
}

4. 非同步測試:併發正確性

#[tokio::test]
async fn test_async() {
    // 測試非同步邏輯
    // 驗證併發安全
    // 檢查超時行為
}

關鍵洞察:好的測試策略是多層次的。單元測試提供快速反饋,整合測試確保協作正確,性質測試發現邊界問題,非同步測試驗證併發安全。

在最後一篇中,我們將總結整個系列,看看如何將 Rust 的思維應用到其他語言。

相關連結與參考資源


上一篇
(Day28) Rust 診斷與記憶體檢查:洩漏、競態、死鎖
下一篇
(Day30) 遷移與總結:從 Laravel、Vue、Python、Go 到 Rust 的對照
系列文
Rust 逼我成為更好的工程師:從 Borrow Checker 看軟體設計30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言