iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0
Rust

大家一起跟Rust當好朋友吧!系列 第 14

Day 14: 測試 (Testing):建立可靠的測試體系

  • 分享至 

  • xImage
  •  

嗨嗨!大家好!歡迎來到 Rust 三十天挑戰的第十四天!

經過前十三天的深入學習,我們已經掌握了 Rust 的核心概念,從基礎語法到智慧指標,從所有權系統到泛型與特徵。今天我們要來學習一個在實際開發中極其重要的主題:測試 (Testing)

如果說前面學的是如何「寫程式」,那麼今天要學的就是如何「確保程式正確」。在現代軟體開發中,測試不只是「可有可無」的加分項,而是「必備」的核心技能。Rust 內建了強大的測試框架,讓我們能夠輕鬆地為程式碼建立完整的測試體系。

老實說,剛開始寫測試時,我覺得這是在「浪費時間」—為什麼要寫這麼多看似重複的程式碼?但隨著專案規模的增長,我深深體會到測試的價值:它們是我重構程式碼時的安全感,是新功能開發時的信心來源,避免改A壞B,更是程式碼品質的守護者。

今天讓我們一起探索 Rust 的測試世界!

為什麼需要測試?

測試的價值

在深入語法之前,讓我們先理解為什麼測試如此重要:

1. 及早發現錯誤:在開發階段就抓到 bug,而不是等到使用者發現

2. 重構的安全感:當你需要改進程式碼時,測試能確保功能沒有被破壞

3. 程式碼文件:良好的測試本身就是程式碼的使用說明書

4. 設計改善:編寫測試會迫使你思考 API 的設計是否合理

5. 團隊協作:其他開發者可以透過測試了解程式碼的預期行為

Rust 測試的優勢

Rust 的測試框架有以下特色:

  • 內建支援:不需要額外的測試框架
  • 並行執行:預設情況下測試會並行執行,提高效率
  • 型別安全:編譯時就能發現測試中的錯誤
  • 文件整合:可以在文件註解中直接寫測試

單元測試 (Unit Tests)

基本測試語法

在 Rust 中,測試函式使用 #[test] 屬性標記:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = 2 + 2;
        assert_eq!(result, 4);
    }

    #[test]
    fn test_basic_assertions() {
        // assert! - 檢查布林值
        assert!(true);
        assert!(2 + 2 == 4);

        // assert_eq! - 檢查相等性
        assert_eq!(2 + 2, 4);
        assert_eq!("hello", "hello");

        // assert_ne! - 檢查不相等性
        assert_ne!(2 + 2, 5);
        assert_ne!("hello", "world");
    }
}

執行測試:

cargo test

實用的測試範例

讓我們建立一個實際的例子來展示測試的各種用法:

/// 計算矩形面積
pub struct Rectangle {
    width: f64,
    height: f64,
}

impl Rectangle {
    pub fn new(width: f64, height: f64) -> Result<Self, String> {
        if width <= 0.0 || height <= 0.0 {
            Err("寬度和高度必須大於 0".to_string())
        } else {
            Ok(Rectangle { width, height })
        }
    }

    pub fn area(&self) -> f64 {
        self.width * self.height
    }

    pub fn perimeter(&self) -> f64 {
        2.0 * (self.width + self.height)
    }

    pub fn can_hold(&self, other: &Rectangle) -> bool {
        self.width >= other.width && self.height >= other.height
    }

