本文 AI 產出,尚未審核

Rust 單元測試(Unit Tests)


簡介

在軟體開發的全流程中,測試是保證程式品質與可維護性的關鍵步驟。對於 Rust 這樣主打安全與效能的系統程式語言而言,單元測試更是不可或缺的工具。透過單元測試,我們可以在 編譯期就捕捉到邏輯錯誤、驗證 API 行為、以及在重構時確保既有功能不會意外退化。

Rust 的測試框架內建於 cargo,不需要額外安裝套件,只要在程式碼中加入 #[cfg(test)] 模組與 #[test] 標記,即可讓測試自動與程式碼一起編譯、執行。本文將從概念說明、實作範例、常見陷阱到最佳實踐,完整介紹如何在 Rust 專案中撰寫、執行與維護單元測試,幫助初學者快速上手,同時提供中階開發者可延伸的測試策略。


核心概念

1. 測試模組的基本結構

在 Rust 中,測試程式碼通常放在同一檔案的 測試模組 (mod tests) 內,並使用 #[cfg(test)] 讓編譯器只在測試建置時編譯此模組。

// src/lib.rs
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

// 只在 `cargo test` 時編譯
#[cfg(test)]
mod tests {
    // 引入外層模組的項目
    use super::*;

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
    }
}
  • #[cfg(test)]:條件編譯屬性,讓測試程式碼不會進入正式發行的 binary。
  • #[test]:標記函式為測試案例,執行時若函式 panic,測試即失敗。

2. 斷言(Assertions)

Rust 提供多種斷言宏,最常用的是 assert!assert_eq!assert_ne!

#[test]
fn test_assertions() {
    // 一般條件斷言
    assert!(true);
    // 相等斷言
    assert_eq!(2 + 2, 4);
    // 不相等斷言
    assert_ne!(5, 3);
}

若斷言失敗,Rust 會自動 panic,測試框架會捕捉到並報告失敗。

3. 測試失敗的訊息與自訂訊息

為了讓失敗原因更易於閱讀,斷言宏支援自訂訊息:

#[test]
fn test_custom_message() {
    let result = add(1, 2);
    assert_eq!(result, 4, "加法結果錯誤:預期 4,實際得到 {}", result);
}

4. 測試的執行與篩選

使用 cargo test 會執行所有測試。若只想跑特定測試,可利用 測試名稱過濾

cargo test test_add          # 只跑名稱包含 `test_add` 的測試
cargo test -- --ignored     # 執行被標記為 #[ignore] 的測試

5. 忽略測試(#[ignore])

有些測試可能耗時較長或依賴外部資源,適合在 CI 中暫時忽略:

#[test]
#[ignore] // 預設不執行,除非加上 `--ignored`
fn long_running_test() {
    // 假設這裡會跑 30 秒以上的運算
}

6. 測試前後的設定與清理

#[test] 只能標記單一函式,若需要在每個測試前後執行共用程式碼,可使用 測試夾具(fixture),通常透過 setup 函式回傳測試需要的狀態,或使用 Drop 釋放資源。

struct Database {
    // 模擬的 DB 連線
}

impl Database {
    fn new() -> Self { Database {} }
    fn query(&self, sql: &str) -> usize { 42 } // 假設回傳筆數
}

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

    fn setup() -> Database {
        // 這裡可以放置初始化邏輯,例如建立測試資料庫
        Database::new()
    }

    #[test]
    fn test_query() {
        let db = setup();
        let count = db.query("SELECT * FROM users");
        assert_eq!(count, 42);
    }
}

7. 測試私有函式

Rust 允許在同一檔案內測試 私有 (fn) 函式,因為測試模組與被測試模組在同一個 crate 中,use super::*; 會把私有項目帶入測試範圍。

fn internal_logic(x: i32) -> i32 {
    x * 2
}

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

    #[test]
    fn test_internal_logic() {
        assert_eq!(internal_logic(3), 6);
    }
}

8. 測試異常情況(should_panic)

若函式預期會在錯誤條件下 panic,可使用 #[should_panic] 標記:

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

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

    #[test]
    #[should_panic(expected = "除數不能為 0")]
    fn test_divide_by_zero() {
        divide(10, 0);
    }
}

