Rust是被設計成用來開發高正確性程式的程式語言,運用了靜態型別來增加程式的可靠性,也用了擁有權、生命周期等等的概念來保證程式的安全性。雖然Rust強大的編譯器會替我們做很多程式碼方面的檢查,但是程式邏輯上的正確性,就無法依靠它來驗證了。



舉例來說,我們實作了一個add_two函數,這個函數可以透過參數傳入一個整數值,並且回傳其加上2之後的結果。Rust的編譯器雖然可以在編譯階段時檢查傳進來的參數型別有沒有錯誤,以及是不是已經被借用了,但是編譯器並無法直接藉由判斷函數名稱add_two,就去推論這個函數就是要把參數的值加上2之後回傳。如果我們不小心實作錯了,使得add_two函數回傳參數的值乘上2的結果,編譯器是無法檢查出來的。為了解決這個問題,我們必須要有另外的程式來驗證add_two函數的執行結果,這項工作就稱為測試(testing)。簡單來說,我們可以寫個程式,將數值3傳給add_two函數的參數,並判斷add_two函數的回傳值是不是5,如果不是,代表add_two函數的實作有錯誤。

測試是個複雜的技術,在這個章節中,我們會將重點放在如何使用Rust程式語言本身提供的機制來做測試。

如何撰寫測試?

在先前的章節中我們有建立出Cargo的函式庫專案,並且稍微了解到加上#[cfg(test)]的模組在執行cargo test指令的時候其底下有被加上#[test]的函數會被執行。沒錯,我們可以說,Rust的「測試」就是一個函數,這個函數被用來驗證其它非測試函數的函數(沒有加上#[test]的函數)所實作的功能。測試函數的主體,通常會有以下三個行為:

1. 建構測試時需要的資料和狀態。
2. 執行我們要測試的程式(例如要被測試的函數)。
3. 驗證程式執行之後的結果是不是跟我們預期的一樣。

測試函數,就是將函數使用#[]語法,並且給它一個test屬性(attribute)。屬性代表著一段Rust的程式碼,例如我們先前使用過的derive屬性,將結構體加上derive屬性之後,就可以替該結構體用預設的關聯函數和方法來實作指定的特性。test屬性的作用是讓一個函數變成測試用的函數,在使用cargo test指令時,就會自動產生出呼叫這些測試函數的程式,來執行這些測試函數,並且回報測試的結果。

我們先建立出一個新的Cargo函式庫專案adder

接著看一下專案內的lib.rs檔案。如下:

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

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

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}

我們先忽略第5行的#[cfg(test)]屬性,直接從第9行的#[test]屬性開始講起。以上程式,由於it_works函數被加上#[test]屬性,因此它是一個測試函數。而assert_eq!巨集有兩個參數,第一個參數是我們要判斷的值,第二個參數是我們預期第一個參數會是什麼的值。如果傳入assert_eq!巨集的兩個值並不相同,程式就會發生panic。

執行cargo test指令看看,結果如下:

rust-testing

cargo test指令會編譯專案並自動產生出來一個主程式,然後執行。藉由這支自動產生出來的主程式來執行所有的測試函數,我們可以大致依據其輸出結果,將測試以「Doc-tests」(文件測試)為界線,以上就是測試函數的測試結果,以下是文件測試的結果。有關於Rust的文件,之後的章節會介紹,這邊也先不管它。看一下測試函數的測試結果的部份,這支主程式會告訴我們總共有多少測試(測試函數的總數量)、是否通過所有測試、每個測試函數的執行結果、測試成功的數量、測試失敗的數量、忽略測試的數量、效能測試(benchmark)的數量、被過濾掉的數量。這些數量的總和就是測試函數的總數量。

我們嘗試在tests模組中再定義一個新的測試函數another,並直接在其主體內使用panic!巨集。如下:

pub fn add(left: usize, right: usize) -> usize {
    left + right
}

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

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }

    #[test]
    fn another() {
        panic!("Make this test fail");
    }
}

執行cargo test指令看看,結果如下:

rust-testing

由於another函數讓程式發生panic,表示這個測試函數並沒有辦法通過測試,除了測試結果的部份會出現測試失敗的訊息外,主程式還會將該測試函數所輸出到標準輸出和標準錯誤的文字印出來;如果該測試函數通過測試,其輸出到標準輸出和標準錯誤的文字就不會顯示。

assert!是一個方便用來撰寫測試程式的巨集,它可以傳入一個布林型別的參數,這個參數若不為true,程式就會panic。舉例來說:

struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

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

    #[test]
    fn larger_can_hold_smaller() {
        let larger = Rectangle {
            width: 7,
            height: 8,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(larger.can_hold(&smaller));
    }

    #[test]
    fn smaller_cannot_hold_larger() {
        let larger = Rectangle {
            width: 7,
            height: 8,
        };
        let smaller = Rectangle {
            width: 5,
            height: 1,
        };

        assert!(!smaller.can_hold(&larger));
    }
}

以上的larger_can_hold_smallersmaller_cannot_hold_larger兩個測試函數,會測試Rectangle結構實體的can_hold方法的邏輯是否正確。不熟悉測試的人可能會覺得寫這樣的程式很累贅,原本沒幾行的程式還要多加好幾個測試函數來測試,但只有這樣才可以真正確保每個函數的程式邏輯,大大地降低程式發生問題的機率,也可以省下後期維護程式的功夫。

回到我們剛剛建立的Cargo函式庫專案adder上吧!

先將lib.rs檔案修改成:

pub fn add_two(a: i32) -> i32 {
    a + 2
}

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

    #[test]
    fn it_adds_two() {
        assert_eq!(4, add_two(2));
    }
}

執行cargo test指令看看,結果如下:

rust-testing

我們可以透過指令執行之後,測試主程式產生出來的訊息,得知it_adds_two測試函數的測試結果是正確的。

如果將程式第2行的a + 2改成a + 3

pub fn add_two(a: i32) -> i32 {
    a + 3
}

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

    #[test]
    fn it_adds_two() {
        assert_eq!(4, add_two(2));
    }
}

執行cargo test指令,結果如下:

rust-testing

因為assert_eq!巨集傳入的兩個參數不相等,會使得程式panic,所以it_adds_two測試函數的測試結果就會是失敗的,並且出現能協助我們偵錯的錯誤訊息。

除了assert_eq!巨集之外還有assert_ne!巨集,它可以被用來判斷傳入的兩個參數是否不相等,如果相等會就讓程式發生panic。

assert!assert_eq!assert_ne!都是在寫測試函數時會很常用到的巨集,除了可以判斷參數傳進的值是否為true、相同或是不相同來讓程式發生panic外,它們也可以像是使用panic!巨集一樣,自訂程式發生panic時要輸出的訊息,只要將訊息的參數接在要判斷的參數之後即可。如下:

pub fn add_two(a: i32) -> i32 {
    a + 3
}

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

    #[test]
    fn it_adds_two() {
        let result = add_two(2);
        assert_eq!(4, result, "4 != {result}");
    }
}

執行cargo test指令,結果如下:

rust-testing

另外,對於有些程式邏輯,「執行函數導致程式發生panic」是正常的行為,因為是有意為之的。但是我們在測試函數中呼叫這個函數後,這個函數就會讓程式發生panic,使得測試函數的測試結果被主程式判定為測試失敗,然而我們的邏輯卻是認為程式發生panic是正確的結果。這時該怎麼處理呢?我們可以對發生panic為正常現象的測試函數,加上#[should_panic]屬性。如下:

pub fn add_two(a: i32) -> i32 {
    if a < 0 {
        panic!("`a` shoud not smaller than zero, but `a` = {a}");
    }

    a + 2
}

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

    #[test]
    #[should_panic]
    fn it_adds_two() {
        add_two(-1);
    }
}

執行cargo test指令,結果如下:

rust-testing

當然,加上#[should_panic]屬性,就會導致非預期的不可恢復錯誤也被當成是正確的結果,為了解決這個問題,我們要限制#[should_panic]屬性的行為。可以對#[should_panic]屬性加上expected參數來指定預期的panic的錯誤訊息是什麼。如下:

pub fn add_two(a: i32) -> i32 {
    if a < 0 {
        panic!("`a` shoud not smaller than zero, but `a` = {a}");
    }

    a + 2
}

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

    #[test]
    #[should_panic(expected = "`a` shoud not smaller than zero, but `a` = -1")]
    fn it_adds_two() {
        add_two(-1);
    }
}

如果it_adds_two測試函數執行時發生panic,且錯誤訊息為`a` shoud not smaller than zero, but `a` = -1,測試主程式讓這個it_adds_two測試函數通過測試。如果程式發生panic,但是其它的錯誤訊息,或是程式沒有發生panic的話,測試主程式就會將這個it_adds_two測試函數標記為測試失敗。

cargo test指令預設會使用多執行緒(thread)來執行所有的測試函數,且由於是使用多執行緒執行的關係,我們必須要讓測試函數彼此的資料和狀態各自獨立。舉例來說,假設有多個測試函數都要對同一個檔案進行寫入,此時使用cargo test指令就很有可能會造成檔案寫入異常,出現不可預期的錯誤。如果想要只用單執行緒來執行測試函數的話,可以替測試主程式加上--test-threads參數,來設定執行緒的數量。指令如下:

