iT邦幫忙

2024 iThome 鐵人賽

DAY 23
0
自我挑戰組

從 Python 開發者的角度學習 Rust —— 從語法基礎到實戰應用系列 第 23

[Day 23] Rust 的測試框架:單元測試 & 集成測試

  • 分享至 

  • xImage
  •  

在軟體開發中,測試是確保程式碼質量與穩定性的關鍵步驟。今天要來介紹 Rust 提供的內建測試框架,讓我們能輕鬆撰寫單元測試和集成測試,確認程式是否如預期運作,我們會探討以下幾個主題:

  1. 單元測試的基本結構
  2. 集成測試的撰寫方式
  3. 錯誤處理與 panic! 測試
  4. 測試中的最佳實踐

一、什麼是 Rust 測試框架?

Rust 的測試框架內建於標準庫,不需要額外安裝套件。只要寫一個函數並加上 #[test] 標記,Rust 便會自動將它識別為測試,然後在執行時確認它是否通過。

測試的概念就像你在開發時建立的一個小考試,你會輸入一些資料,然後看這些資料是否能通過預期的檢查。這樣可以在開發過程中防止錯誤的出現。


二、單元測試:檢查小功能是否正常

單元測試的主要目的是針對程式中的個別小功能進行測試,確保其輸入和輸出符合預期。這通常用於檢查單個函數或模組的邏輯,從而驗證小範圍的功能是否正常運作。單元測試非常重要,因為它能讓我們提早發現程式中的邏輯錯誤,避免在後續的開發過程中引發更大的問題。

如何撰寫一個簡單的單元測試

假設我們有一個 add 函數,該函數的作用是將兩個整數相加。我們希望透過撰寫測試來確認這個函數的結果是否正確。

// src/lib.rs

pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        let result = add(2, 3);
        assert_eq!(result, 5); // 檢查 add(2, 3) 的結果是否為 5
    }
}

讓我們逐步分解這段程式碼,看看每一部分的具體功能。

pub fn add(a: i32, b: i32) -> i32

這是我們要測試的 add 函數。它接受兩個整數作為參數,並返回它們的和。這個函數本身非常簡單,但我們需要透過測試來確保它的邏輯正確,尤其是在多種輸入情況下。

#[cfg(test)]

這段程式碼是一個條件編譯指示。它告訴編譯器,只有在進行測試時才會編譯 tests 模組內的測試程式碼,這樣在生產環境中不會包含這些測試相關的程式碼。

mod tests

這是測試模組的定義。我們將測試函數放在這個模組中,並透過 use super::*; 引入我們要測試的 add 函數。這樣我們可以在測試中直接使用 add 函數,而不需要額外的模組路徑。

#[test]

#[test] 是 Rust 測試框架的核心屬性,它標記了這個函數為一個測試函數。當我們執行 cargo test 命令時,Rust 會自動找到所有帶有 #[test] 屬性的函數並執行它們。這些測試函數應該包含斷言來檢查程式的行為是否符合預期。

測試函數 fn test_add()

這是實際進行測試的函數。它首先執行我們要測試的 add 函數,並將結果儲存在 result 變數中。接著,它使用 assert_eq! 斷言巨集來檢查 result 是否等於預期值 5。

  • assert_eq!:這是一個斷言巨集,用於檢查兩個值是否相等。如果值不相等,這個測試會失敗,並顯示錯誤訊息。這種斷言有助於確保程式的行為符合預期,並在問題發生時快速找到錯誤原因。

在這個例子中,我們輸入了數字 23,期望結果是 5。如果 add 函數正確實現了相加功能,測試會通過。如果結果不是 5,測試會失敗並顯示對應的錯誤訊息,讓我們可以迅速定位問題。


三、如何運行測試?

Rust 的測試框架內建在 Cargo 中,這意味著你不需要安裝任何額外的工具來運行測試。每次你執行測試時,Cargo 會自動編譯專案、運行測試函數,並為你顯示測試結果。

1. 執行測試

要運行測試,只需打開終端機並執行以下命令:

cargo test