    pub fn is_square(&self) -> bool {
        (self.width - self.height).abs() < f64::EPSILON
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_rectangle_creation() {
        let rect = Rectangle::new(10.0, 20.0);
        assert!(rect.is_ok());

        let rect = rect.unwrap();
        assert_eq!(rect.width, 10.0);
        assert_eq!(rect.height, 20.0);
    }

    #[test]
    fn test_rectangle_invalid_dimensions() {
        let result = Rectangle::new(-5.0, 10.0);
        assert!(result.is_err());

        let result = Rectangle::new(5.0, 0.0);
        assert!(result.is_err());
    }

    #[test]
    fn test_area_calculation() {
        let rect = Rectangle::new(3.0, 4.0).unwrap();
        assert_eq!(rect.area(), 12.0);

        let square = Rectangle::new(5.0, 5.0).unwrap();
        assert_eq!(square.area(), 25.0);
    }

    #[test]
    fn test_perimeter_calculation() {
        let rect = Rectangle::new(3.0, 4.0).unwrap();
        assert_eq!(rect.perimeter(), 14.0);
    }

    #[test]
    fn test_can_hold() {
        let larger = Rectangle::new(8.0, 7.0).unwrap();
        let smaller = Rectangle::new(5.0, 1.0).unwrap();
        let same_size = Rectangle::new(8.0, 7.0).unwrap();

        assert!(larger.can_hold(&smaller));
        assert!(!smaller.can_hold(&larger));
        assert!(larger.can_hold(&same_size));
    }

    #[test]
    fn test_is_square() {
        let square = Rectangle::new(5.0, 5.0).unwrap();
        let rectangle = Rectangle::new(5.0, 3.0).unwrap();

        assert!(square.is_square());
        assert!(!rectangle.is_square());
    }

    #[test]
    fn test_floating_point_precision() {
        // 處理浮點數精度問題
        let rect = Rectangle::new(0.1, 0.2).unwrap();
        let expected_area = 0.02;
        
        // 使用 epsilon 比較浮點數
        assert!((rect.area() - expected_area).abs() < f64::EPSILON);
    }
}

測試失敗與 panic

有時候我們需要測試程式碼在特定條件下會 panic:

pub fn divide(a: f64, b: f64) -> f64 {
    if b == 0.0 {
        panic!("不能除以零!");
    }
    a / b
}

pub fn parse_positive_number(s: &str) -> Result<u32, String> {
    match s.parse::<u32>() {
        Ok(num) if num > 0 => Ok(num),
        Ok(_) => Err("數字必須大於 0".to_string()),
        Err(_) => Err("無法解析為數字".to_string()),
    }
}

#[cfg(test)]
mod math_tests {
    use super::*;

    #[test]
    fn test_divide_normal() {
        assert_eq!(divide(10.0, 2.0), 5.0);
        assert_eq!(divide(7.0, 3.0), 7.0 / 3.0);
    }

    #[test]
    #[should_panic]
    fn test_divide_by_zero() {
        divide(10.0, 0.0);
    }

    #[test]
    #[should_panic(expected = "不能除以零")]
    fn test_divide_by_zero_with_message() {
        divide(10.0, 0.0);
    }

    #[test]
    fn test_parse_positive_number_success() {
        assert_eq!(parse_positive_number("42"), Ok(42));
        assert_eq!(parse_positive_number("1"), Ok(1));
    }

    #[test]
    fn test_parse_positive_number_zero() {
        assert_eq!(
            parse_positive_number("0"),
            Err("數字必須大於 0".to_string())
        );
    }

    #[test]
    fn test_parse_positive_number_negative() {
        // 注意:parse::<u32> 本身就會拒絕負數
        assert!(parse_positive_number("-5").is_err());
    }

    #[test]
    fn test_parse_positive_number_invalid() {
        assert_eq!(
            parse_positive_number("abc"),
            Err("無法解析為數字".to_string())
        );
        
        assert_eq!(
            parse_positive_number(""),
            Err("無法解析為數字".to_string())
        );
    }
}

測試組織與控制

忽略測試

有時候某些測試需要很長時間執行,或者需要特殊的環境設定:

#[cfg(test)]
mod expensive_tests {
    use std::thread;
    use std::time::Duration;

    #[test]
    fn quick_test() {
        assert_eq!(2 + 2, 4);
    }

    #[test]
    #[ignore]
    fn expensive_test() {
        // 模擬耗時操作
        thread::sleep(Duration::from_secs(1));
        assert_eq!(expensive_computation(), 42);
    }

