Rust 學習之路─第十一章:測試

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. 驗證程式執行之後的結果是不是跟我們預期的一樣。

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

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

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

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

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

rust-testing

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

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

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

rust-testing

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

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

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

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

先將「lib.rs」檔案修改成:

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

rust-testing

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

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

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

rust-testing

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

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

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

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

rust-testing

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

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

rust-testing

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

如果「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_1」、「it_adds_two_panic」,都會被執行到。所以如果只想測試某幾個測試函數,假設目前有「add_two_and_two」和「add_three_and_two」兩個測試函數要測試,指令如下:

cargo test add

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

cargo test -- --ignore

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」檔案,檔案內容如下:

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

rust-testing

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

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

cargo test --test integration_test

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

總結

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

關於作者

Magic Len

Magic Len

各位好,我是Magic Len,是這網站的管理員。我是台灣台中大肚山上人,畢業於台中高工資訊科和台灣科技大學資訊工程系,曾在桃機航警局服役。我熱愛自然也熱愛科學,喜歡和別人分享自己的知識與經驗。如果你有興趣認識我,可以加我的Facebook,並且請註明是從MagicLen來的。

相關文章