返回文章列表

Actix-web API 實戰:設計與測試單一資源端點

本文為一份以測試驅動開發 (TDD) 為核心的 Actix-web 教學,指導如何設計、實現並測試一個獲取單一資源的 API 端點。我們將使用記憶體中的應用程式狀態作為資料來源,專注於 handler 的編寫與單元測試技巧。

Web 開發 Rust

在建構 RESTful API 時,獲取單一資源的詳細資訊是一個最基本也最常見的需求。本文將以「測試驅動開發」(TDD) 的思想為指導,帶您從零開始,為一個課程平台設計、實現並徹底測試 GET /courses/{tutor_id}/{course_id} 這個 API 端點。我們將使用記憶體中的應用程式狀態來模擬資料庫,讓您能專注於 Actix-web 的核心開發與測試技巧。

步驟一:定義 API 需求與資料模型

我們的目標是建立一個 API,它能根據傳入的 tutor_idcourse_id,從共享的應用程式狀態中查找並回傳對應的課程資料。

首先,我們需要 CourseAppState 的定義:

// 在 src/models.rs
use serde::Serialize;
#[derive(Serialize, Debug, Clone)]
pub struct Course {
    pub tutor_id: i32,
    pub course_id: Option<i32>,
    // ... 其他欄位
}

// 在 src/state.rs
use std::sync::Mutex;
pub struct AppState {
    pub courses: Mutex<Vec<Course>>,
    // ... 其他狀態
}

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

在 TDD 中,我們在編寫 handler 之前先定義它的預期行為。我們需要考慮兩種主要情境:成功找到課程,以及找不到課程。

src/handlers.rs 的測試模組中,我們編寫以下測試:

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::{http::StatusCode, web, App};
    use chrono::Utc;
    use std::sync::Mutex;

    // 測試成功找到課程的案例
    #[actix_rt::test]
    async fn get_course_detail_success_test() {
        // 1. 準備: 建立一個包含目標課程的 AppState
        let course = Course { tutor_id: 1, course_id: Some(1), course_name: "Test Course".into(), posted_time: Some(Utc::now().naive_utc()) };
        let app_state = web::Data::new(AppState {
            courses: Mutex::new(vec![course]),
            // ... 其他狀態
        });
        // 準備 handler 需要的路徑參數
        let params: web::Path<(i32, i32)> = web::Path::from((1, 1));

        // 2. 執行: 直接呼叫 handler 函式
        let resp = get_course_detail(app_state, params).await;

        // 3. 斷言: 驗證回應狀態碼是否為 200 OK
        assert_eq!(resp.status(), StatusCode::OK);
    }

    // 測試找不到課程的案例
    #[actix_rt::test]
    async fn get_course_detail_failure_test() {
        // 1. 準備: 建立一個空的 AppState
        let app_state = web::Data::new(AppState {
            courses: Mutex::new(vec![]),
            // ... 其他狀態
        });
        // 準備一個不存在的課程 ID
        let params: web::Path<(i32, i32)> = web::Path::from((1, 999));

        // 2. 執行
        let resp = get_course_detail(app_state, params).await;

        // 3. 斷言: 驗證回應狀態碼是否為 404 NOT FOUND
        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
    }
}

此時執行 cargo test,測試會因找不到 get_course_detail 函式而失敗。這是 TDD 的「紅燈」階段,驅使我們去創建這個函式。

步驟三:實現 API Handler

現在,我們來編寫 get_course_detail handler,目標是讓剛剛的測試通過。

// 在 src/handlers.rs
pub async fn get_course_detail(
    app_state: web::Data<AppState>,
    params: web::Path<(i32, i32)>,
) -> HttpResponse {
    let (tutor_id, course_id) = params.into_inner();
    
    let selected_course = app_state
        .courses
        .lock()
        .unwrap()
        .clone()
        .into_iter()
        .find(|x| x.tutor_id == tutor_id && x.course_id == Some(course_id));

    // 根據查詢結果回傳不同的 HttpResponse
    if let Some(course) = selected_course {
        HttpResponse::Ok().json(course)
    } else {
        HttpResponse::NotFound().json("Course not found")
    }
}

程式碼解說

  • params.into_inner(): 從 web::Path 提取元組 (i32, i32)
  • .find(...): 在從 Mutex 中安全地複製出來的課程列表上,尋找符合 tutor_idcourse_id 的課程。
  • if let Some(course) ...: 這種寫法清晰地處理了「找到」與「沒找到」兩種情況,並分別回傳 200 OK 或 404 Not Found 的回應。

再次執行 cargo test,所有測試現在都應該通過了,我們達成了 TDD 的「綠燈」階段。

圖表解說:單元測試工作流程

此循序圖詳細展示了單元測試函式如何模擬 Actix-web 的環境,直接調用並驗證 handler 的邏輯。

@startuml
!theme _none_
skinparam dpi auto
skinparam defaultFontName "Microsoft JhengHei UI"
skinparam minClassWidth 100
skinparam defaultFontSize 16
title Handler 單元測試流程

participant "Test Function" as Test
participant "web::Data" as WebData
participant "web::Path" as WebPath
participant "Handler" as Handler
participant "HttpResponse" as Response

Test -> WebData : new(AppState)
note right: 準備共享狀態

Test -> WebPath : from((1, 1))
note right: 準備路徑參數

Test -> Handler : 呼叫 get_course_detail(app_state, params)
Handler -> Handler : 執行業務邏輯\n(lock, find, etc.)
Handler -> Response : 建立 HttpResponse (Ok or NotFound)
Handler --> Test : 回傳 HttpResponse

Test -> Response : .status()
Response --> Test : 回傳 StatusCode
Test -> Test : assert_eq!(status, StatusCode::OK)
note right: 驗證結果

end note

end note

end note

@enduml

步驟四:註冊路由並執行

最後,在 main.rs 中將我們的 handler 註冊到對應的路由上。

// 在 src/routes.rs
pub fn course_routes(cfg: &mut web::ServiceConfig) {
    cfg.service(
        web::scope("/courses")
            // ... 其他課程路由
            .route("/{tutor_id}/{course_id}", web::get().to(get_course_detail)),
    );
}

現在,當您啟動伺服器並使用 curl -X GET localhost:3000/courses/1/1 這樣的指令時,get_course_detail handler 就會執行,並根據其內部邏輯回傳課程的 JSON 資料或 404 錯誤。透過 TDD 的實踐,我們在開發過程中就已經確保了這段邏輯的正確性與穩健性。