    fn expensive_computation() -> i32 {
        // 假設這是一個很耗時的計算
        thread::sleep(Duration::from_millis(100));
        42
    }
}

執行被忽略的測試:

cargo test -- --ignored

執行所有測試(包括被忽略的):

cargo test -- --include-ignored

運行特定測試

# 只運行名稱包含 "rectangle" 的測試
cargo test rectangle

# 運行特定的測試函式
cargo test test_area_calculation

# 運行特定模組的測試
cargo test math_tests

控制測試輸出

# 顯示測試中的 println! 輸出
cargo test -- --nocapture

# 單執行緒運行測試(避免並行問題)
cargo test -- --test-threads=1

整合測試 (Integration Tests)

整合測試用於測試函式庫的公開 API,放在 tests/ 目錄中:

// 檔案結構
// src/
//   lib.rs
// tests/
//   integration_test.rs
//   common/
//     mod.rs

src/lib.rs

//! 一個簡單的部落格文章管理函式庫

use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq)]
pub struct Post {
    pub id: u32,
    pub title: String,
    pub content: String,
    pub author: String,
    pub published: bool,
}

impl Post {
    pub fn new(id: u32, title: String, content: String, author: String) -> Self {
        Post {
            id,
            title,
            content,
            author,
            published: false,
        }
    }

    pub fn publish(&mut self) {
        self.published = true;
    }

    pub fn unpublish(&mut self) {
        self.published = false;
    }

    pub fn update_content(&mut self, new_content: String) {
        self.content = new_content;
    }
}

pub struct Blog {
    posts: HashMap<u32, Post>,
    next_id: u32,
}

impl Blog {
    pub fn new() -> Self {
        Blog {
            posts: HashMap::new(),
            next_id: 1,
        }
    }

    pub fn create_post(&mut self, title: String, content: String, author: String) -> u32 {
        let id = self.next_id;
        self.next_id += 1;

        let post = Post::new(id, title, content, author);
        self.posts.insert(id, post);
        id
    }

    pub fn get_post(&self, id: u32) -> Option<&Post> {
        self.posts.get(&id)
    }

    pub fn get_post_mut(&mut self, id: u32) -> Option<&mut Post> {
        self.posts.get_mut(&id)
    }

    pub fn delete_post(&mut self, id: u32) -> bool {
        self.posts.remove(&id).is_some()
    }

    pub fn published_posts(&self) -> Vec<&Post> {
        self.posts
            .values()
            .filter(|post| post.published)
            .collect()
    }

    pub fn posts_by_author(&self, author: &str) -> Vec<&Post> {
        self.posts
            .values()
            .filter(|post| post.author == author)
            .collect()
    }

    pub fn post_count(&self) -> usize {
        self.posts.len()
    }
}

impl Default for Blog {
    fn default() -> Self {
        Self::new()
    }
}

// 單元測試仍然在 src 檔案中
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_post_creation() {
        let post = Post::new(
            1,
            "測試標題".to_string(),
            "測試內容".to_string(),
            "測試作者".to_string(),
        );

        assert_eq!(post.id, 1);
        assert_eq!(post.title, "測試標題");
        assert_eq!(post.content, "測試內容");
        assert_eq!(post.author, "測試作者");
        assert!(!post.published);
    }

    #[test]
    fn test_post_publish() {
        let mut post = Post::new(
            1,
            "標題".to_string(),
            "內容".to_string(),
            "作者".to_string(),
        );

        assert!(!post.published);
        post.publish();
        assert!(post.published);
        post.unpublish();
        assert!(!post.published);
    }
}

tests/integration_test.rs

// 整合測試檔案

use rust_blog::{Blog, Post};

#[test]
fn test_blog_workflow() {
    let mut blog = Blog::new();

    // 測試建立文章
    let post_id = blog.create_post(
        "我的第一篇文章".to_string(),
        "這是我的第一篇部落格文章!".to_string(),
        "Alice".to_string(),
    );

    assert_eq!(post_id, 1);
    assert_eq!(blog.post_count(), 1);

    // 測試取得文章
    let post = blog.get_post(post_id).unwrap();
    assert_eq!(post.title, "我的第一篇文章");
    assert!(!post.published);

    // 測試發布文章
    blog.get_post_mut(post_id).unwrap().publish();
    let published_posts = blog.published_posts();
    assert_eq!(published_posts.len(), 1);

    // 測試刪除文章
    assert!(blog.delete_post(post_id));
    assert_eq!(blog.post_count(), 0);
    assert!(!blog.delete_post(post_id)); // 重複刪除應該失敗
}

