快轉到主要內容
  1. 教學文章/

Rust 測試入門:unit test、integration test 與 cargo test 完全攻略

·10 分鐘· loading · loading · ·
Rust Testing Unit-Test Integration-Test Cargo Python
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Rust 入門 - 本文屬於一個選集。
§ 12: 本文

featured

一、前言
#

很多 Python 開發者第一次碰 Rust 時,會先被 ownership 嚇到。 第二個卡關點,通常是測試。 在 Python 世界裡,pytest 幾乎是標配。 寫個 test_*.py,跑 pytest,失敗訊息也很親切。 Rust 則是另一種哲學。 它把測試直接放進語言和工具鏈裡。 你不用先選測試框架,不用先決定 plugin,不用煩惱哪個 runner 才是主流。 cargo test 幾乎就是起手式。 而且 Rust 的測試體驗其實很不錯。 單元測試、整合測試、文件測試,都有內建支持。 加上編譯器幫你把型別和 borrow 規則先擋掉,很多 bug 甚至在「跑測試之前」就先被消滅了。 這篇文章會用 Python 工程師熟悉的角度,帶你把 Rust 測試一次摸清楚。 我們會依序看:

  • 最基本的 #[test]
  • 常用斷言巨集
  • should_panicResult 型測試
  • 如何測 private function
  • integration test 的資料夾結構
  • cargo test 的幾個超實用參數 如果你剛學完 Rust 基礎語法,這篇剛好可以接上。 如果你已經會寫功能,但還沒建立測試習慣,那更值得補起來。

二、安裝與準備
#

今天我們用 library 專案示範。 因為 library 比較容易展示 function 測試,也比較符合 Rust 社群常見教學方式。 先建立一個專案:

cargo new rust-testing-demo --lib
cd rust-testing-demo

--lib 代表建立函式庫,而不是 CLI binary。 執行後你會看到類似這樣的結構:

rust-testing-demo/
├── Cargo.toml
└── src/
    └── lib.rs

Cargo 幫你產生的 src/lib.rs,其實已經內建一個最小測試:

pub fn add(left: u64, right: u64) -> u64 {
    left + right
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

直接跑:

cargo test

你會看到:

running 1 test
test tests::it_works ... ok
test result: ok. 1 passed; 0 failed

這裡先記三個重點。 第一,Rust 測試通常就寫在原始碼旁邊。 第二,#[cfg(test)] 表示這個模組只在測試時編譯。 第三,#[test] 是告訴 test runner 這是一個測試函式。 如果你來自 Python,可以把它想成「不用另外裝 pytest,也不用遵守 test 檔名規則,Cargo 會直接懂你在幹嘛」。
#

三、第一個 unit test:從 #[test] 開始
#

我們先把範例換成比較像真實功能的東西。 假設你在做一個文字工具 crate,要把使用者輸入做標準化:

pub fn normalize_username(name: &str) -> String {
    name.trim().to_lowercase()
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn trims_spaces_and_lowercases() {
        let result = normalize_username("  PaTTy  ");
        assert_eq!(result, "patty");
    }
}

跑測試:

cargo test

這就是最基本的 unit test。 重點其實只有兩件事:

  1. 準備輸入
  2. 驗證輸出 如果你熟 Python,這個感覺跟下面很像:
def normalize_username(name: str) -> str:
    return name.strip().lower()
def test_trims_spaces_and_lowercases():
    assert normalize_username("  PaTTy  ") == "patty"

概念完全一致。 差別只是 Rust 用 attribute 標記測試,Python 常靠命名規則加 framework discovery。

為什麼 use super::*;
#

因為 tests 模組被包在原檔案裡面。 super 指向上一層模組,也就是 lib.rs 目前那層。 所以這行的意思是,把外層定義的函式都引進來測試。 如果你不喜歡 wildcard,也可以明確寫:

use super::normalize_username;

在小測試裡,use super::*; 很常見。 但如果模組大了,明確 import 反而可讀性比較高。

測試名稱要像句子
#

Rust 社群很常用 snake_case 寫出「行為描述」。 例如:

  • returns_zero_for_empty_input
  • rejects_invalid_email
  • keeps_original_order_when_scores_tie 這個習慣很值得學。 因為當 cargo test 失敗時,你會直接看到測試名。 名字清楚,除錯速度真的差很多。

四、常用斷言:assert!assert_eq!assert_ne!
#

Rust 內建的 assertion macro 很夠用。 最常見的是這三個:

1. assert!
#

用來驗證條件為真。

pub fn is_adult(age: u8) -> bool {
    age >= 18
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn adult_age_should_return_true() {
        assert!(is_adult(20));
    }
    #[test]
    fn minor_age_should_return_false() {
        assert!(!is_adult(15));
    }
}

2. assert_eq!
#

用來比對左右兩邊是否相等。 它會在失敗時印出 left/right,非常實用。

pub fn double(n: i32) -> i32 {
    n * 2
}
#[test]
fn doubles_number() {
    assert_eq!(double(21), 42);
}

3. assert_ne!
#

確認兩邊不相等。

#[test]
fn normalized_name_should_not_keep_original_case() {
    let result = normalize_username("PaTTy");
    assert_ne!(result, "PaTTy");
}

失敗訊息也可以自訂
#

這點跟 Python assert ..., "message" 很像。 例如:

#[test]
fn score_should_be_in_valid_range() {
    let score = 87;
    assert!(score <= 100, "score should not exceed 100, got {score}");
}

當測試邏輯不是單純相等比較時,自訂訊息特別好用。 不然半年後回來看,真的會忘記當初在驗什麼。
#

五、測試錯誤情境:should_panicResult
#

真實世界不是每個函式都只會成功。 有些功能本來就應該在非法輸入時報錯。 Rust 在這塊提供兩種常見作法。

作法一:#[should_panic]
#

先看一個會 panic 的函式:

pub fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("divisor cannot be zero");
    }
    a / b
}

