一、前言 #
很多 Python 開發者第一次碰 Rust 時,會先被 ownership 嚇到。
第二個卡關點,通常是測試。
在 Python 世界裡,pytest 幾乎是標配。
寫個 test_*.py,跑 pytest,失敗訊息也很親切。
Rust 則是另一種哲學。
它把測試直接放進語言和工具鏈裡。
你不用先選測試框架,不用先決定 plugin,不用煩惱哪個 runner 才是主流。
cargo test 幾乎就是起手式。
而且 Rust 的測試體驗其實很不錯。
單元測試、整合測試、文件測試,都有內建支持。
加上編譯器幫你把型別和 borrow 規則先擋掉,很多 bug 甚至在「跑測試之前」就先被消滅了。
這篇文章會用 Python 工程師熟悉的角度,帶你把 Rust 測試一次摸清楚。
我們會依序看:
- 最基本的
#[test] - 常用斷言巨集
should_panic與Result型測試- 如何測 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。 重點其實只有兩件事:
- 準備輸入
- 驗證輸出 如果你熟 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_inputrejects_invalid_emailkeeps_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_panic 與 Result
#
真實世界不是每個函式都只會成功。 有些功能本來就應該在非法輸入時報錯。 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 幾乎開箱即用。
你不用先選 pytest、unittest、nose、hypothesis 哪套才是主力。
當然,進階生態系還是很多。
但最基礎的單元與整合測試,不加套件就能做得很好。
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 testcargo test常用參數 就已經能把大部分 Rust 專案的測試骨架搭起來了。 如果你本來就是 Python 開發者,那你會發現很多觀念其實不陌生。 只是 Rust 幫你把結構收得更緊,讓工具鏈更早介入。 這也是它迷人的地方。 寫起來有點硬。 但穩。 很穩。 下次當你寫完一個 Rust function,不妨順手補一個#[test]。 你會很快習慣那個看到綠燈的安心感。
延伸閱讀 #
- Rust Book, Testing chapter: https://doc.rust-lang.org/book/ch11-00-testing.html
- Cargo Book, cargo test: https://doc.rust-lang.org/cargo/commands/cargo-test.html
- Rust 錯誤處理:Result/Option vs Python try/except: ../rust-error-handling/
- Python pytest 入門:單元測試、fixture 與參數化測試: ../python-pytest/