#[test]
fn test_multiple_authors() {
    let mut blog = Blog::new();

    // Alice 寫了兩篇文章
    let alice_post1 = blog.create_post(
        "Alice 的第一篇".to_string(),
        "內容1".to_string(),
        "Alice".to_string(),
    );
    let alice_post2 = blog.create_post(
        "Alice 的第二篇".to_string(),
        "內容2".to_string(),
        "Alice".to_string(),
    );

    // Bob 寫了一篇文章
    let bob_post = blog.create_post(
        "Bob 的文章".to_string(),
        "Bob 的內容".to_string(),
        "Bob".to_string(),
    );

    // 發布 Alice 的第一篇和 Bob 的文章
    blog.get_post_mut(alice_post1).unwrap().publish();
    blog.get_post_mut(bob_post).unwrap().publish();

    // 測試按作者查詢
    let alice_posts = blog.posts_by_author("Alice");
    assert_eq!(alice_posts.len(), 2);

    let bob_posts = blog.posts_by_author("Bob");
    assert_eq!(bob_posts.len(), 1);

    // 測試已發布文章
    let published = blog.published_posts();
    assert_eq!(published.len(), 2);
}

#[test]
fn test_post_content_update() {
    let mut blog = Blog::new();

    let post_id = blog.create_post(
        "可編輯的文章".to_string(),
        "原始內容".to_string(),
        "Editor".to_string(),
    );

    // 更新內容
    blog.get_post_mut(post_id)
        .unwrap()
        .update_content("更新後的內容".to_string());

    let post = blog.get_post(post_id).unwrap();
    assert_eq!(post.content, "更新後的內容");
    assert_eq!(post.title, "可編輯的文章"); // 標題不變
}

tests/common/mod.rs

共用的測試工具函式:

// 共用的測試輔助函式

use rust_blog::{Blog, Post};

pub fn create_sample_blog() -> Blog {
    let mut blog = Blog::new();

    let post1_id = blog.create_post(
        "Rust 入門".to_string(),
        "Rust 是一個系統程式語言...".to_string(),
        "Rust 愛好者".to_string(),
    );

    let post2_id = blog.create_post(
        "測試的重要性".to_string(),
        "測試可以確保程式碼品質...".to_string(),
        "測試專家".to_string(),
    );

    let post3_id = blog.create_post(
        "進階 Rust 技巧".to_string(),
        "智慧指標和生命週期...".to_string(),
        "Rust 愛好者".to_string(),
    );

    // 發布前兩篇文章
    blog.get_post_mut(post1_id).unwrap().publish();
    blog.get_post_mut(post2_id).unwrap().publish();

    blog
}

pub fn assert_post_equals(actual: &Post, expected: &Post) {
    assert_eq!(actual.id, expected.id);
    assert_eq!(actual.title, expected.title);
    assert_eq!(actual.content, expected.content);
    assert_eq!(actual.author, expected.author);
    assert_eq!(actual.published, expected.published);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_sample_blog_creation() {
        let blog = create_sample_blog();
        assert_eq!(blog.post_count(), 3);
        assert_eq!(blog.published_posts().len(), 2);
    }
}

tests/advanced_integration.rs

使用共用工具的進階整合測試:

use rust_blog::Blog;

mod common;

#[test]
fn test_blog_statistics() {
    let blog = common::create_sample_blog();

    // 測試總體統計
    assert_eq!(blog.post_count(), 3);
    assert_eq!(blog.published_posts().len(), 2);

    // 測試作者統計
    let rust_enthusiast_posts = blog.posts_by_author("Rust 愛好者");
    assert_eq!(rust_enthusiast_posts.len(), 2);

    let test_expert_posts = blog.posts_by_author("測試專家");
    assert_eq!(test_expert_posts.len(), 1);

    // 測試不存在的作者
    let nonexistent_posts = blog.posts_by_author("不存在的作者");
    assert_eq!(nonexistent_posts.len(), 0);
}