對應測試:

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn divides_normally() {
        assert_eq!(divide(12, 3), 4);
    }
    #[test]
    #[should_panic(expected = "divisor cannot be zero")]
    fn panics_when_dividing_by_zero() {
        divide(10, 0);
    }
}

只要函式真的 panic,而且訊息包含指定字串,測試就算通過。 如果你不寫 expected = ...,那任何 panic 都會被接受。 拍拍君的建議是,能寫 expected 就寫。 因為這樣能避免測試只是「剛好壞掉也算過」。

作法二:回傳 Result 的測試
#

有些測試流程比較長,可能中途會用到 ?。 這時 Rust 允許測試函式直接回傳 Result<(), E>

fn parse_port(text: &str) -> Result<u16, std::num::ParseIntError> {
    text.parse::<u16>()
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn parses_valid_port() -> Result<(), std::num::ParseIntError> {
        let port = parse_port("8080")?;
        assert_eq!(port, 8080);
        Ok(())
    }
}

這種寫法在需要多步驟 setup 時很好用。 尤其當你測的是 I/O、序列化、時間格式、路徑處理之類的功能。 它讀起來比一堆 unwrap() 乾淨。 也比較不會把真正錯誤原因藏起來。

那什麼時候該測 panic,什麼時候該回傳 Result
#

簡單原則:

  • 如果 API 設計就是「錯了直接 panic」,用 should_panic
  • 如果錯誤本來就是業務邏輯的一部分,應該設計成 Result 這點和 Python 很像。 只是 Python 常用 pytest.raises(...)。 Rust 則偏向在型別層面把錯誤路徑寫清楚。 如果你想複習 Rust 的錯誤模型,可以回頭看這篇:Rust 錯誤處理:Result/Option vs Python try/except

六、測試 private function,其實很自然
#

很多剛接觸 Rust 的人會問: 「private function 可以測嗎?」 答案是,可以。 而且很方便。 因為 unit test 通常就放在同一個檔案裡。 看個例子:

fn collapse_spaces(text: &str) -> String {
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub fn normalize_title(text: &str) -> String {
    collapse_spaces(text.trim())
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn private_helper_should_collapse_multiple_spaces() {
        assert_eq!(collapse_spaces("Rust   testing   guide"), "Rust testing guide");
    }
    #[test]
    fn public_api_should_trim_and_collapse_spaces() {
        assert_eq!(normalize_title("  Rust   testing   guide  "), "Rust testing guide");
    }
}

因為 tests 是同檔案內的子模組,所以它可以存取外層的 private item。 這點超適合測一些:

  • parser helper
  • 字串清理函式
  • 邊界條件判斷
  • 小型轉換邏輯 在 Python,你也可以測私有 helper,但通常只是靠慣例。 Rust 這邊的模組可見性更明確,所以界線比較清楚。 拍拍君自己的習慣是:
  • 複雜邏輯,直接測 helper
  • 對外保證,再補一層測 public API 這樣失敗時比較容易定位問題。 如果只測最外層,一旦案例很多,debug 會比較慢。

七、Integration Test:測 crate 的公開介面
#

單元測試適合驗證「小功能」。 但當你想確認多個模組串起來有沒有正常工作,就該上 integration test 了。 Rust 的 integration test 慣例放在專案根目錄的 tests/。 目錄像這樣:

rust-testing-demo/
├── Cargo.toml
├── src/
│   └── lib.rs
└── tests/
    ├── auth_flow.rs
    └── common/
        └── mod.rs

先把 src/lib.rs 改成:

pub fn register_user(name: &str, age: u8) -> Result<String, String> {
    if name.trim().is_empty() {
        return Err("name cannot be empty".into());
    }
    if age < 13 {
        return Err("age must be at least 13".into());
    }
    Ok(format!("user:{}", name.trim().to_lowercase()))
}

接著建立 tests/auth_flow.rs

use rust_testing_demo::register_user;
#[test]
fn registers_valid_user() {
    let user_id = register_user("拍拍君", 20).unwrap();
    assert_eq!(user_id, "user:拍拍君");
}
#[test]
fn rejects_empty_name() {
    let err = register_user("   ", 20).unwrap_err();
    assert_eq!(err, "name cannot be empty");
}
#[test]
fn rejects_underage_user() {
    let err = register_user("拍拍醬", 10).unwrap_err();
    assert_eq!(err, "age must be at least 13");
}

這裡有一個超重要差異。 integration test 不能直接摸 private function。 它是從 crate 外部的角度來用你的套件。 所以它只能使用 pub 公開的 API。 這也是 integration test 的價值所在。 它逼你站在使用者角度思考: 「如果我是別的 crate,我到底拿得到什麼?」

跟 unit test 的差別整理
#

類型 放哪裡 能不能碰 private 適合測什麼
Unit test 原始碼同檔或同模組 可以 小函式、邊界條件、內部邏輯
Integration test tests/ 目錄 不行 公開 API、模組互動、主要流程
如果你熟 Python,可以把 integration test 想成「不要 import internal helper,只測正式對外介面」。
這種測法在重構時特別有安全感。
因為內部怎麼改都行,只要 public behavior 不變,測試就會繼續綠燈。

八、共享測試工具:tests/common/mod.rs
#

integration test 多了之後,通常會遇到重複 setup。 例如:

  • 建立假資料
  • 產生測試用帳號
  • 建立 temporary path
  • 包一些常用 assertion 這時可以放到 tests/common/mod.rs。 例如:
pub fn demo_name() -> &'static str {
    "拍拍君"
}
pub fn demo_age() -> u8 {
    20
}

然後在 tests/auth_flow.rs 裡:

mod common;
use common::{demo_age, demo_name};
use rust_testing_demo::register_user;
#[test]
fn registers_valid_user() {
    let user_id = register_user(demo_name(), demo_age()).unwrap();
    assert_eq!(user_id, "user:拍拍君");
}

注意這個 mod common; 是要你手動宣告的。 因為 tests/ 裡的每個檔案本質上都是獨立 crate。 這個設計一開始有點怪。 但習慣後就會覺得它很乾淨。 共享邏輯明確,彼此依賴關係也看得出來。

小提醒
#

如果你把 helper 寫成 tests/common.rs,Cargo 可能會把它也當成一個 test target。 很多人因此看到「0 tests」然後愣一下。 用 tests/common/mod.rs 這種結構通常更直觀。
#

九、cargo test 的實用指令,比你想像中好用很多
#

只會 cargo test 還不夠。 真的開始寫 Rust 專案後,下面幾招非常常用。

1. 跑全部測試
#

cargo test

這會跑:

  • unit tests
  • integration tests
  • doctests 也就是說,Rust 文件裡的範例如果有標成 code block,也可能被拿去測。 這點等等會提到。

2. 只跑名稱包含某字串的測試
#

cargo test register

如果你的測試名包含 register,就只會跑那些。 這很像:

pytest -k register

在你只想重跑一小段測試時,超方便。

3. 顯示 println! 輸出
#

cargo test -- --nocapture

預設情況下,Rust 會把測試輸出吃掉。 只有失敗時才印。 如果你正在 debug,這個參數非常重要。 Python 人應該會立刻聯想到:

pytest -s

沒錯,感覺差不多。

4. 只跑某個 integration test 檔案
#

cargo test --test auth_flow

tests/ 底下檔案越來越多時,這個很好用。 你不用每次都把整個專案測一輪。

5. 跑單一測試執行緒
#

cargo test -- --test-threads=1

大多數情況下,Rust 會平行跑測試。 這很快。 但如果你的測試會共用檔案、環境變數、port 或資料庫資源,就可能互相踩到。 這時先切成單執行緒,比硬 debug 半天划算。
#

十、文件測試(doctest):讓文件不是裝飾品
#

Rust 很喜歡把文件和程式碼綁在一起。 所以你在註解裡放的範例,也能被 cargo test 驗證。 例如:

/// 將名字標準化成小寫、去除前後空白。
///
/// # Examples
///
/// ```
/// use rust_testing_demo::normalize_username;
///
/// assert_eq!(normalize_username("  PaTTy  "), "patty");
/// ```
pub fn normalize_username(name: &str) -> String {
    name.trim().to_lowercase()
}

