在現代後端開發中,確保 API 的穩定性與正確性至關重要。測試驅動開發 (Test-Driven Development, TDD) 是一種強調「測試先行」的開發模式,它能有效提升程式碼品質並降低維護成本。本文將以 Rust 的 Actix-web 框架與 SQLx 資料庫工具為例,引導您實踐 TDD 流程,從零開始建構並測試一個課程管理的 RESTful API。

步驟一:定義需求與 API 端點

我們的目標是為一個線上課程平台建立後端 API,至少需要包含以下幾個核心功能:

  • GET /tutors/{tutor_id}/courses: 取得某位導師的所有課程。
  • GET /tutors/{tutor_id}/courses/{course_id}: 取得單一課程的詳細資訊。
  • POST /tutors/{tutor_id}/courses: 為某位導師新增一門課程。
  • DELETE /tutors/{tutor_id}/courses/{course_id}: 刪除一門課程。
  • PUT /tutors/{tutor_id}/courses/{course_id}: 更新一門課程的資訊。

步驟二:編寫單元測試 (測試先行)

在 TDD 流程中,我們在編寫任何具體實現之前,先為每個 API 端點編寫測試案例。這有助於我們清晰地定義每個 handler 的預期行為,包括成功和失敗的情境。

1. 測試環境初始化

在測試模組中,我們需要一個輔助函式來設定測試環境,包括載入環境變數和建立資料庫連線池。

// 在 tests/course_tests.rs
use actix_web::{web, http::StatusCode};
use dotenv::dotenv;
use sqlx::postgres::PgPool;
use std::sync::Mutex;
// ... 引入 handler 和 state ...

async fn setup_test_environment() -> web::Data<AppState> {
    dotenv().ok();
    let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL is not set");
    let pool = PgPool::connect(&database_url).await.unwrap();
    web::Data::new(AppState {
        health_check_response: "".to_string(),
        visit_count: Mutex::new(0),
        db: pool,
    })
}

2. 編寫測試案例

我們為「取得單一課程詳情」的功能編寫兩個測試案例:一個測試成功路徑,另一個測試當課程不存在時的失敗路徑。

#[actix_rt::test]
async fn get_course_detail_success_test() {
    let app_state = setup_test_environment().await;
    let params: web::Path<(i32, i32)> = web::Path::from((1, 1)); // 假設 tutor 1, course 1 存在
    let resp = get_course_details(app_state, params).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[actix_rt::test]
async fn get_course_detail_failure_test() {
    let app_state = setup_test_environment().await;
    let params: web::Path<(i32, i32)> = web::Path::from((1, 999)); // 假設 course 999 不存在
    let resp = get_course_details(app_state, params).await;
    // 預期會回傳一個錯誤
    assert!(resp.is_err());
    // 並且錯誤的狀態碼應為 NOT_FOUND
    if let Err(err) = resp {
        assert_eq!(err.status_code(), StatusCode::NOT_FOUND);
    }
}

此時執行 cargo test,這些測試理應會因為找不到 get_course_details 函式而編譯失敗。這正是 TDD 的第一步:紅燈 (Red)。

步驟三:實現資料庫存取層

為了讓測試通過,我們需要開始編寫實際的程式碼。首先是與資料庫互動的邏輯。

// 在 src/dbaccess/course.rs
use crate::errors::EzyTutorError;
use crate::models::course::Course;
use sqlx::postgres::PgPool;

pub async fn get_course_details_db(
    pool: &PgPool,
    tutor_id: i32,
    course_id: i32,
) -> Result<Course, EzyTutorError> {
    let course_row = sqlx::query_as!(
        Course,
        "SELECT * FROM ezy_course_c6 WHERE tutor_id = $1 AND course_id = $2",
        tutor_id,
        course_id
    )
    .fetch_optional(pool) // fetch_optional 會回傳 Option<Course>
    .await?;

    if let Some(course) = course_row {
        Ok(course)
    } else {
        // 當查詢結果為 None 時,回傳自定義的 NotFound 錯誤
        Err(EzyTutorError::NotFound("Course not found".into()))
    }
}

程式碼解說

  • 我們使用 sqlx::query_as! 巨集來執行 SQL 查詢,並將結果自動對應到 Course 結構體。
  • fetch_optional 適合用來查詢可能存在也可能不存在的單一紀錄。
  • fetch_optional 回傳 Ok(None) 時,我們將其轉換為自定義的 EzyTutorError::NotFound 錯誤,這對於在 handler 層產生正確的 HTTP 404 回應至關重要。

步驟四:實現 API Handlers

最後,我們來實現 API handler,它作為橋樑,連接 HTTP 請求與資料庫邏輯。

// 在 src/handlers/course.rs
use crate::dbaccess::course::get_course_details_db;
use crate::errors::EzyTutorError;
use crate::state::AppState;
use actix_web::{web, HttpResponse};

pub async fn get_course_details(
    app_state: web::Data<AppState>,
    path: web::Path<(i32, i32)>,
) -> Result<HttpResponse, EzyTutorError> {
    let (tutor_id, course_id) = path.into_inner();
    
    // 調用資料庫存取函式
    get_course_details_db(&app_state.db, tutor_id, course_id)
        .await
        .map(|course| HttpResponse::Ok().json(course))
}

程式碼解說

  • handler 的職責非常單純:解析傳入的參數 (app_statepath),調用對應的資料庫存取函式,然後將結果 (Ok(course)) 或錯誤 (Err(EzyTutorError)) 映射為一個 HttpResponse
  • .map(|course| ...) 的寫法極為優雅,它只在 get_course_details_db 成功回傳 Ok(course) 時執行,將 course 物件序列化為 JSON 並放入 HTTP 200 OK 回應中。如果 get_course_details_db 回傳 Err,這個 Err 會被 ? 運算子直接傳播出去,由 Actix-web 的錯誤處理機制接管。

現在再次執行 cargo test,之前編寫的兩個測試案例應該都會順利通過。這就是 TDD 的「綠燈」(Green) 階段。接下來的「重構」(Refactor) 階段,則是在不改變測試結果的前提下,優化程式碼的結構與品質。

圖表解說:TDD 流程中的請求與測試

此循序圖展示了一個 HTTP 請求的完整處理流程,並標示出單元測試是如何在 Handler 層級進行模擬與驗證的。

@startuml
!theme _none_
skinparam dpi auto
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam minClassWidth 100
skinparam defaultFontSize 16
title TDD 流程中的請求處理與單元測試

participant "Client / Test" as Client
participant "Actix-web" as Actix
participant "Handler" as Handler
participant "DB Access" as DBAccess
participant "SQLx / DB" as DB

box "單元測試範圍" #LightBlue
  Client -> Handler : 模擬請求 (web::Path, web::Data)
  Handler -> DBAccess : 呼叫 DB 函式
  DBAccess -> DB : 執行 SQL 查詢
  DB --> DBAccess : 回傳 Result<Course> 或 Error
  DBAccess --> Handler : 回傳 Result<Course, EzyTutorError>
  Handler --> Client : 回傳 Result<HttpResponse, EzyTutorError>
  Client -> Client : 驗證回應 (assert_eq!)
end box

note right of Client
  在真實請求中,
  Client 是瀏覽器或 curl,
  請求會先經過 Actix-web 路由。
end note
@enduml

透過遵循 TDD 的「紅-綠-重構」循環,我們不僅能確保每個 API 端點都符合預期,還能自然地驅動出一個層次分明、高內聚、低耦合且易於測試的後端應用架構。