這個命令會做幾件事情:

  1. 編譯專案:Rust 會先編譯你的專案,包括所有帶有 #[cfg(test)] 標記的測試模組。
  2. 執行測試:接著,Rust 會自動執行所有帶有 #[test] 標記的測試函數。
  3. 顯示結果:測試執行完畢後,終端機上會顯示測試結果。

2. 測試結果範例

假設你已經撰寫了一個名為 test_add 的測試,當你執行 cargo test 命令後,你可能會看到類似如下的輸出結果:

running 1 test
test tests::test_add ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; finished in 0.00s

讓我們逐步解析這個輸出:

  • running 1 test:這行表示 Rust 找到並運行了一個測試函數。在這個例子中,只有一個測試函數,名為 test_add

  • test tests::test_add ... ok:這行表示 test_add 測試成功通過了。ok 表示測試結果符合預期,並且測試沒有失敗。

  • test result: ok. 1 passed; 0 failed; 0 ignored;:這段總結了測試結果:

    • 1 passed:表示有一個測試成功通過。
    • 0 failed:表示沒有測試失敗。如果某個測試失敗了,這裡會顯示失敗的數量。
    • 0 ignored:表示沒有忽略的測試。有時候,你可能會標記某些測試為 #[ignore],這些測試在默認情況下不會執行,除非你指定要執行忽略的測試。
  • finished in 0.00s:這段表示測試執行所花費的時間。在這個例子中,測試執行非常迅速,幾乎立刻完成。

3. 錯誤情況

假設測試失敗了,結果可能會如下所示:

running 1 test
test tests::test_add ... FAILED

failures:

---- tests::test_add stdout ----
thread 'tests::test_add' panicked at 'assertion failed: `(left == right)`
  left: `4`,
 right: `5`', src/lib.rs:8:9

failures:
    tests::test_add

test result: FAILED. 0 passed; 1 failed; 0 ignored; finished in 0.00s

這裡顯示測試失敗,因為 assert_eq! 檢查的結果並非我們期望的值。具體錯誤訊息會告訴你哪些值不相等(left: 4right: 5),並指向錯誤發生的位置(src/lib.rs:8:9)。

這樣的錯誤報告可以幫助你迅速發現和修正程式中的邏輯問題。


四、集成測試:檢查多個部分的協同運作

單元測試是用來驗證單個函數或模組的功能是否正常,而集成測試則是用來確認多個模組之間的協同工作是否符合預期。這種測試能幫助我們確保程式的不同部分能夠正確互動,避免在整合時發生意外的錯誤。

集成測試通常用於測試一個系統的某些核心流程,確保它們在多個模組間的合作下能夠正常運行。

1. 如何撰寫集成測試?

在 Rust 中,集成測試通常放在專案的 tests 目錄下。這些測試是以單獨的文件形式存在,並且每個文件中的測試會當作獨立的二進位執行檔來運行。這與單元測試的區別在於,單元測試通常是在模組內部進行的,而集成測試則是從專案的外部來檢查功能。

1.1 建立集成測試檔案

首先,我們需要在專案的根目錄下創建一個名為 tests 的資料夾。如果資料夾已經存在,可以直接在其中新增測試文件。假設我們要測試的是 add 函數,可以創建 tests/integration_test.rs 檔案。

integration_test.rs 中,我們可以撰寫如下的測試:

// tests/integration_test.rs
use your_crate::add;

#[test]
fn test_add_integration() {
    let result = add(5, 10);
    assert_eq!(result, 15);
}

1.2 測試解釋

  • use rust_test::add:這行代碼將從當前專案中導入 add 函數。在集成測試中,模組和函數會被當作外部的庫來使用,因此我們必須使用 use 語句來明確導入它們。你需要將 rust-test 替換為 rust_test 的專案名稱的來呼叫。

  • #[test]:這裡依然使用了 Rust 測試框架中的 #[test] 屬性來標記測試函數。這告訴 Cargo 這個函數是一個測試,在執行 cargo test 時會被運行。

  • assert_eq!:這是一個斷言,用來檢查兩個值是否相等。如果 add(5, 10) 的結果是 15,那麼測試會通過。如果不是,測試會失敗,並顯示錯誤訊息。

