iT邦幫忙

2023 iThome 鐵人賽

DAY 10
0
Software Development

Rust Web API 從零開始系列 第 10

Day10 - 單元測試與Domain Module

  • 分享至 

  • xImage
  •  

既然可以從extractor取得request中的訂閱訊息了,那接下來是不是只要持久化到資料庫就好了呢?事實上不盡然,以目前的extractor為例:

#[derive(Deserialize, Debug, ToSchema)]
pub struct NewSubscriber {
    /// Subscriber Email
    pub email: Option<String>,
    /// Subscriber Name
    pub name: Option<String>,
}

實際上,我們無法只透過string來保證傳入的參數符合email的規則,因此我們在使用前通常要做一定的檢查:

pub async fn subscribe(Form(data): Form<NewSubscriber>) -> StatusCode {
    data.email
        //// 如果此欄位為Some則驗證內容
        .map(|email| valid_email(email))
        .map(|valid_result| {
            //// 驗證成功回傳OK
            if valid_result {
                StatusCode::OK
            } else {
                StatusCode::BAD_REQUEST
            }
        })
        //// 如果欄位為None則直接回傳Bad Request
        .unwrap_or_else(|| StatusCode::BAD_REQUEST)
}

在簡單的應用程式中這麼做是可以接受的,但當系統變得龐大時,這樣的作法容易造成相同的檢查散落在應用程式的各個地方,尤其當這些驗證規則變化,就要花費大量的時間盤點,然後盤點總是會漏的...。這種作法也會降低系統模組化的可行性,作為一個可復用的模組,我們需要假設使用方傳遞了錯誤的資料,所以不僅僅是處裡函數的入口,在每個方法的入口其實都要各自驗證。那麼有沒有辦法保證系統中傳遞的資料都是有效的呢?或許我麼可以把腦筋動到資料生成上,透過一些手段確保只有正確的資料會被產生出來,事實上解決這個問題就是在解決一個常見的程式碼壞味道-基本型別偏執

Domain Module

首先我們先新增一個資料夾作為domain模組並且加上mod.rs,先加入一個subscriber_email.rs:

//// subscriber_email.rs
#[derive(Debug, Clone)]
pub struct SubscriberEmail(String);

這邊使用SubscriberEmail這個型別來表示訂閱者的email,接下來要確保資料生成的正確性,我們幫它實做一個parse的工廠方法,作用有點像是建構式,並將驗證做在裡面:

//// subscriber_email.rs
//// 請先安裝validator套件簡化email驗證
use validator::validate_email;

#[derive(Debug, Clone)]
pub struct SubscriberEmail(String);

impl SubscriberEmail {
    pub fn parse(email: String) -> Result<Self, String> {
        if validate_email(&email) {
            Ok(Self(email))
        } else {
            Err(format!("{} is not a valid subscriber email.", email))
        }
    }
}

只要透過parse產生的SubscriberEmail,就可以保證符合email的規格。

單元測試

接下來我們為SubscriberEmail撰寫單元測試,確保parse方法的正確性。rust中的單元測試很特別,官方建議把單元測試與被測的方法寫在同一個檔案中,因為這樣還可以測試模組內的私有方法,有些人可能覺的私有方法不須測試,但rust把這個選擇權交給開發者自己決定。在開始之前先安裝開發的依賴,以這個方式安裝的套件在release的時候不會被編譯:

cargo add --dev claims

claims是一個協助撰寫測試的套件,然後我們就可以開始寫測試了:

//// 這是一個測試模組
#[cfg(test)]
mod tests {
    use claims::assert_err;

    //// 也可以用super的方式找到上一層模組的內容
    use crate::domain::SubscriberEmail;
    
    //// 在測試方法上標注
    #[test]
    fn empty_string_is_rejected() {
        let email = " ".to_string();
        assert_err!(SubscriberEmail::parse(email));
    }

    #[test]
    fn email_missing_at_symbol_is_rejected() {
        let email = "sefsefewe.com".to_string();
        assert_err!(SubscriberEmail::parse(email));
    }

    #[test]
    fn email_missing_subject_is_rejected() {
        let email = "@domain.com".to_string();
        assert_err!(SubscriberEmail::parse(email));
    }
}

接下來只要運行cargo test就可以看到測試結果了。把SubscriberName也建立好後我們就可以組出訂閱者的結構了

pub struct Subscriber {
    pub email: SubscriberEmail,
    pub name: SubscriberName,
}

完整的Subscriber可以參考這裡

在Handler中使用Subscriber

//// subscription.rs
pub async fn subscribe(Form(data): Form<NewSubscriber>) -> StatusCode {
    //// 這邊可以熟悉一下rust中使用鏈式處裡錯誤的方法
    Subscriber::try_from(data)
        .map(|_| StatusCode::OK)
        //// 如果拆箱失敗直接回傳Bad Request
        .unwrap_or_else(|| StatusCode::BAD_REQUEST)
}

impl TryFrom<NewSubscriber> for Subscriber {
    type Error = String;

    fn try_from(value: NewSubscriber) -> Result<Self, String> {
        let name = SubscriberName::parse(value.name.unwrap_or_default())?;
        let email = SubscriberEmail::parse(value.email.unwrap_or_default())?;
        Ok(Subscriber { email, name })
    }
}

在這邊try_from是rust中用來轉型的trait,稍微注意一下parse失敗的話會回傳Error("錯誤訊息"),再透過?語法糖將錯誤往上傳遞,這就是rust的錯誤處裡方式,將錯誤以返回值傳遞,由上層調用邏輯決定處裡方式。

小結

我覺的今天的內容,除了rust的使用方法以外,更重要的是透過解決基本型別偏執來達到系統上的穩定!


上一篇
Day09 - 應用程式模組
下一篇
Day11 - 使用SeaORM進行持久化(1)
系列文
Rust Web API 從零開始30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言