cargo test -- --test-threads=1

先前有提到,cargo test指令會編譯專案並自動產生出來一個主程式,然後執行。在cargo test--test-threads選項之間還有一個--,就是要用來區隔cargo test指令本身的選項和傳遞給cargo test指令所產生的主程式的參數。

cargo test指令預設也不會將通過測試的測試函數所印出的東西顯示出來,如果要將其顯示,可以替測試主程式加上--nocapture參數。指令如下:

cargo test -- --nocapture

如果只想測試單一個測試函數,而不是所有的測試函數的話,在cargo test後直接接上要測試的測試函數名稱即可。例如:

cargo test it_adds_two

事實上,以上指令只要測試函數是以it_adds_two開頭來命名的話,例如it_adds_two_1it_adds_two_panic,都會被執行到。所以如果只想測試某幾個測試函數,假設目前有add_two_and_twoadd_three_and_two兩個測試函數要測試,指令如下:

cargo test add

如果想讓指定的測試函數在執行cargo test指令時不被執行,可以替該函數加上#[ignore]屬性,測試時即可忽略它。需要很長時間來測試的測試函數可以將它們加上#[ignore]屬性,可以縮短cargo test指令的執行時間。當我們有時間進行長時間的測試時,可以替測試主程式加上--ignored參數,來過濾掉沒有#[ignore]屬性的測試函數,並執行有#[ignore]屬性的測試函數。指令如下:

cargo test -- --ignored

Rust程式語言將測試分為單元測試(unit tests)和整合測試(integration tests)。單元測試針對單一功能進行測試,會去測試模組內部私有的介面。整合測試是應用於函式庫之外的測試,以外部程式使用這個函式庫的角度來對整個函式庫進行測試。

單元測試

我們可以將單元測試的程式和要測試的程式撰寫在同一個檔案,定義出一個模組,將其加上#[cfg(test)]屬性,這個模組就只會在使用cargo test指令的時候才會被編譯,因此不用擔心寫愈多的測試會導致cargo build指令編譯出來的程式愈肥,且編譯時間也不會變得愈久。將使用#[test]屬性定義出來的測試函數放入這個使用#[cfg(test)]屬性來定義的模組,在使用cargo test指令的時候才會被編譯執行。

Rust程式語言之所以會把單元測試以模組為單位來進行,並且與要測試的程式寫在同一個檔案,就是為了要讓單元測試能夠深及內部私有成員。先前我們有學到模組和子模組的用法,一個模組就是一個檔案,也就是說每個檔案都可以撰寫其自己的單元測試模組,不用擔心要測試的項目究竟是不是有用pub關鍵字設為公開的。

整合測試

要從外部對程式進行測試,我們可以在程式專案的根目錄內建立一個tests目錄,這個目錄檔案內的每個Rust程式原始碼檔案都是獨立的crate,也是獨立的測試主程式。使用cargo test指令來編譯並且執行主程式內所定義的測試函數。

舉例來說,繼續先前的adder程式專案,我們在程式專案的根目錄內建立一個tests目錄,並在tests目錄中建立integration_test.rs檔案,檔案內容如下:

#[test]
fn it_adds_two() {
    assert_eq!(4, adder::add_two(2));
}

執行cargo test指令,結果如下:

rust-testing

可以發現到,cargo test指令除了有執行原先單元測試時的target/debug/deps/adder-f12a4ff0ee820d36測試主程式之外,還有執行到剛才增加整合測試主程式target/debug/deps/integration_test-4df8fa857a707db9

預設的cargo test指令會編譯並執行tests目錄底下所有的Rust程式原始碼檔案,如果只想要指定一個來執行,可以加上--test參數,後面接上要編譯並執行的Rust程式原始碼的檔案名稱。舉例來說,如果我們只想編譯並執行tests目錄中的integration_test.rs檔案,指令如下:

cargo test --test integration_test

tests目錄中的Rust程式也可以使用子模組的檔案系統結構,但是如果沒有使用建立新目錄的方式在tests目錄中存放子模組的程式碼的話,在使用cargo test指令時,這個子模組還是會被編譯成獨立的測試主程式來執行。另外,整合測試無法在src目錄內只有main.rs檔案而沒有lib.rs檔案的應用程式專案下使用。

總結

Rust程式語言運用了模組、屬性和檔案系統的結構提供了基本的單元測試與整合測試機制,可以幫助我們減少程式邏輯上的錯誤。在下一章節中,我們要來實作一個完整、擁有CLI的應用程式專案。

下一章:製作grep命令列程式