#[test]
fn test_published_content_only() {
    let blog = common::create_sample_blog();
    let published_posts = blog.published_posts();

    // 確保只有已發布的文章被回傳
    for post in published_posts {
        assert!(post.published);
    }

    // 檢查特定的已發布文章
    let titles: Vec<&String> = published_posts.iter().map(|p| &p.title).collect();
    assert!(titles.contains(&&"Rust 入門".to_string()));
    assert!(titles.contains(&&"測試的重要性".to_string()));
    assert!(!titles.contains(&&"進階 Rust 技巧".to_string()));
}

文件測試 (Documentation Tests)

Rust 可以執行文件註解中的程式碼範例,確保文件和實際程式碼保持同步:

/// 計算兩個數字的最大公約數
/// 
/// 使用歐幾里得演算法來計算兩個正整數的最大公約數。
/// 
/// # 參數
/// 
/// * `a` - 第一個正整數
/// * `b` - 第二個正整數
/// 
/// # 回傳值
/// 
/// 回傳 `a` 和 `b` 的最大公約數
/// 
/// # 範例
/// 
/// ```
/// use rust_blog::gcd;
/// 
/// let result = gcd(48, 18);
/// assert_eq!(result, 6);
/// 
/// let result = gcd(17, 13);
/// assert_eq!(result, 1);
/// ```
/// 
/// # Panics
/// 
/// 當任一參數為 0 時會 panic:
/// 
/// ```should_panic
/// use rust_blog::gcd;
/// 
/// gcd(0, 5); // 這會 panic
/// ```
pub fn gcd(mut a: u32, mut b: u32) -> u32 {
    assert!(a != 0 && b != 0, "GCD 的參數不能為 0");
    
    while b != 0 {
        let temp = b;
        b = a % b;
        a = temp;
    }
    a
}

/// 檢查一個數字是否為質數
/// 
/// # 範例
/// 
/// ```
/// use rust_blog::is_prime;
/// 
/// assert!(is_prime(2));
/// assert!(is_prime(17));
/// assert!(!is_prime(4));
/// assert!(!is_prime(1));
/// ```
/// 
/// 對於大數字的檢查:
/// 
/// ```
/// use rust_blog::is_prime;
/// 
/// // 這些是已知的質數
/// assert!(is_prime(97));
/// assert!(is_prime(101));
/// 
/// // 這些不是質數
/// assert!(!is_prime(99));
/// assert!(!is_prime(100));
/// ```
pub fn is_prime(n: u32) -> bool {
    if n < 2 {
        return false;
    }
    if n == 2 {
        return true;
    }
    if n % 2 == 0 {
        return false;
    }
    
    let limit = (n as f64).sqrt() as u32 + 1;
    for i in (3..limit).step_by(2) {
        if n % i == 0 {
            return false;
        }
    }
    true
}

執行文件測試:

cargo test --doc

模擬與測試替身 (Mocking)

對於需要外部依賴的程式碼,我們可以使用特徵來建立可測試的抽象:

use std::collections::HashMap;

// 定義資料庫介面
pub trait Database {
    fn get_user(&self, id: u32) -> Option<String>;
    fn save_user(&mut self, id: u32, name: String) -> Result<(), String>;
    fn delete_user(&mut self, id: u32) -> bool;
}

// 真實的資料庫實現(簡化版)
pub struct SqlDatabase {
    connection_string: String,
}

impl SqlDatabase {
    pub fn new(connection_string: String) -> Self {
        SqlDatabase { connection_string }
    }
}

impl Database for SqlDatabase {
    fn get_user(&self, _id: u32) -> Option<String> {
        // 實際會連接資料庫查詢
        None
    }