2. 如何執行集成測試?

當集成測試寫好後,你依然可以使用 cargo test 來執行測試。Rust 會自動識別並運行 tests 資料夾下的所有測試文件。

cargo test

這個命令會同時執行單元測試和集成測試。Rust 會自動區分專案中的單元測試(位於模組內部)和集成測試(位於 tests 資料夾),並逐一執行所有標記為 #[test] 的函數。

3. 測試結果分析

當你執行集成測試時,輸出結果會和單元測試類似。假設我們執行的集成測試通過了,結果可能如下:

running 1 test
test integration_test::test_add_integration ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; finished in 0.00s

這表示有一個集成測試被成功執行,並且測試結果符合預期。

4. 集成測試的意義

集成測試的目的是確認多個模組之間的協同作用是否符合預期。舉個例子,如果你開發了一個網路服務應用,單元測試可能會檢查單個模組的功能,例如用戶認證功能是否正確,而集成測試則會檢查整個系統的工作流程,比如用戶是否能夠正確註冊、登錄,並執行後續操作。

透過集成測試,你能夠提早發現模組之間的互動問題,確保整個系統的穩定性。

當我們在開發應用程式時,處理錯誤是非常重要的一環。程式必須能夠適當地應對錯誤情境,例如無效的輸入或不符合預期的行為。為了確保程式在錯誤情況下能夠按照預期運行,我們可以撰寫專門的測試來模擬這些錯誤場景。在 Rust 中,特別是針對會導致程式崩潰(panic!)的情況,我們可以使用 #[should_panic] 屬性來標記預期會發生錯誤的測試。


五、處理錯誤的測試

在某些情況下,程式的行為可能會產生錯誤,這可能是因為無效的輸入,或是程式中的特定邏輯分支引發了異常。為了確保這些錯誤情況被適當處理,我們需要撰寫測試來模擬這些情境,並驗證程式是否能按預期進行錯誤處理。

1. 撰寫 panic! 測試

在 Rust 中,當遇到不可恢復的錯誤時,程式會使用 panic! 來中止運行。舉個簡單的例子,當一個函數嘗試除以 0 時,這種情況是不合法的,我們應該讓程式發出 panic! 來避免這樣的操作。為了測試這種情境,我們可以使用 #[should_panic] 巨集,來告訴測試框架,我們預期這個測試會引發 panic!

首先,我們一樣在 tests 資料夾下建立 divide_test.rs ,並輸入以下程式碼:

pub fn divide(a: i32, b: i32) -> i32 {
    if b == 0 {
        panic!("除數不能為 0");
    }
    a / b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    #[should_panic(expected = "除數不能為 0")]
    fn test_divide_by_zero() {
        divide(10, 0);
    }
}

測試解釋:

  • #[should_panic]:這個屬性告訴 Rust 測試框架,這個測試應該觸發 panic!。當 divide 函數執行 panic! 時,測試會被認為成功。如果 panic! 沒有發生,測試將失敗。

  • expected = "除數不能為 0":這是一個選項,允許你指定預期的 panic! 訊息。如果測試中引發的 panic! 訊息與這裡指定的不符,測試也會失敗。這樣可以更加精確地檢查程式是否引發了正確的錯誤訊息。

2. 測試場景

在程式設計中,遇到錯誤情境時觸發 panic! 其實是應對不可預期錯誤的最後手段。通常,應該儘量避免在一般邏輯中引發 panic!,而是使用錯誤處理機制(如 ResultOption)來管理預期的錯誤。然而,當遇到無法避免或不可恢復的錯誤時(例如,嘗試除以 0 或訪問不存在的索引),使用 panic! 是一種合適的選擇。

例如,在數學運算中,除以 0 是不合法的操作,必須處理這種情況以防止程式崩潰。這時,我們可以撰寫一個觸發 panic! 的測試來驗證我們的程式能夠正確處理這個錯誤。

3. panic! 測試的意義