expected 參數讓測試僅在符合特定訊息時才算成功,避免因其他 panic 而誤判。


程式碼範例

以下提供 5 個實用範例,涵蓋基礎斷言、測試夾具、異步測試、參數化測試與自訂測試宏。

範例 1:基本算術函式與測試

// src/math.rs
pub fn mul(a: i32, b: i32) -> i32 {
    a * b
}

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

    #[test]
    fn test_mul_positive() {
        assert_eq!(mul(3, 4), 12);
    }

    #[test]
    fn test_mul_negative() {
        assert_eq!(mul(-2, 5), -10);
    }
}

重點:同一檔案內即可完成函式與對應測試,讓開發者在編寫功能時即同步驗證正確性。

範例 2:使用測試夾具(fixture)模擬資料庫

// src/db.rs
pub struct FakeDb {
    data: Vec<i32>,
}

impl FakeDb {
    pub fn new() -> Self {
        FakeDb { data: vec![1, 2, 3] }
    }

    pub fn insert(&mut self, val: i32) {
        self.data.push(val);
    }

    pub fn count(&self) -> usize {
        self.data.len()
    }
}

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

    // 測試前的共用初始化
    fn setup_db() -> FakeDb {
        FakeDb::new()
    }

    #[test]
    fn test_insert() {
        let mut db = setup_db();
        db.insert(4);
        assert_eq!(db.count(), 4);
    }

    #[test]
    fn test_initial_count() {
        let db = setup_db();
        assert_eq!(db.count(), 3);
    }
}

說明setup_db 充當測試夾具,保證每個測試都有乾淨、可預測的狀態。

範例 3:異步函式的單元測試

Rust 1.39 起支援 async/await,測試異步程式碼需要 #[tokio::test](或 async-std)等測試執行器。

// Cargo.toml
// [dependencies]
// tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

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

pub async fn delayed_double(x: i32) -> i32 {
    sleep(Duration::from_millis(50)).await;
    x * 2
}

#[cfg(test)]
mod tests {
    use super::*;
    // tokio 提供的異步測試屬性
    #[tokio::test]
    async fn test_delayed_double() {
        let result = delayed_double(5).await;
        assert_eq!(result, 10);
    }
}

提示:使用 #[tokio::test] 時,測試本身會自動建立 Tokio 執行緒池,無需手動建立 runtime。

範例 4:參數化測試(使用宏)

Rust 標準庫沒有內建參數化測試,但可以透過自訂宏簡化多組測試資料。

// src/util.rs
pub fn is_even(n: i32) -> bool {
    n % 2 == 0
}

// 自訂測試宏
macro_rules! param_test {
    ($name:ident, $func:ident, [$(($input:expr, $expected:expr)),* $(,)?]) => {
        #[test]
        fn $name() {
            $(
                assert_eq!($func($input), $expected,
                    "測試失敗:{}({}) 應該等於 {}", stringify!($func), $input, $expected);
            )*
        }
    };
}

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

    // 使用宏產生多組測試
    param_test!(test_is_even, is_even, [
        (0, true),
        (1, false),
        (2, true),
        (15, false),
        (-4, true),
    ]);
}

好處:一次寫入多筆測試資料,減少樣板程式碼,且錯誤訊息仍保持可讀性。

範例 5:測試失敗時的自訂訊息與 should_panic

// src/parse.rs
pub fn parse_number(s: &str) -> Result<i32, &'static str> {
    s.trim().parse::<i32>()
        .map_err(|_| "無法解析為整數")
}

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

    #[test]
    fn test_parse_success() {
        let v = parse_number(" 42 ").expect("應該能成功解析");
        assert_eq!(v, 42);
    }

    #[test]
    fn test_parse_failure() {
        let err = parse_number("abc").unwrap_err();
        assert_eq!(err, "無法解析為整數",
            "解析錯誤訊息不符,實際為 {}", err);
    }

    #[test]
    #[should_panic(expected = "unwrap")]
    fn test_unwrap_panic() {
        // 直接 unwrap 會在錯誤時 panic,這裡測試 panic 行為
        parse_number("NaN").unwrap();
    }
}