    fn save_user(&mut self, _id: u32, _name: String) -> Result<(), String> {
        // 實際會寫入資料庫
        Ok(())
    }

    fn delete_user(&mut self, _id: u32) -> bool {
        // 實際會從資料庫刪除
        true
    }
}

// 使用者服務
pub struct UserService<D: Database> {
    db: D,
}

impl<D: Database> UserService<D> {
    pub fn new(db: D) -> Self {
        UserService { db }
    }

    pub fn get_user_info(&self, id: u32) -> String {
        match self.db.get_user(id) {
            Some(name) => format!("用戶 {}: {}", id, name),
            None => format!("找不到用戶 {}", id),
        }
    }

    pub fn create_user(&mut self, id: u32, name: String) -> Result<String, String> {
        if name.is_empty() {
            return Err("用戶名稱不能為空".to_string());
        }

        self.db.save_user(id, name.clone())?;
        Ok(format!("成功建立用戶:{}", name))
    }

    pub fn remove_user(&mut self, id: u32) -> String {
        if self.db.delete_user(id) {
            format!("用戶 {} 已被刪除", id)
        } else {
            format!("刪除用戶 {} 失敗", id)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // 測試用的模擬資料庫
    struct MockDatabase {
        users: HashMap<u32, String>,
        should_fail_save: bool,
    }

    impl MockDatabase {
        fn new() -> Self {
            MockDatabase {
                users: HashMap::new(),
                should_fail_save: false,
            }
        }

        fn with_users(users: Vec<(u32, String)>) -> Self {
            let mut db = MockDatabase::new();
            for (id, name) in users {
                db.users.insert(id, name);
            }
            db
        }

        fn set_save_failure(&mut self, should_fail: bool) {
            self.should_fail_save = should_fail;
        }
    }

    impl Database for MockDatabase {
        fn get_user(&self, id: u32) -> Option<String> {
            self.users.get(&id).cloned()
        }

        fn save_user(&mut self, id: u32, name: String) -> Result<(), String> {
            if self.should_fail_save {
                Err("資料庫錯誤".to_string())
            } else {
                self.users.insert(id, name);
                Ok(())
            }
        }

        fn delete_user(&mut self, id: u32) -> bool {
            self.users.remove(&id).is_some()
        }
    }

    #[test]
    fn test_get_existing_user() {
        let db = MockDatabase::with_users(vec![(1, "Alice".to_string())]);
        let service = UserService::new(db);

        let result = service.get_user_info(1);
        assert_eq!(result, "用戶 1: Alice");
    }

    #[test]
    fn test_get_nonexistent_user() {
        let db = MockDatabase::new();
        let service = UserService::new(db);

        let result = service.get_user_info(999);
        assert_eq!(result, "找不到用戶 999");
    }

    #[test]
    fn test_create_user_success() {
        let db = MockDatabase::new();
        let mut service = UserService::new(db);

        let result = service.create_user(1, "Bob".to_string());
        assert_eq!(result, Ok("成功建立用戶:Bob".to_string()));

        // 驗證用戶確實被保存
        let user_info = service.get_user_info(1);
        assert_eq!(user_info, "用戶 1: Bob");
    }

    #[test]
    fn test_create_user_empty_name() {
        let db = MockDatabase::new();
        let mut service = UserService::new(db);

        let result = service.create_user(1, "".to_string());
        assert_eq!(result, Err("用戶名稱不能為空".to_string()));
    }

    #[test]
    fn test_create_user_database_failure() {
        let mut db = MockDatabase::new();
        db.set_save_failure(true);
        let mut service = UserService::new(db);

        let result = service.create_user(1, "Charlie".to_string());
        assert_eq!(result, Err("資料庫錯誤".to_string()));
    }

    #[test]
    fn test_remove_existing_user() {
        let db = MockDatabase::with_users(vec![(1, "David".to_string())]);
        let mut service = UserService::new(db);

        let result = service.remove_user(1);
        assert_eq!(result, "用戶 1 已被刪除");

        // 驗證用戶確實被刪除
        let user_info = service.get_user_info(1);
        assert_eq!(user_info, "找不到用戶 1");
    }

    #[test]
    fn test_remove_nonexistent_user() {
        let db = MockDatabase::new();
        let mut service = UserService::new(db);

        let result = service.remove_user(999);
        assert_eq!(result, "刪除用戶 999 失敗");
    }
}

自訂測試框架與巨集

對於重複的測試模式,我們可以建立自訂的測試巨集:

// 建立一個巨集來簡化數學函式的測試
macro_rules! test_math_function {
    ($func_name:ident, $test_name:ident, $input:expr, $expected:expr) => {
        #[test]
        fn $test_name() {
            let result = $func_name($input.0, $input.1);
            assert_eq!(result, $expected);
        }
    };
    
    // 支援多組測試資料
    ($func_name:ident, $test_name:ident, [$(($input:expr, $expected:expr)),+ $(,)?]) => {
        #[test]
        fn $test_name() {
            $(
                let result = $func_name($input.0, $input.1);
                assert_eq!(result, $expected, 
                    "Failed for input {:?}, expected {}, got {}", 
                    $input, $expected, result);
            )+
        }
    };
}

// 自訂斷言巨集
macro_rules! assert_approximately_eq {
    ($left:expr, $right:expr, $epsilon:expr) => {
        assert!(
            ($left - $right).abs() < $epsilon,
            "assertion failed: `(left ~= right)`\n  left: `{}`,\n right: `{}`,\n epsilon: `{}`",
            $left,
            $right,
            $epsilon
        );
    };
    
    ($left:expr, $right:expr) => {
        assert_approximately_eq!($left, $right, f64::EPSILON);
    };
}

// 使用自訂巨集進行測試
fn add(a: i32, b: i32) -> i32 {
    a + b
}

fn multiply(a: i32, b: i32) -> i32 {
    a * b
}

fn divide(a: f64, b: f64) -> f64 {
    a / b
}

test_math_function!(add, test_add_positive, (2, 3), 5);
test_math_function!(add, test_add_negative, (-2, -3), -5);
test_math_function!(multiply, test_multiply_basic, (4, 5), 20);

test_math_function!(add, test_add_multiple, [
    ((1, 1), 2),
    ((5, 7), 12),
    ((-3, 8), 5),
    ((0, 0), 0),
]);

#[cfg(test)]
mod float_tests {
    use super::*;

    #[test]
    fn test_division_with_custom_assertion() {
        assert_approximately_eq!(divide(22.0, 7.0), 3.142857, 0.000001);
        assert_approximately_eq!(divide(1.0, 3.0), 0.333333, 0.000001);
    }

    #[test]
    fn test_floating_point_arithmetic() {
        let result = 0.1 + 0.2;
        // 不能直接用 == 比較浮點數
        // assert_eq!(result, 0.3); // 這會失敗!
        
        // 使用自訂的近似相等斷言
        assert_approximately_eq!(result, 0.3, 1e-10);
    }
}

控制測試的並行執行

#[cfg(test)]
mod concurrent_tests {
    use std::sync::{Arc, Mutex};
    use std::thread;
    use std::time::Duration;

    // 模擬共享資源
    static mut GLOBAL_COUNTER: i32 = 0;
    static MUTEX_COUNTER: Mutex<i32> = Mutex::new(0);

    #[test]
    fn test_thread_safe_counter() {
        let counter = Arc::new(Mutex::new(0));
        let mut handles = vec![];

        for _ in 0..10 {
            let counter_clone = Arc::clone(&counter);
            let handle = thread::spawn(move || {
                for _ in 0..100 {
                    let mut num = counter_clone.lock().unwrap();
                    *num += 1;
                }
            });
            handles.push(handle);
        }

        for handle in handles {
            handle.join().unwrap();
        }

        assert_eq!(*counter.lock().unwrap(), 1000);
    }

    #[test]
    #[ignore] // 這個測試需要單獨執行,因為使用了全域變數
    fn test_global_state() {
        unsafe {
            GLOBAL_COUNTER = 0;
            GLOBAL_COUNTER += 1;
            assert_eq!(GLOBAL_COUNTER, 1);
        }
    }

    #[test]
    fn test_timing_sensitive_operation() {
        let start = std::time::Instant::now();
        
        // 模擬一個耗時操作
        thread::sleep(Duration::from_millis(100));
        
        let elapsed = start.elapsed();
        
        // 測試操作是否在合理時間內完成
        assert!(elapsed >= Duration::from_millis(100));
        assert!(elapsed < Duration::from_millis(200));
    }
}

今天的收穫

今天我們深入探討了 Rust 的測試生態系統:

核心概念

  • 單元測試:使用 #[test] 屬性和 assert! 系列巨集
  • 整合測試:放在 tests/ 目錄中,測試公開 API
  • 文件測試:在文件註解中的可執行範例

實用技巧

  • 測試組織:使用 #[cfg(test)] 模組
  • 模擬與測試替身:透過 trait 抽象化依賴
  • 自訂測試工具:巨集和輔助函式

進階主題

  • 控制測試的並行執行:處理共享狀態

為什麼測試很重要?

  • 品質保證:及早發現和修復 bug
  • 重構安全:確保修改不會破壞功能
  • 文件化:測試本身就是最好的使用範例
  • 設計改善:促進更好的 API 設計

測試是現代軟體開發不可或缺的一環。Rust 的測試框架雖然簡單,但功能強大且易於使用。掌握測試技巧將讓你能夠開發出更可靠、更容易維護的軟體。

今天的小挑戰

為了鞏固今天的學習,嘗試為一個圖書館管理系統建立完整的測試套件:

功能需求

  1. 書籍管理:新增、刪除、更新書籍資訊
  2. 會員管理:註冊、更新會員資料
  3. 借閱系統:借書、還書、續借
  4. 查詢功能:按標題、作者、ISBN 搜尋
  5. 統計報告:借閱次數、熱門書籍等

測試要求

  • 為每個功能撰寫單元測試
  • 建立整合測試驗證系統工作流程
  • 使用模擬物件測試外部依賴(如資料庫)
  • 撰寫文件測試展示 API 用法
  • 測試錯誤處理和邊界條件

技術提示

// 定義核心結構
pub struct Book {
    isbn: String,
    title: String,
    author: String,
    available: bool,
}

pub struct Member {
    id: u32,
    name: String,
    email: String,
    borrowed_books: Vec<String>, // ISBN 列表
}

pub struct Library<D: Database> {
    database: D,
}

// 定義資料庫介面以便測試
pub trait Database {
    fn save_book(&mut self, book: &Book) -> Result<(), String>;
    fn find_book(&self, isbn: &str) -> Option<Book>;
    fn save_member(&mut self, member: &Member) -> Result<(), String>;
    // 更多方法...
}

// 測試範例
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_book_creation() {
        // 測試書籍建立
    }

    #[test]
    fn test_member_borrow_book() {
        // 測試借書流程
    }

    #[test]
    #[should_panic(expected = "書籍已被借出")]
    fn test_borrow_unavailable_book() {
        // 測試借閱不可用書籍的錯誤處理
    }
}

這個挑戰將讓你綜合運用今天學到的所有測試技巧:單元測試、整合測試、模擬物件、錯誤測試等。重點是建立一個完整、可靠的測試體系,確保圖書館系統的每個功能都經過充分驗證。

明天我們將開始第三週的學習,探討閉包 (Closures) 與迭代器 (Iterators),這些函數式程式設計的概念將讓我們能夠寫出更簡潔、更高效的程式碼!

如果在實作過程中遇到任何問題,歡迎在留言區討論。測試技能需要在實際專案中不斷練習和改進,但建立良好的測試習慣將讓你受益終生!

我們明天見!


上一篇
Day 13: 智慧指標 (Smart Pointers):Box, Rc, Arc 與進階記憶體管理
下一篇
Day 15: 閉包 (Closures) 與迭代器 (Iterators):函數式程式設計的優雅之道
系列文
大家一起跟Rust當好朋友吧!18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言