Rust 整合測試(Integration Tests)
簡介
在 Rust 專案中,單元測試 主要聚焦於單一模組或函式的正確性,而 整合測試(Integration Tests)則負責驗證 多個模組之間的協作,以及整個程式的外部介面(例如 CLI、Web API、資料庫存取)是否如預期運作。
對於任何規模超過幾個檔案的專案,僅靠單元測試往往不足以捕捉跨模組的錯誤或環境依賴的問題。整合測試能在 真實執行環境 中跑出完整的二進位檔,模擬使用者的實際操作流程,從而大幅提升程式的可靠度與可維護性。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶領讀者一步步掌握 Rust 整合測試的核心技巧,讓你在日常開發中能快速建立、執行與除錯。
核心概念
1. 整合測試的檔案結構
Rust 為整合測試提供了專屬的目錄 tests/。Cargo 會自動把該目錄下的每個 .rs 檔案視為 獨立的測試二進位,編譯時會把整個 crate(包含 src/lib.rs 或 src/main.rs)作為依賴引入。
my_project/
├─ Cargo.toml
├─ src/
│ ├─ lib.rs
│ └─ main.rs
└─ tests/
├─ api_test.rs
└─ cli_test.rs
重點:
tests/內的測試 不能直接存取私有(private)項目,必須透過公開的 API 介面來互動,這正是模擬真實使用情境的關鍵。
2. #[tokio::test] 與非同步測試
現代的 Rust 應用常常需要非同步 I/O(例如 HTTP 請求、資料庫操作)。在整合測試中,我們可以使用 #[tokio::test](或其他 async 執行時)將測試函式標記為非同步,讓測試程式碼保持簡潔且不需要手動建立執行時。
#[tokio::test]
async fn fetch_user_profile() {
// 透過 HTTP 客戶端呼叫本地伺服器的 API
let resp = reqwest::get("http://127.0.0.1:8080/api/user/1")
.await
.expect("request failed");
assert_eq!(resp.status(), 200);
}
3. 測試前後的環境設定
整合測試往往需要 建立測試專用的環境(例如暫存資料庫、臨時檔案目錄)。Rust 提供了 std::fs::create_dir_all、tempfile crate 等工具,配合 Drop 實作或 #[ctor]/#[dtor](第三方 crate)可在測試開始前初始化、結束後清理。
use tempfile::TempDir;
/// 建立一個臨時資料庫目錄,測試結束時自動刪除
fn setup_test_db() -> TempDir {
TempDir::new().expect("cannot create temp dir")
}
4. 測試資料的共用
若多個測試需要相同的測試資料,可以把資料產生邏輯抽成 helper 函式,放在 tests/common/mod.rs,然後在各測試檔案中 mod common; 引入。這樣既避免重複程式,又保持測試的獨立性。
// tests/common/mod.rs
pub fn init_logger() {
let _ = env_logger::builder().is_test(true).try_init();
}
5. 斷言與錯誤訊息
使用 assert_eq!, assert! 等宏時,提供自訂錯誤訊息 能讓測試失敗時快速定位問題。例如:
assert_eq!(result, expected, "計算結果不符,輸入: {}, 預期: {}", input, expected);
程式碼範例
以下示範 5 個常見的整合測試情境,涵蓋同步、非同步、環境設定與錯誤處理。
範例 1:測試公開函式的行為
// tests/math_integration_test.rs
use my_project::math::add;
/// 測試 `add` 函式在不同輸入下的正確性
#[test]
fn test_add_basic() {
assert_eq!(add(2, 3), 5);
assert_eq!(add(-1, 1), 0);
}
說明:即使
add只是一個簡單的函式,放在tests/中仍可驗證它在 crate 整體編譯 後的行為,確保未來的重構不會破壞公開 API。
範例 2:非同步 HTTP API 測試
// tests/api_integration_test.rs
use reqwest::StatusCode;
#[tokio::test]
async fn test_get_user_endpoint() {
// 假設測試環境已啟動本地伺服器
let url = "http://127.0.0.1:8080/api/user/42";
let resp = reqwest::get(url).await.expect("failed to send request");
assert_eq!(resp.status(), StatusCode::OK);
let json: serde_json::Value = resp.json().await.expect("invalid json");
assert_eq!(json["id"], 42);
assert!(json["name"].as_str().is_some());
}
重點:使用
#[tokio::test]讓測試自動建立 Tokio 執行時,無需手動Runtime::new()。
範例 3:資料庫整合測試(使用 SQLite 記憶體)
// tests/db_integration_test.rs
use my_project::db::{establish_connection, create_user, find_user_by_id};
#[test]
fn test_user_crud() {
// 建立一個記憶體中的 SQLite 連線
let conn = establish_connection(":memory:").expect("cannot connect to DB");
// 初始化資料表
my_project::db::migrate(&conn).expect("migration failed");
// 建立使用者
let user = create_user(&conn, "alice", "alice@example.com")
.expect("insert failed");
assert_eq!(user.name, "alice");
// 讀取使用者
let fetched = find_user_by_id(&conn, user.id).expect("select failed");
assert_eq!(fetched.email, "alice@example.com");
}
說明:使用 SQLite 記憶體資料庫可以在測試結束時自動釋放,避免對實體資料庫造成污染。
範例 4:CLI 程式測試(使用 assert_cmd)
// tests/cli_test.rs
use assert_cmd::Command;
use predicates::str::contains;
#[test]
fn test_cli_help_output() {
let mut cmd = Command::cargo_bin("my_cli").expect("binary not found");
cmd.arg("--help")
.assert()
.success()
.stdout(contains("Usage:"))
.stderr(contains(""));
}
技巧:
assert_cmd讓我們可以像使用終端機一樣呼叫編譯好的二進位,檢查標準輸出與錯誤訊息。
範例 5:使用 tempfile 建立臨時檔案系統測試
// tests/filesystem_test.rs
use std::fs;
use tempfile::tempdir;
#[test]
fn test_write_and_read_file() {
// 建立臨時目錄
let dir = tempdir().expect("cannot create temp dir");
let file_path = dir.path().join("data.txt");
// 寫入資料
fs::write(&file_path, b"hello world").expect("write failed");
// 讀取並驗證
let content = fs::read_to_string(&file_path).expect("read failed");
assert_eq!(content, "hello world");
// `dir` 離開作用域時自動刪除
}
重點:使用
tempfile可避免測試對實際檔案系統造成永久變更,保持測試的 可重入性。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
| 測試相依於全域狀態 | 測試間共享同一個資料庫或檔案,導致測試順序影響結果。 | 為每個測試建立 獨立的環境(如 SQLite 記憶體、tempfile),或使用 serial_test crate 讓測試串行執行。 |
| 忘記啟動外部服務 | 整合測試需要的 HTTP 伺服器、Redis 等未啟動,測試直接失敗。 | 在 CI 中使用 Docker Compose 或 testcontainers crate 自動佈署測試依賴;本地開發可寫入 setup.sh 輔助腳本。 |
| 測試執行時間過長 | 非同步或 I/O 密集測試未妥善限制,導致測試套件慢到不可接受。 | 使用 tokio::time::timeout 包裝長時間操作;在 CI 中設定 測試超時(cargo test -- --test-threads=1)。 |
| 斷言訊息不足 | 測試失敗時只能看到「assertion failed」的訊息,難以定位。 | 為 assert_eq!、assert! 加上 自訂錯誤訊息,或使用 pretty_assertions 讓 diff 更易讀。 |
| 測試二進位過大 | 整合測試會編譯整個 crate,若依賴過多會導致編譯時間暴增。 | 只在 必要時 使用整合測試;對於純函式邏輯,仍以單元測試為主。 |
最佳實踐
- 保持測試獨立:每個測試檔案都應該能在乾淨的環境下執行。
- 使用 helper 模組:把重複的設定、清理程式抽成共用函式,放在
tests/common/。 - CI 整合:在 GitHub Actions、GitLab CI 等平台上加入
cargo test --all --tests,確保每次合併前都跑過整合測試。 - 測試資料外部化:將較大的測試資料(JSON、CSV)放在
tests/fixtures/,使用include_str!或std::fs::read_to_string讀取,避免程式碼過長。 - 適度使用
#[ignore]:對於耗時或需要外部服務的測試,可加上#[ignore],在 CI 中顯式呼叫cargo test -- --ignored。
實際應用場景
| 場景 | 為何需要整合測試 | 範例 |
|---|---|---|
| Web 服務 | 確認路由、驗證、資料庫交易在真實 HTTP 請求下正確運作。 | 使用 reqwest 呼叫本地伺服器的 /login,驗證 JWT 產生與錯誤回傳。 |
| CLI 工具 | 測試指令列參數解析、檔案輸入/輸出、錯誤訊息。 | assert_cmd 驗證 my_tool --version 會輸出正確的版本號。 |
| 嵌入式或 WASM | 確認跨平台編譯後的二進位仍能與外部硬體或瀏覽器 API 正確互動。 | 在 CI 中使用 wasm-pack test --headless 搭配 wasm-bindgen-test。 |
| 微服務串接 | 多服務間的協議(gRPC、Kafka)需要端到端驗證。 | 使用 testcontainers 啟動一個臨時 Kafka,測試生產者與消費者的訊息流程。 |
| 資料處理管線 | 大型 CSV/JSON 轉換流程必須保證每一步的輸出符合規範。 | 在 tests/fixtures/ 放入樣本檔案,跑完整的 ETL 流程並比對最終結果。 |
總結
整合測試是 保證 Rust 應用在真實環境中可靠運作 的最後一道防線。透過 tests/ 目錄的獨立二進位、非同步測試支援、以及靈活的環境設定,我們可以在不影響主程式碼的前提下,完整驗證跨模組、跨系統的行為。
- 結構化測試檔案:把測試放在
tests/,保持與單元測試的分離。 - 使用非同步測試:
#[tokio::test]讓 I/O 密集測試更簡潔。 - 妥善管理測試環境:
tempfile、Docker、testcontainers為常見解決方案。 - 避免測試相依:每個測試都應自行建立與清理資源。
- 在 CI 中執行:確保每次提交都有完整的整合測試覆蓋。
只要遵循上述最佳實踐,從 簡單的函式驗證 到 完整的端到端流程測試,你就能在 Rust 專案中建立一套可靠且易於維護的測試基礎,讓程式在面對未來的變更與擴充時,依舊保持穩定。祝開發順利,測試無憂!