本文 AI 產出,尚未審核

Rust 整合測試(Integration Tests)

簡介

在 Rust 專案中,單元測試 主要聚焦於單一模組或函式的正確性,而 整合測試(Integration Tests)則負責驗證 多個模組之間的協作,以及整個程式的外部介面(例如 CLI、Web API、資料庫存取)是否如預期運作。

對於任何規模超過幾個檔案的專案,僅靠單元測試往往不足以捕捉跨模組的錯誤或環境依賴的問題。整合測試能在 真實執行環境 中跑出完整的二進位檔,模擬使用者的實際操作流程,從而大幅提升程式的可靠度與可維護性。

本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,帶領讀者一步步掌握 Rust 整合測試的核心技巧,讓你在日常開發中能快速建立、執行與除錯。


核心概念

1. 整合測試的檔案結構

Rust 為整合測試提供了專屬的目錄 tests/。Cargo 會自動把該目錄下的每個 .rs 檔案視為 獨立的測試二進位,編譯時會把整個 crate(包含 src/lib.rssrc/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_alltempfile 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 Composetestcontainers crate 自動佈署測試依賴;本地開發可寫入 setup.sh 輔助腳本。
測試執行時間過長 非同步或 I/O 密集測試未妥善限制,導致測試套件慢到不可接受。 使用 tokio::time::timeout 包裝長時間操作;在 CI 中設定 測試超時cargo test -- --test-threads=1)。
斷言訊息不足 測試失敗時只能看到「assertion failed」的訊息,難以定位。 assert_eq!assert! 加上 自訂錯誤訊息,或使用 pretty_assertions 讓 diff 更易讀。
測試二進位過大 整合測試會編譯整個 crate,若依賴過多會導致編譯時間暴增。 只在 必要時 使用整合測試;對於純函式邏輯,仍以單元測試為主。

最佳實踐

  1. 保持測試獨立:每個測試檔案都應該能在乾淨的環境下執行。
  2. 使用 helper 模組:把重複的設定、清理程式抽成共用函式,放在 tests/common/
  3. CI 整合:在 GitHub Actions、GitLab CI 等平台上加入 cargo test --all --tests,確保每次合併前都跑過整合測試。
  4. 測試資料外部化:將較大的測試資料(JSON、CSV)放在 tests/fixtures/,使用 include_str!std::fs::read_to_string 讀取,避免程式碼過長。
  5. 適度使用 #[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 專案中建立一套可靠且易於維護的測試基礎,讓程式在面對未來的變更與擴充時,依舊保持穩定。祝開發順利,測試無憂!