說明unwrap_err 讓我們取得錯誤值並檢查內容;#[should_panic] 用來驗證程式在錯誤條件下會正確 panic。


常見陷阱與最佳實踐

常見陷阱 可能原因 解決方式 / 最佳實踐
測試永遠通過 斷言寫錯或忘記加入斷言 確認每個測試都有至少一個 assert!assert_eq!assert_ne!
測試依賴全域狀態 使用 static mut、全域變數或單例 盡量使用 測試夾具,讓每個測試擁有獨立的環境
測試速度慢 在測試內部執行 I/O、網路請求或大量計算 使用 #[ignore] 標記長時間測試,或在 CI 中分層執行;本地開發時只跑快測試
測試失敗訊息不清楚 直接使用 assert!,未提供自訂訊息 為重要斷言加入 , "說明文字 {}",讓失敗時快速定位
測試程式碼與業務程式碼混雜 把測試寫在 main.rs,導致編譯產出變大 測試模組 放在 src/lib.rs 或對應模組內,保持 main.rs 只負責執行邏輯
忘記 #[cfg(test)] 測試程式碼被編譯進正式 binary,增大檔案 確認所有測試模組都有 #[cfg(test)] 包住
測試不夠隔離 測試間共享同一個暫存檔或資料庫 每個測試使用唯一的臨時目錄(tempfile crate)或在測試結束時清理資源

最佳實踐清單

  1. 測試即寫:功能完成前先寫測試,形成 TDD 流程。
  2. 保持測試獨立:不要讓測試相互依賴,使用 setup/teardownDrop 釋放資源。
  3. 命名具描述性:測試函式名稱應該說明「測試什麼」與「預期結果」,如 test_add_overflow
  4. 使用自訂訊息:在斷言失敗時提供足夠的上下文,減少除錯時間。
  5. 分層測試:單元測試(fast)→整合測試(mid)→端到端測試(slow),在 CI 中分別執行。
  6. 持續監控測試覆蓋率:使用 cargo tarpaulingrcov 追蹤覆蓋率,確保關鍵路徑都有測試。
  7. 避免測試依賴外部服務:若必須,使用 mock(如 mockall)或 測試雙(test double)取代真實服務。

實際應用場景

  1. 函式庫(crate)發布前的驗證

    • 在開源函式庫(如 serderegex)中,單元測試確保每個公開 API 在不同平台、不同編譯選項下皆正確運作。
  2. 嵌入式系統的安全關鍵模組

    • 針對硬體抽象層(HAL)或驅動程式,使用單元測試模擬硬體寄存器,提前捕捉錯誤,減少實機除錯成本。
  3. Web 服務的業務邏輯

    • 在使用 actix-webrocket 等框架時,將路由處理器的核心邏輯抽離成純函式,透過單元測試驗證資料驗證、授權判斷等。
  4. 資料處理與演算法

    • 大型資料分析或機器學習前置處理(如 CSV 解析、特徵工程)常伴隨大量邊界條件,單元測試可保證每一步的正確性。
  5. 持續整合(CI)流程

    • 在 GitHub Actions、GitLab CI 中加入 cargo test --all --locked,確保每次 PR 都通過所有單元測試,避免回歸。

總結

Rust 內建的測試框架以 簡潔、快速、可組合 為設計核心,使開發者能在同一個專案中同時撰寫功能與驗證程式碼。透過 #[cfg(test)]#[test]、斷言宏與測試夾具,我們可以建立 可靠、可維護 的單元測試集合;再加上 #[should_panic]#[ignore]、異步測試支援,幾乎可以涵蓋所有常見的測試需求。

在實務上,遵循 測試即寫、保持測試獨立、提供清晰訊息 的最佳實踐,並在 CI 中持續執行與監控測試覆蓋率,能大幅降低程式錯誤的風險,提升開發效率與產品品質。

從今天開始,把每一個新功能都視為「先寫測試,再寫實作」的任務,讓 Rust 的安全與效能在測試的保護下發揮到極致!