撰寫 panic! 測試有助於確認程式在遇到無效操作時是否會引發正確的錯誤,並給出相應的錯誤訊息。這在應對極端情況或無效輸入時尤為重要,可以防止潛在的崩潰或未預期的錯誤行為蔓延至整個應用程式。

測試失敗的情況:

假設 divide 函數沒有對除數為 0 的情況觸發 panic!,這時測試會失敗,並顯示錯誤訊息,說明我們的測試未能達到預期結果。

thread 'tests::test_divide_by_zero' panicked at 'assertion failed: expected panic, but no panic occurred', src/lib.rs:8:9

這種情況下,我們就知道必須修正程式,使其在遇到無效除數時觸發正確的錯誤。


六、什麼情況下應該建立單元測試與集成測試?

單元測試的目的是針對程式中的「最小單位」進行測試,這通常是指函數或類別的單一邏輯。這些測試應該簡單、快速,專注於確認某個小功能是否能按預期運作。

你應該考慮撰寫單元測試的情況:

  1. 測試單一功能的正確性:如果你剛剛完成了一個函數,想確認它能夠在特定輸入下回傳正確結果,這是單元測試最常見的情況。它有助於快速驗證單一邏輯的正確性,而不需要考慮外部依賴。

    例子:

    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
    }
    
  2. 測試邊界條件與特殊輸入:對於任何會接受用戶輸入的功能,測試極端情況或無效輸入是必須的。例如,當你開發數學運算或處理字串的函數時,單元測試能幫助你確認函數在極限情況下的表現。

    例子:測試空字串、負數或除以零的情況。

    #[test]
    fn test_divide_by_zero() {
        assert!(divide(10, 0).is_err());
    }
    
  3. 快速驗證回歸錯誤:當你修復了一個錯誤時,單元測試能確保修復後該功能不再出現同樣的問題,並且在未來的開發過程中不會因其他改動引發回歸錯誤。


集成測試的重點在於檢查多個模組之間的協同運作。當系統越來越複雜時,單元測試無法涵蓋各模組之間的互動,因此需要透過集成測試來檢查整體流程是否符合預期。

你應該考慮撰寫集成測試的情況:

  1. 測試系統的工作流程:當你想測試一個完整的工作流程(例如,從用戶註冊到數據儲存的整個過程),集成測試可以檢查模組之間的交互是否正常,確保它們能夠無縫合作。

    例子:測試從前端 API 發送請求到後端數據庫操作是否正確連接。

    #[test]
    fn test_complete_workflow() {
        let result = complete_user_registration("test_user");
        assert_eq!(result, true);
    }
    
  2. 檢查模組之間的依賴性:當你的應用程式依賴多個模組或第三方庫時,集成測試能確保這些依賴能正確協作。例如,確認數據從資料庫中提取、處理後,能正確顯示在前端。

  3. 測試外部服務的交互:如果你的程式需要與外部 API 或其他系統進行溝通,集成測試可以模擬這些交互情境,確保整個系統能正確處理外部依賴帶來的挑戰。


七、總結

Rust 提供了內建測試框架,讓開發者能夠確保程式碼的穩定性與品質。在這篇文章中,我們學會了如何撰寫單元測試、集成測試,處理錯誤情境,以及如何使用測試框架中的各種功能。

在vscode編輯器當中,如果你設定了 #[test]rust-analyzer 會判斷該函數為測試函數,因此可以直接點程式碼內的 Run Test 來進行測試,vscode會直接開啟一個terminal展示測試成果,這是一個非常方便的功能喔。

https://ithelp.ithome.com.tw/upload/images/20241006/20121176Ybo8YVzGt6.png

現在,你可以開始使用 Rust 測試框架,為你的專案撰寫測試,確保程式能夠穩定運行,並在開發過程中不斷驗證程式的正確性。


上一篇
[Day 22] 淺談 Rust 巨集(二):不再重覆製造輪子
下一篇
[Day 24] Rust 的 Web 應用(一):簡介 Actix 框架
系列文
從 Python 開發者的角度學習 Rust —— 從語法基礎到實戰應用30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言