avatar
threadsinstagram

Rust 入門筆記 (3) - 用單元測試來學習 Rust 語法

Table of Contents

前言

延續上一篇文章,本篇將介紹如何實作一個簡單的 Rust 單元測試,並學習如何在 VS Code 中實現套件管理工具 Cargo 的擴充功能。

實作

完整程式碼可以參考這個 GitHub 連結

寫一段簡單的函式

這裡就用一個經典的加減運算代表某個我們想測試的 utility function:

pub fn add(a: i8, b: i8) -> i8 {
    a + b
}
 
pub fn sub(a: i8, b: i8) -> i8 {
    a - b
}
 
fn main() {
    // 這段也可以不寫,主要只是想模擬主程式執行時的輸出值
    let result_array = [add(55, 66), sub(55, 66)];
    println!("{:?}", result_array);
}

語法的部份下面會另外一起做解釋,這邊重點先放在學會怎麼寫一個測試並執行它。接著我們可以在上面程式之後,試著加一段 Rust 版本的單元測試:

#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
        assert_eq!(add(-1, 1), 0);
        assert_eq!(add(0, 0), 0);
        assert_eq!(add(-5, -7), -12);
    }
 
    #[test]
    fn test_sub() {
        assert_eq!(sub(5, 3), 2);
        assert_eq!(sub(-1, 1), -2);
        assert_eq!(sub(0, 0), 0);
        assert_eq!(sub(-5, -7), 2);
    }
}

此時再試著執行 cargo test 或如果是 VS Code 可以點擊該單元測試區塊上的 Run Tests 按鈕,可以看到測試執行通過的資訊:

demo of unit test code

這樣應該就算是一個單元測試版本的 hello world 了,那以下就來了解一下其中的語法以及進階的使用方式。

Rust 語法解析

為了方便解釋我將其中加減運算的函式拆到另一個 utils.rs 檔案來:

// utils.rs
pub fn add(a: i8, b: i8) -> i8 {
    a + b
}
 
pub fn sub(a: i8, b: i8) -> i8 {
    a - b
}
 
#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
        assert_eq!(add(-1, 1), 0);
        assert_eq!(add(0, 0), 0);
        assert_eq!(add(-5, -7), -12);
    }
 
    #[test]
    fn test_sub() {
        assert_eq!(sub(5, 3), 2);
        assert_eq!(sub(-1, 1), -2);
        assert_eq!(sub(0, 0), 0);
        assert_eq!(sub(-5, -7), 2);
    }
}
// main.rs
mod utils;
 
fn main() {
    let result_array = [utils::add(55, 66), utils::sub(55, 66)];
    println!("{:?}", result_array);
}

數字型別

這裡的函式寫法看起來有點像 TypeScript,每個參數以及回傳值都有指定型別,而這裡都是 i8i8 指的是 8 位元的整數 (integer),因為需包含正負號與 0,這個整數允許的範圍會是在 -2^72^7 - 1 之間,也就是只能是 -128127 之間的整數。看張圖可能會有種突然回憶起高中的計算機概論的感覺:

而至於需要幾位元的整數都可以按需調整,如果全都是正整數也可以用 u8 代表 unsigned,也就是正負號的位元不需被佔用,因此可以包含更大的值 (ref)。而順帶一提如果要允許小數的話就會是 f8 這樣,代表 floating number 的意思。

pub 與 use

這個 pub 看起來是指程式中的公私有的定義(ref)。在 Rust 中預設每個模組都是私有的,也就是只有在這個模組中的作用域可以讀取得到,如果要讓這些 function 可以在不同檔案間去載入,會需要將它設為 pub 讓其他模組中可以讀取得到。

這裡可以試著在 utils.rs 中去拿掉這個 pub 後用 cargo run 編譯試試看,會看到這樣的錯誤:

error[E0603]: function `sub` is private
 --> src/main.rs:4:52
  |
4 |     let result_array = [utils::add(55, 66), utils::sub(55, 66)];
  |                                                    ^^^ private function
  |
note: the function `sub` is defined here
 --> src/utils.rs:5:1
  |
