15.1 單元測試與整合測試#
內建的測試框架#
Rust 的標準函式庫內建了完整的測試框架,不需要像多數語言那樣額外引入測試框架。測試函式以 #[test] 屬性標記,測試模組以 #[cfg(test)] 條件編譯,cargo test 會編譯測試並依序執行。
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn adds_two_numbers() {
assert_eq!(add(2, 3), 5);
}
}
#[cfg(test)] 意味著這個模組只在測試編譯時存在,正式建置時它會被完全移除,因此測試用的輔助函式不會進入發行產物。這個設計讓測試碼碼能自由使用 super::* 引入被測模組的私有項目,卻不必擔心污染公開 API。
執行測試的基本指令是 cargo test。它會先建置測試目標,再執行測試二進位檔,並對每一個測試函式各跑一次:
cargo test
cargo test adds_two_numbers # 只跑名稱包含此字串的測試
cargo test -- --nocapture # 顯示被捕捉的輸出
cargo test -- --test-threads=1 # 單執行緒執行
--nocapture 很重要,因為測試框架預設會捕捉 println! 的輸出,只有在測試失敗時才一併印出。當你想觀察程式在測試中的中間狀態時,加上 --nocapture 就能直接看到。
測試的層級組織#
Rust 把測試分成三個層級,各自有不同的可見範圍與擺放位置。
| 層級 | 位置 | 可存取範圍 | 執行時機 |
|---|---|---|---|
| 單元測試 | 被測模組內的 #[cfg(test)] mod tests | 可存取私有項目 | 與 crate 一起編譯 |
| 整合測試 | tests/ 目錄下的每個 .rs 檔 | 只能使用公開 API | 每個檔案是獨立 crate |
| 文件測試 | 文件註解中的程式碼區塊 | 只能使用公開 API | cargo test 時一併執行 |
單元測試貼近被測程式碼,能測到私有函式,失敗時的錯誤訊息也能直接指出是內部哪一段邏輯出了問題。整合測試則站在使用者的角度,只透過公開 API 操作 crate,等於同時驗證了「介面設計是否足夠好用」這個問題。文件測試是 Rust 特有的第三層:文件註解裡的程式碼區塊會被抽出、編譯並執行,確保範例不會隨著時間演进而過期。
flowchart TD
A["cargo test"] --> B["編譯 lib crate"]
B --> C["單元測試:crate 內部的 #[cfg(test)] 模組"]
B --> D["編譯 tests/ 下的每個 .rs 成獨立 crate"]
D --> E["整合測試:只使用公開 API"]
B --> F["抽取文件註解中的程式碼區塊"]
F --> G["文件測試:以獨立 crate 編譯並執行"]
C --> H["彙整結果並回報通過與失敗"]
E --> H
G --> H斷言與訊息#
最基本的是 assert!,它接受一個布林值:
#[test]
fn positive_sum() {
let total = 3 + 5;
assert!(total > 0, "sum should be positive, got {total}");
}
assert_eq! 比較兩個值是否相等,assert_ne! 比較是否不等。兩者都會在失敗時印出兩邊的值,這對除錯非常關鍵。
#[test]
fn compares_values() {
let expected = vec![1, 2, 3];
let actual = vec![1, 2, 4];
assert_eq!(expected, actual);
assert_ne!(expected, actual);
}
所有斷言都支援自訂訊息,格式字串可以像 println! 一樣引用變數:
#[test]
fn length_check() {
let words = vec!["a", "b"];
assert_eq!(words.len(), 2, "expected 2 words but found {}", words.len());
}
assert_eq! 有一個容易被忽略的細節:它要求兩邊的型別相同,且要求 PartialEq 與 Debug。Debug 是必要的,因為失敗時要印出值。若某個型別不適合實作 Debug,可以改用 assert!(a == b),但這會失去差異輸出。
測試 panic 路徑#
對於預期會 panic 的路徑,#[should_panic] 可以把 panic 變成成功條件。
pub fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
panic!("division by zero");
}
a / b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic(expected = "division by zero")]
fn panics_on_zero_divisor() {
divide(1, 0);
}
}
expected 參數會比對 panic 訊息的子字串,這讓測試在訊息文字微調後仍然穩定。若沒有指定 expected,任何 panic 都算通過,測試的嚴謹度較低,建議盡量加上。
忽略、平行執行與篩選#
某些測試很慢或依賴外部資源,可以用 #[ignore] 標記,讓它不被預設執行,但仍可用 cargo test -- --ignored 單獨執行。
#[test]
#[ignore = "requires network access"]
fn fetches_remote_data() {
// 實際的網路操作
}
測試預設以多執行緒平行執行,每個測試在自己的執行緒中跑,彼此的執行緒本地的資料互不干擾,但全域狀態是共用的。若測試會修改全域變數或環境變數,就可能出現難以重現的間歇性失敗,此時可以用 --test-threads=1 強制循序執行來協助診斷。
執行緒本地的隔離可以用 std::thread::LocalKey 或第三方 crate,但更常見的做法是把共享狀態封裝進 Mutex,測試時用獨立的 guard 避免交錯。
文件測試#
文件測試是 Rust 獨有的機制,也是最容易被低估的一項優勢。文件註解中的程式碼區塊會被抽取成獨立的 crate 編譯並執行,所以文件中的範例永遠不會與實際行為脫節。
/// 將數字加倍。
///
/// # 範例
///
/// ```
/// use stacker::double;
///
/// assert_eq!(double(21), 42);
/// ```
pub fn double(x: i32) -> i32 {
x * 2
}
區塊開頭的空白會被剝除,程式碼會被自動包成 fn main() { ... }。若程式碼本身需要 main,可以加上 no_run 標記表示「只編譯不執行」,或用 ignore 完全跳過。文件測試只能使用 crate 的公開 API,因此它同時也是對公開介面的體驗測試。
對照其他語言#
Rust 的測試框架是內建的,這與 Python 的 unittest/pytest、JavaScript 的 Jest、Java 的 JUnit 形成對比。內建的好處是零相依、任何專案開箱即用,且工具鏈整合完整:cargo test 會同時建置、除錯並執行測試,不需要額外的設定檔。
代價是生態系的功能較分散。測試替身(mock)、參數化測試、快照測試這類需求,通常要靠 mockall、rstest、insta 等第三方 crate 補足。Rust 的哲學是提供核心機制並保持小,而把擴充交給社群。
屬性測試與模糊測試#
傳統的單元測試驗證的是你想到的案例,屬性測試(property-based testing)驗證的是「對所有輸入都成立的性質」。proptest 是這個領域最主流的 crate,它會自動產生大量隨機輸入,並在找到反例時自動縮減到最小失敗案例。
use proptest::prelude::*;
proptest! {
#[test]
fn double_is_always_even(x in 0i32..1000) {
prop_assert_eq!(double(x) * 2, (x * 2) * 2);
}
}
模糊測試(fuzzing)則更為激進。cargo-fuzz 會用 libFuzzer 持續對函式餵入隨機位元組,試圖找到會造成當機或記憶體錯誤的輸入,特別適合解析器與 unsafe 程式碼。
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
let _ = stacker::parse(data);
});
基準測試與覆蓋率#
效能宣稱需要證據。criterion 是基準測試的標準選擇,它會做統計分析並自動產出比較報告:
use criterion::{criterion_group, criterion_main, Criterion};
fn bench_add(c: &mut Criterion) {
c.bench_function("add", |b| b.iter(|| 2 + 3));
}
criterion_group!(benches, bench_add);
criterion_main!(benches);
覆蓋率則可以用 cargo llvm-cov 或 cargo tarpaulin 取得,它們會插樁編譯並統計每條程式路徑是否被執行過。覆蓋率數字本身不是目標,它的主要用途是找出完全沒有被測到的程式碼區塊,那些區塊往往正是缺陷的藏身處。
cargo install cargo-llvm-cov
cargo llvm-cov --all-features --html
測試設計原則#
好的測試應該驗證行為而非實作。測試私有函式的回傳值是合理的,但測試「函式呼叫了某個輔助函式」這種內部細節,只會讓重構變得困難。每一個斷言都應該有清楚的失敗訊息,讓讀取 CI 日誌的人不必回頭看程式碼就知道出了什麼事。
另一個原則是每個測試只測一件事。若一個測試裡有多個斷言,前一個失敗時你不知道後面的狀態,因此應該拆開。這樣失敗報告會精準指向問題,而通過的測試也能明確宣告「這個行為是對的」。
一個完整範例#
底下是一個同時包含單元測試與整合測試的小型 crate。
// src/lib.rs
pub struct Stack {
items: Vec<i32>,
}
impl Stack {
pub fn new() -> Self {
Stack { items: Vec::new() }
}
pub fn push(&mut self, value: i32) {
self.items.push(value);
}
pub fn pop(&mut self) -> Option<i32> {
self.items.pop()
}
pub fn len(&self) -> usize {
self.items.len()
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_stack_is_empty() {
let s = Stack::new();
assert!(s.is_empty());
assert_eq!(s.len(), 0);
}
#[test]
fn push_and_pop() {
let mut s = Stack::new();
s.push(1);
s.push(2);
assert_eq!(s.len(), 2);
assert_eq!(s.pop(), Some(2));
assert_eq!(s.pop(), Some(1));
assert_eq!(s.pop(), None);
}
}
// tests/integration.rs
use stacker::Stack;
#[test]
fn stack_behaves_like_a_lifo() {
let mut s = Stack::new();
for i in 1..=5 {
s.push(i);
}
let popped: Vec<i32> = std::iter::from_fn(|| s.pop()).collect();
assert_eq!(popped, vec![5, 4, 3, 2, 1]);
assert!(s.is_empty());
}
整合測試放在 tests/ 目錄下會成為獨立 crate,只能透過 use stacker::Stack 使用公開 API。若某個功能只能在整合測試中透過 #[cfg(test)] 才測得到,通常表示公開介面的設計還有可以改進的地方。