這個例子有兩個好處。 第一,文件就是活的。 如果未來你改壞 API,範例也會跟著 fail。 第二,使用者看到文件時,拿到的是「真的能跑」的程式碼,不是隨手寫寫的假片段。 Python 世界也能做到類似事,但通常要額外靠 doctest 模組或其他工具。 Rust 這邊是預設整合進來的。 老實說,這點我很喜歡。 很工程。 也很實際。
#

十一、Rust 測試思維,和 Python 有什麼不同?
#

如果要用一句話總結: Python 常把測試框架當外掛,Rust 則把測試視為語言工具鏈的一部分。 所以你會感受到幾個差異:

1. 開始成本更低
#

Rust 幾乎開箱即用。 你不用先選 pytestunittestnosehypothesis 哪套才是主力。 當然,進階生態系還是很多。 但最基礎的單元與整合測試,不加套件就能做得很好。

2. 編譯期先幫你擋一層
#

這點不是測試功能本身,但影響很大。 在 Python,你常常要寫測試來防止:

  • 傳錯型別
  • 拼錯欄位名
  • 回傳值形狀不一致 Rust 因為編譯器更嚴格,這些有一部分會先爆在 compile time。 所以你可以把測試資源更專注放在:
  • 邏輯正確性
  • 邊界條件
  • API 行為
  • 錯誤路徑

3. 公開介面和內部細節的邊界更清楚
#

unit test 可以測內部。 integration test 則強迫你從外部觀點驗證。 這個分層非常健康。 專案一大,差別就出來了。

4. 測試名稱與模組結構很重要
#

Rust 不太鼓勵你把所有測試都扔進一大包。 它希望你的模組關係是有結構的。 剛開始可能覺得稍微囉唆。 但等你維護半年後,就會感謝當初的自己。
#

十二、拍拍君的實戰建議
#

如果你今天就要把 Rust 測試加進現有專案,拍拍君建議這樣開始:

第一步,先替純函式補 unit test
#

最容易下手的是:

  • 字串轉換
  • 數值計算
  • parser
  • validation
  • config 處理 這些函式輸入輸出清楚,測起來成本最低。 先累積手感。

第二步,再補公開流程的 integration test
#

找 2 到 3 條最重要的 happy path 與 error path。 不用一開始就追求 100% coverage。 先保護最核心流程比較實際。

第三步,把 bug 轉成測試
#

這招超有效。 每修掉一個 bug,就留下一個會重現它的測試。 久了之後,你的測試集就不只是驗證功能。 它也是專案的「事故博物館」。 未來有人再把同樣問題引回來,CI 會先幫你罵人。 很棒。

第四步,讓 CI 跑 cargo test
#

本地有跑不代表每個人都會跑。 把它接到 CI,才是真的建立團隊習慣。 如果你對 GitHub Actions 有興趣,可以搭配這篇:GitHub Actions 進階:矩陣測試、Artifacts 與部署自動化
#

結語
#

Rust 的測試門檻其實沒有想像中高。 相反地,它可能比很多人熟悉的語言更一致、更有秩序。 你只要先掌握這幾個核心:

  • #[test]
  • assert! / assert_eq! / assert_ne!
  • #[should_panic]
  • Result 型測試
  • tests/ integration test
  • cargo test 常用參數 就已經能把大部分 Rust 專案的測試骨架搭起來了。 如果你本來就是 Python 開發者,那你會發現很多觀念其實不陌生。 只是 Rust 幫你把結構收得更緊,讓工具鏈更早介入。 這也是它迷人的地方。 寫起來有點硬。 但穩。 很穩。 下次當你寫完一個 Rust function,不妨順手補一個 #[test]。 你會很快習慣那個看到綠燈的安心感。

延伸閱讀
#

Rust 入門 - 本文屬於一個選集。
§ 12: 本文

相關文章

Rust Async + Tokio 入門:非同步 Rust vs Python asyncio
·8 分鐘· loading · loading
Rust Async Tokio Python Asyncio 非同步
Rust Closures + Iterators:用 FP 風格寫出優雅的 Rust
·9 分鐘· loading · loading
Rust Python Closures Iterators Functional Programming
Rust Collections vs Python:Vec 和 HashMap 的生存指南
·7 分鐘· loading · loading
Rust Python Collections Vec Hashmap
Rust 生命週期(Lifetime)入門:編譯器教你管記憶體
·8 分鐘· loading · loading
Rust Lifetime Borrowing Memory-Safety Python
Rust 錯誤處理:Result/Option vs Python try/except
·7 分鐘· loading · loading
Rust Python Error-Handling Result Option
Rust for Python 開發者:Ownership 與 Borrowing 入門
·7 分鐘· loading · loading
Rust Python Ownership Borrowing Memory