5 | fn sub(a: i8, b: i8) -> i8 {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
For more information about this error, try `rustc --explain E0603`.
error: could not compile `hello_cargo` (bin "hello_cargo") due to 2 previous errors

甚至也可以用 cargo test 去執行測試,會看到類似的錯誤。看到 main.rs 中會看到 mod utilsutils::add 這些語法,雖然概念上不太一樣,但可以以類似的 JS 語法來理解:

import utils from utils;
 
utils.add(55, 66);

也就是說在不同模組之間要能導出、載入這些 function 在 Rust 中會需要用 pubuse:: 這些語法來實現。

另外補充一下,在專案中的 Cargo.toml 中會看到裡面有 package 這個關鍵字,這裡在想那這樣 cratepackage 都是指套件嗎?翻閱了教學手冊有講解這個點,擷取文件中的這段如下:

  • Packages: A Cargo feature that lets you build, test, and share crates
  • Crates: A tree of modules that produces a library or executable
  • Modules and use: Let you control the organization, scope, and privacy of paths
  • Paths: A way of naming an item, such as a struct, function, or module

簡單整理的話可以是這樣:

  • package 包含 crates,每個 crate 裡面又在包含數個 modules
  • package 被視為一個 Cargo 專案,可能引用多個 crate 來產出另一個 crate
  • crate 被視為套件
  • module 被視為模組,就是每個包含特定邏輯的程式碼片段,裡面有自己的作用域、function、變數等
  • 模組間互相引用時,會使用一些關鍵字來處理:
    • use 就像是 JavaScript 中的 import
    • pub 雖然概念上不同,但可以說為了要達到類似 export 的效果要將自己設定為公開 (public)
    • Paths 就是在引入模組時可以用 :: 這個運算子取得對應的變數或函式

單元測試語法

最後看到測試語法的部份:

#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn test_add() {
        assert_eq!(add(2, 3), 5);
        assert_eq!(add(-1, 1), 0);
        assert_eq!(add(0, 0), 0);
        assert_eq!(add(-5, -7), -12);
    }
 
    #[test]
    fn test_sub() {
        assert_eq!(sub(5, 3), 2);
        assert_eq!(sub(-1, 1), -2);
        assert_eq!(sub(0, 0), 0);
        assert_eq!(sub(-5, -7), 2);
    }
}

關於測試的寫法可以參考文件這段,筆記一些語法:

  • #[cfg(test)]:代表只有在執行 cargo test 時才執行 (ref)
  • mod tests#[test]:定義測試的區塊,在想這可能可以理解為類似 jest 中的 describeit 吧,用一個模組來把某個 test suite 中的單元測試案例包起來
  • use super::*;:將父層模組的所有項目引入這個測試模組的作用域
  • assert_eq!:看到 ! 就可以知道是某種操作的巨集 (marco),這裡顧名思義就是在斷言是否相等

一些進階的測試語法

這邊繼續參考文件內容來嘗試一些關於測試中的其他語法:

#[cfg(test)]
mod tests {
    use super::*;
 
    // ...
 
    #[test]
    #[should_panic]
    fn some_error() {
        panic!("oh no!");
    }
}

這個 panic! 參考文件是 Rust 中做錯誤處理的語法,從這段說明看起來 Rust 的錯誤處理分兩種:

  • 可復原的 (recoverable):用 Result<T, E> 來表示,用途類似用來提示使用者的警告
  • 不可復原的 (unrecoverable):用 panic! 會將程式停止執行,讓開發者能快速地在編譯過程中就知道需要處理錯誤

而將一個想要斷言應該錯誤的測試案例,可以用 #[should_panic] 來標記,因此這段測試就會正常通過。

那這個語法實際上有什麼用?可以試著在原本的 add 中加上一段會處理錯誤的邏輯,並加上一個測試案例讓它能觸發這個 panic

pub fn add(a: i8, b: i8) -> i8 {
  if a > 50 || b > 50 {
      panic!("請輸入小於 50 的值!你輸入的 a 是 {}, b 是 {}", a, b);
  }
  a + b
}
 
#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn some_error() {
        assert_eq!(add(55, 66), 121);
    }
}

當今天沒加上 #[should_panic] 的狀況下去執行測試,會遇到執行錯誤,並顯示這樣的訊息:

---- utils::tests::some_error stdout ----
thread 'utils::tests::some_error' panicked at src/utils.rs:3:7:
請輸入小於 50 的值!你輸入的 a 55, b 66

這時只要補上 #[should_panic] 就能正確去斷言這個測試案例:

#[test]
#[should_panic]
fn some_error() {
    assert_eq!(add(55, 66), 121);
}

但在使用 #[should_panic] 時如果只寫上面這樣,其實會連不是原模組裡的 panic 錯誤都被抓進來,而造成可能誤判的狀況。看個例子,假設今天是像這樣的測試:

#[test]
#[should_panic]
fn some_error() {
    assert_eq!(add(12, 34), 46);
    panic!("不是程式碼中的 panic");
}

這時我們的輸入值皆小於 50,所以並不會觸發 add 中的 panic,但因為在這個測試中另外發生了 panic,所以這測試仍會通過,為了更準確的去指定只要抓 add 中的 panic,會需要加點料:

#[test]
#[should_panic(expected = "請輸入小於 50 的值")]
fn some_error() {
    assert_eq!(add(12, 34), 46);
    panic!("不是程式碼中的 panic");
}

像上面這樣用 expected 去指定 panic 中的錯誤訊息要包含怎樣的 substring,這時再去執行這個測試,就會如預期的遇到錯誤了:

note: panic did not contain expected string
      panic message: `"不是程式碼中的 panic"`,
 expected substring: `"請輸入小於 50 的值"`
 
failures:
    utils::tests::some_error
 
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 2 filtered out; finished in 0.02s

小結

今天從昨天的 cargo test 延伸學習了如何寫一個簡單的 Rust 單元測試,並趁機會把一些前面沒提到的語法觀念補上,了解了數字型別、模組導出載入方式、錯誤處理等,也小小的體驗到了在初學一個新語言的前期各種學習曲線還很低時的成就感。

另外關於測試其實還有更多進階內容,有興趣的讀者歡迎再參考下面的延伸資料。

延伸閱讀

Tags