測試
經過數十年的軟體開發,人們發現了一項真理:未經過測試的軟體鮮少有效。不過,在這裡我們都是樂觀人士,對吧?(許多人會進一步表示:「大部分有測試的軟體也無效。」)因此,明智的做法是測試您的程式,以確保程式符合預期運作。
執行測試的一種簡單方法是撰寫 README
檔案,說明你的程式應如何運作。當您準備好要進行新版本時,請檢閱 README
並確保程式行為仍符合預期。您也可以撰寫說明程式應如何處理錯誤輸入,以使練習更嚴謹。
再提供一個巧妙的概念:在寫程式碼之前撰寫 README
。
自動化測試
這些都很好,但手動執行所有步驟?這會花費很多時間。同時,許多人開始享受指示電腦執行任務。讓我們來討論如何自動化這些測試。
Rust 有內建的測試框架,所以讓我們從撰寫第一個測試開始
fn answer() -> i32 {
42
}
#[test]
fn check_answer_validity() {
assert_eq!(answer(), 42);
}
您可以在任何檔案中放入這段程式碼,cargo test
都能找出這段程式碼並執行它。重點在於 #[test]
屬性。它讓建置系統能夠找出此類函式,並將其視為測試執行,驗證它們不會發生恐慌(panic)。
我們已經看到 如何 撰寫測試,現在我們還需要找出 要 測試什麼。正如您所見,為函式撰寫斷言相當容易。但 CLI 應用程式通常不只一個函式!更糟的是,它通常會處理使用者輸入、讀取檔案和寫入輸出。
讓您的程式可測試
有兩種互補的測試功能的方法:測試用於建立完整應用程式的小單元,它們稱為「單元測試」。還有測試「從外部」的最終應用程式,稱為「黑盒測試」或「整合式測試」。讓我們從第一個開始。
為了了解我們應該測試什麼,讓我們看看我們的程式特點是什麼。主要是,grrs
假設列印出符合給定模式的行。因此,讓我們為 *完全* 撰寫單元測試:我們想要確保我們最重要的邏輯功能正常,而且我們想要用一種不依賴我們周圍的任何設定程式碼的方式來執行(例如,處理 CLI 引數)。
回到我們的第一個 grrs
的 實現,我們將此程式碼區塊新增到 main
函式中
// ...
for line in content.lines() {
if line.contains(&args.pattern) {
println!("{}", line);
}
}
很遺憾,這不太容易測試。首先,它位於主函式中,所以我們無法輕易地呼叫它。這可以透過將此程式碼區塊移至函式中來輕易修正
#![allow(unused)] fn main() { fn find_matches(content: &str, pattern: &str) { for line in content.lines() { if line.contains(pattern) { println!("{}", line); } } } }
現在我們可以在測試中呼叫此函式,並查看它的輸出是什麼
#[test]
fn find_a_match() {
find_matches("lorem ipsum\ndolor sit amet", "lorem");
assert_eq!( // uhhhh
或者…我們可以嗎?現在,find_matches
直接列印到 stdout
,即終端機。我們無法在測試中輕易捕獲到這個!當實作後撰寫測試時,這是一個經常出現的問題:我們撰寫了一個堅定整合在所使用的內容中的函式。
好吧,我們如何讓它可以測試?我們需要以某種方式捕獲輸出。Rust 的標準函式庫有一些用於處理 I/O(輸入/輸出)的簡潔抽象,我們將利用一個稱為 std::io::Write
的函式。這是一個 特徵,用於抽象我們可以寫入的內容,包括字串,還有 stdout
。
如果這是您第一次在 Rust 的一些範例中聽過「特色」,那您真的很有福氣。特色是 Rust 最強大的功能之一。您可以將它們視為 Java 中的介面,或 Haskell 中的類型類別 (取決於您比較熟悉哪一個)。它們容許您對可以由不同類型共用的行為進行抽象。使用特色的程式碼,可以用非常一般且彈性的方式表達想法。這表示它也可能很難閱讀。不過不要讓它嚇到您:即使研究 Rust 多年的人也不見得總能馬上理解一般程式碼的作法。在這種情況下,不妨思考具體用途。例如在我們的情況中,抽象化的行為就是「寫入它」。實作(「impl」)它的類型範例包括:終端的標準輸出、檔案、記憶體中的緩衝區,或 TCP 網路連線。(向下捲動std::io::Write
文件,查看「實作者」清單。)
有了這項知識,我們來改變我們的函數,以便接受第三個參數。它應該是實作 Write
的任何類型。這樣,我們就能在測試時提供一個簡單的字串並對其進行斷言。以下是我們如何撰寫 find_matches
這個版本的做法
fn find_matches(content: &str, pattern: &str, mut writer: impl std::io::Write) {
for line in content.lines() {
if line.contains(pattern) {
writeln!(writer, "{}", line);
}
}
}
新的參數是 mut writer
,也就是一個可變的物件,我們稱其為「writer」。它的類型是 impl std::io::Write
,您可以將它理解為「實作 Write
特色的任何類型佔位符」。請也注意我們如何用 writeln!(writer, …)
取代先前使用的 println!(…)
。println!
的功能與 writeln!
相同,但始終使用標準輸出。
現在我們可以測試輸出
#[test]
fn find_a_match() {
let mut result = Vec::new();
find_matches("lorem ipsum\ndolor sit amet", "lorem", &mut result);
assert_eq!(result, b"lorem ipsum\n");
}
要將它使用在我們的應用程式程式碼中,我們必須變更 main
中對 find_matches
的呼叫,方法是增加 &mut std::io::stdout()
作為第三個參數。這是一個主函數的範例,它建立於我們在前面各章節所見的基礎上,並使用我們萃取出的 find_matches
函數
fn main() -> Result<()> {
let args = Cli::parse();
let content = std::fs::read_to_string(&args.path)
.with_context(|| format!("could not read file `{}`", args.path.display()))?;
find_matches(&content, &args.pattern, &mut std::io::stdout());
Ok(())
}
我們剛剛了解如何讓這段程式碼更易於測試。有了這段程式碼,我們
- 找出應用程式核心片段之一,
- 把它放入獨立函數中,
- 並讓它更有彈性。
儘管目標是讓它更易於測試,但我們最後得到的是一段非常慣用的可重複使用 Rust 程式碼。太棒了!
將程式碼分成函式庫和二進位檔案目標
在這裡,我們可以再做一件事。到目前為止,我們把所寫的一切都放到 src/main.rs
檔案中。這意味著我們目前的專案會產生一個二進位檔案。但我們也可以像這樣讓我們的程式碼可用於函式庫
- 將
find_matches
函數放到新的src/lib.rs
。 - 在
fn
之前加上pub
(所以變成pub fn find_matches
),讓我們的函式庫使用者可以存取它。 - 從
src/main.rs
中移除find_matches
。 - 在
fn main
中,在呼叫find_matches
之前加上grrs::
,所以現在變成grrs::find_matches(…)
。這表示它會使用我們剛剛寫好的函式庫函數!
Rust 處理專案的方式相當彈性,而且一開始就考慮要如何規劃箱子的函式庫部分會是個好主意。例如,您可以考慮先為應用程式特定邏輯寫入一個函式庫,然後在 CLI 中像使用其他函式庫那樣使用它。或者,如果您的專案有許多二進位檔案,則可以將共用功能放入該箱子的函式庫部分。
藉由執行來測試 CLI 應用程式
到目前為止,我們費盡心思測試應用程式的業務邏輯,這部分結果是 find_matches
函數。這非常有價值而且是朝向一個測試良好的程式碼庫踏出的第一步。(通常,這種測試稱為「單元測試」。)
雖然如此,還是有許多程式碼我們還沒測試:我們為處理外部世界所撰寫的所有內容!想像一下,您寫了主函數,但在不慎使用了使用者的路徑引數之前,您不小心留下了硬式編碼字串。我們也應該為此撰寫測試!(這種類型的測試通常稱為「整合測試」或「系統測試」。)
在核心部份,我們仍然在撰寫函式並加上 #[test]
註解。在這些函式中執行什麼只是一種考量。例如,我們可能想要使用專案的主要二進位,並像執行常規程式一樣執行該元件。我們也會將這些測試放入新目錄中的新檔案:tests/cli.rs
。
回顧一下,grrs
是一個小工具,用於搜尋檔案中的字串。我們之前已測試過我們可以找到符合項。讓我們思考我們還能測試哪些其他功能。
以下是我的想法。
- 在檔案不存在時會發生什麼情況?
- 在沒有符合項的情況下,結果會是什麼?
- 當我們忘記一個(或兩個)引數時,我們的程式會以錯誤結束嗎?
這些都是有效的測試案例。另外,我們還應該包含一個用於「快樂路徑」的測試案例,換句話說,我們找到至少一個符合項並列印出來。
為了讓這些測試類型更容易,我們打算使用 assert_cmd
建構。它擁有一堆簡潔的輔助函式,能讓我們執行我們的主要二進位,並觀察它的執行方式。此外,我們還會加入 predicates
建構,這有助於我們撰寫 assert_cmd
可以測試的斷言(並且擁有很棒的錯誤訊息)。我們會將這些相依性加到我們的 Cargo.toml
中的「開發相依性」區段,而不是加到主要清單中。它們只在開發建構時需要,而不是使用時。
[dev-dependencies]
assert_cmd = "2.0.14"
predicates = "3.1.0"
聽起來像有很多設定。不過,讓我們直接深入,建立我們的 tests/cli.rs
檔案
use assert_cmd::prelude::*; // Add methods on commands
use predicates::prelude::*; // Used for writing assertions
use std::process::Command; // Run programs
#[test]
fn file_doesnt_exist() -> Result<(), Box<dyn std::error::Error>> {
let mut cmd = Command::cargo_bin("grrs")?;
cmd.arg("foobar").arg("test/file/doesnt/exist");
cmd.assert()
.failure()
.stderr(predicate::str::contains("could not read file"));
Ok(())
}
您可以使用 cargo test
執行此測試,就像我們上述撰寫的測試一樣。第一次執行時可能會花一點時間,因為 Command::cargo_bin("grrs")
需要編譯您的主要二進位。
產生測試檔案
我們剛剛看到的測試只檢查我們的程式在輸入檔案不存在時會撰寫錯誤訊息。那是重要的測試,但可能不是最重要的測試:現在讓我們測試,我們實際上列印在檔案中找到的符合項吧!
我們需要一個具有我們已知內容的文件,這樣我們才能知道我們的程式應該回傳什麼以及在我們的程式碼中檢查這個預期。一個想法可能是將一個具有自訂內容的文件新增至專案,並在我們的測試中使用該文件。另一個想法是在我們的測試中新增暫時性的檔案。對於本教學,我們將看看後者的方法。主要是因為它更靈活,而且也會在其他情況下運作;例如,當你正在測試會變更文件的程式時。
若要建立這些暫時性檔案,我們將使用 assert_fs
Crate。我們將它新增至我們 Cargo.toml
中的 dev-dependencies
assert_fs = "1.1.1"
以下是新的測試案例(你可以將它寫在另一個案例的下方),它會先建立一個暫時性檔案(一個「具名稱」的檔案,這樣我們才能取得它的路徑),在裡面填入一些文字,然後執行我們的程式以查看我們是否能取得正確的輸出。當 file
超出作用域(在函式的尾端),實際的暫時性檔案將自動被刪除。
use assert_fs::prelude::*;
#[test]
fn find_content_in_file() -> Result<(), Box<dyn std::error::Error>> {
let file = assert_fs::NamedTempFile::new("sample.txt")?;
file.write_str("A test\nActual content\nMore content\nAnother test")?;
let mut cmd = Command::cargo_bin("grrs")?;
cmd.arg("test").arg(file.path());
cmd.assert()
.success()
.stdout(predicate::str::contains("A test\nAnother test"));
Ok(())
}
要測試什麼?
雖然寫整合測試肯定是有趣的,但寫它們以及在應用程式的行為改變時更新它們也需要一些時間。若要確保你明智地使用你的時間,你應該問問自己你應該測試什麼。
一般而言,為使用者可以觀察到的所有類型的行為撰寫整合測試會是一個好主意。這表示你不需要涵蓋所有邊界案例:通常只需要具備不同類型的範例並仰賴單元測試來涵蓋邊界案例即可。
專注於你無法主動控制的事物也不是一個好主意。測試 --help
的精確佈局,因為它是為你產生的,將是一個壞主意。相反地,你可能只想檢查某些元素是否存在。
根據你的程式的性質,你也可以嘗試加入更多測試技巧。例如,如果已擷取程式的一部分並發現自己在寫很多範例案例作為單元測試時,同時試圖想出所有邊界案例,你應該深入研究 proptest
。如果你有一個消耗任意檔案並解析它們的程式,請嘗試寫一個 fuzzer 來找出邊界案例中的臭蟲。