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)或在測試結束時清理資源 |
最佳實踐清單
- 測試即寫:功能完成前先寫測試,形成 TDD 流程。
- 保持測試獨立:不要讓測試相互依賴,使用
setup/teardown或Drop釋放資源。 - 命名具描述性:測試函式名稱應該說明「測試什麼」與「預期結果」,如
test_add_overflow。 - 使用自訂訊息:在斷言失敗時提供足夠的上下文,減少除錯時間。
- 分層測試:單元測試(fast)→整合測試(mid)→端到端測試(slow),在 CI 中分別執行。
- 持續監控測試覆蓋率:使用
cargo tarpaulin或grcov追蹤覆蓋率,確保關鍵路徑都有測試。 - 避免測試依賴外部服務:若必須,使用 mock(如
mockall)或 測試雙(test double)取代真實服務。
實際應用場景
函式庫(crate)發布前的驗證
- 在開源函式庫(如
serde、regex)中,單元測試確保每個公開 API 在不同平台、不同編譯選項下皆正確運作。
- 在開源函式庫(如
嵌入式系統的安全關鍵模組
- 針對硬體抽象層(HAL)或驅動程式,使用單元測試模擬硬體寄存器,提前捕捉錯誤,減少實機除錯成本。
Web 服務的業務邏輯
- 在使用
actix-web、rocket等框架時,將路由處理器的核心邏輯抽離成純函式,透過單元測試驗證資料驗證、授權判斷等。
- 在使用
資料處理與演算法
- 大型資料分析或機器學習前置處理(如 CSV 解析、特徵工程)常伴隨大量邊界條件,單元測試可保證每一步的正確性。
持續整合(CI)流程
- 在 GitHub Actions、GitLab CI 中加入
cargo test --all --locked,確保每次 PR 都通過所有單元測試,避免回歸。
- 在 GitHub Actions、GitLab CI 中加入
總結
Rust 內建的測試框架以 簡潔、快速、可組合 為設計核心,使開發者能在同一個專案中同時撰寫功能與驗證程式碼。透過 #[cfg(test)]、#[test]、斷言宏與測試夾具,我們可以建立 可靠、可維護 的單元測試集合;再加上 #[should_panic]、#[ignore]、異步測試支援,幾乎可以涵蓋所有常見的測試需求。
在實務上,遵循 測試即寫、保持測試獨立、提供清晰訊息 的最佳實踐,並在 CI 中持續執行與監控測試覆蓋率,能大幅降低程式錯誤的風險,提升開發效率與產品品質。
從今天開始,把每一個新功能都視為「先寫測試,再寫實作」的任務,讓 Rust 的安全與效能在測試的保護下發揮到極致!