在Rust中,單元測試通常儲存在與被測試程式碼同的源檔案中。對於任何給定的結構、函式或方法,其相應的單元測試通常位於同一個源檔案中,通常在檔案底部。這有一個很好的副作用,就是幫助你保持程式碼對較小並分離關注點。
如果你嘗試在一個檔案中放入太多邏輯,它可能會變得相當大,特別是如果你有複雜的測試。一旦你超過1,000行,你可能需要考慮重構。
Rust的內建測試功能
Rust提供了幾個基本的測試功能,儘管與更成熟的測試框架相比,你可能會發現內建功能不足。Rust和其他語言之間的一個顯著區別是,核心Rust工具和語言包含測試功能,無需使用額外的函式庫架。
在許多語言中,測試是事後才考慮的,需要額外的工具和函式庫進行適當的測試。Rust通常可以找到缺少的功能,但你也可能發現,由於嚴格的語言保證,Rust使測試變得更容易。
讓我們看一個簡單的帶有單元測試的函式庫構,以演示Rust的測試功能:
// 一個泛型加法函式
pub fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 2), 4);
}
}
這段程式碼展示了Rust單元測試的基本結構。首先,我們定義了一個泛型的add
函式,它可以對實作了std::ops::Add
trait的任何類別進行加法運算。接著,我們建立了一個標記為#[cfg(test)]
的tests
模組,這告訴編譯器這是一個測試模組,只在測試時編譯。在模組內部,use super::*;
匯入了外層作用域的所有專案,使我們可以存取add
函式。然後,我們定義了一個標記為#[test]
的測試函式,使用assert_eq!
巨集查add(2, 2)
的結果是否為4
。當執行cargo test
命令時,Rust會自動執行所有標記為#[test]
的函式。
Rust提供了以下測試功能:
單元測試 - Rust和Cargo直接提供單元測試,無需使用額外的函式庫試模組必須標記為
#[cfg(test)]
,測試函式必須標記為#[test]
屬性。整合測試 - Rust和Cargo提供整合測試,允許從公共介面測試函式庫用程式。測試通常構建為獨立於主原始碼的單獨應用程式。
檔案測試 - 在使用rustdoc的原始碼檔案中的程式碼例被視為單元測試,這巧妙地同時提高了檔案和測試的整體品質。
Cargo整合 - 單元測試、整合測試和檔案測試自動與Cargo配合工作,除了定義測試本身外,不需要額外的工作。
cargo test
命令處理過濾、顯示斷言錯誤,甚至為你平行化測試。*斷言巨集 - Rust提供斷言巨集如
assert!()
和assert_eq!()
,儘管這些不是測試專用的(它們是普通的Rust巨集可以在任何地方使用)。然而,Cargo在執行單元測試時會適當處理斷言失敗並提供有用的輸出訊息。
當你執行cargo test
時,輸出如下所示:
$ cargo test
Compiling unit-tests v0.1.0
Finished test [unoptimized + debuginfo] target(s) in 0.95s
Running unittests (target/debug/deps/unit_tests-c06c761997d04f8f)
running 1 test
test tests::test_add ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out;
finished in 0.00s
Doc-tests unit-tests
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out;
finished in 0.00s
擴充套件Rust的測試能力
Rust的單元測試不包括你可能在其他單元測試框架中找到的輔助工具、固定裝置、測試框架或引數化測試功能。對於這些,你要麼需要自己編寫程式碼要麼嘗試使用一些函式庫
引數化測試
對於基本的引數化測試,parameterized
crate提供了一個很好的介面來建立測試。test-case
crate提供了另一種簡單、簡潔與易於使用的引數化測試實作。
use test_case::test_case;
#[test_case(1,
## 屬性測試的威力:超越手動測試的極限
在 Rust 的測試生態系統中,有一個特別值得關注的工具—proptest 函式庫個強大的測試框架為開發者提供了一種自動化測試方法,能夠顯著提高程式碼的健壯性。作為 Rust 實作的 QuickCheck(一個最早於 1999 年發布的 Haskell 測試函式庫proptest 並非簡單的移植,而是在保留原始功能的基礎上增加了許多 Rust 特有的改進。
### 屬性測試的核心優勢
屬性測試的核心思想是自動產生隨機測試資料,驗證程式碼是否符合特定屬性,並在發現錯誤時提供最小的失敗案例。這種方法能夠幫助開發者發現那些用人工選擇的測試案例很難捕捉到的邊界情況和異常行為。
然而,屬性測試並非萬能的:
屬性測試需要權衡取捨 - 可能需要耗費更多的 CPU 週期來測試隨機值, 相較於手動挑選或已知的值。雖然可以調整要測試的隨機值數量, 但對於可能值集合較大的資料,測試每一種可能的結果通常並不實際。
雖然屬性測試在發現隱藏問題方面非常有效,但它並不能完全替代對已知值的測試,特別是當需要驗證規範合規性時。
### 使用 proptest 進行簡單案例測試
讓我們重新審視前面提到的加法函式範例,但這次使用 proptest 來提供測試資料:
```rust
pub fn add<T: std::ops::Add<Output = T>>(a: T, b: T) -> T {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn test_add(a: i64, b: i64) {
assert_eq!(add(a, b), a + b);
}
}
}
這段程式碼示範瞭如何使用 proptest 來測試一個泛型加法函式。關鍵部分是 proptest!
巨集它會自動為測試函式生成隨機的 i64
類別輸入引數。測試斷言很簡單:確認我們的 add
函式結果與直接使用 +
運算元的結果相同。這種方法比手動編寫測試案例更有效,因為它可以自動測試大量不同的輸入組合。
當測試失敗時:發現邊界情況
當執行上述測試時,我們可能會得到以下輸出:
cargo test
Compiling proptest v0.1.0
Finished test [unoptimized + debuginfo] target(s) in 0.59s
Running unittests (target/debug/deps/proptest-db846addc2c2f40d)
running 1 test
test tests::test_add ... FAILED
failures:
----- tests::test_add stdout -----
thread 'tests::test_add' panicked at 'Test failed: attempt to add with
overflow; minimal failing input: a = -2452998745726535882,
b = -6770373291128239927
successes: 1
local rejects: 0
global rejects: 0
', src/lib.rs:9:5
failures:
tests::test_add
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered
out; finished in 0.00s
error: test failed, to rerun pass '--lib'
出乎意料的是,我們的加法函式在某些情況下失敗了!proptest 不僅指出了測試失敗,還提供了導致失敗的最小輸入案例:兩個大的負數相加導致整數溢位。這正是屬性測試的價值所在——它能夠自動發現我們可能未曾預想到的邊界情況。
Rust 中的算術溢位處理
在深入修復上述問題前,值得了解 Rust 中算術溢位的處理機制。這種機制可能會讓初學者感到困惑,因為它在不同編譯模式下表現不同:
- 在除錯模式下(包括測試),Rust 預設使用檢查型算術,會在溢位時 panic
- 在發布模式下,則使用非檢查型算術,行為類別於 C 語言(溢位時進行環繞)
這種差異背後的理念是:測試程式碼該更嚴格以捕捉更多錯誤,但為了相容性,執行時程式碼該表現得像大多數其他程式語言一樣。
Rust 標準函式庫數類別提供了多種算術函式,例如 i32
類別有:checked_add()
、unchecked_add()
、carrying_add()
、wrapping_add()
、overflowing_add()
和 saturating_add()
。
修復溢位問題
要解決我們的測試失敗,可以使用環繞加法來模擬 C 語言的行為:
extern crate num_traits;
use num_traits::ops::wrapping::WrappingAdd;
pub fn add<T: WrappingAdd<Output = T>>(a: T, b: T) -> T {
a.wrapping_add(&b)
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn test_add(a: i64, b: i64) {
assert_eq!(add(a, b), a.wrapping_add(b));
}
}
}
在這個修改版本中,我們使用了 num_traits
函式庫的 WrappingAdd
特徵,以實作環繞加法。關鍵變化在於:
- 函式的泛型約束從
std::ops::Add
改為WrappingAdd
- 實作中使用
wrapping_add
方法而非直接的+
運算元 - 測試的斷言也相應更新,使用
a.wrapping_add(b)
而非a + b
這樣修改後,當整數溢位時,加法運算會環繞(就像在 C 語言中一樣),而不是觸發 panic。現在測試應該能夠順利透過,因為我們明確處理了溢位情況。
不需測試的內容:編譯器的智慧
Rust 作為一種靜態類別語言,在測試方面提供了顯著的優勢。靜態類別系統的核心價值在於:編譯器會在程式執行前分析原始碼,透過類別規範限制可能的輸入和輸出集合。
靜態類別系統的保障
在 Rust 中,編譯器會確保:
- 類別比對預期要求
- 參照有效
- 不會在執行時混淆字元串和整數
- 沒有資料競爭條件
這意味著在編寫測試時,我們不需要測試那些編譯器或借用檢查器已經幫我們測試過的內容。例如:
- 不需要檢查整數是否為整數,或字元串是否為字元串
- 不需要檢查參照是否有效
- 不需要檢查資料是否被多個執行緒時修改(競爭條件)
測試重點的轉移
這並不意味著 Rust 程式不需要測試,而是測試的重點從類別驗證和記憶體用轉移到了邏輯測試上。雖然類別轉換可能失敗,但處理這些情況是邏輯問題,而非類別問題。
Rust 的人體工學設計使得處理可能失敗的事情變得更加直觀和安全。例如,使用 Result
和 Option
類別來處理可能的錯誤情況,編譯器會強制我們考慮這些可能性。
處理平行測試特殊情況與全域狀態
在多執行緒境下測試程式碼時,全域狀態管理是一個常見的挑戰。Rust 的所有權系統在這方面提供了強大的保障,但仍需要特別注意一些情況。
Rust 的測試框架預設會平行執行測試,這可能導致使用分享資源的測試互相干擾。對於這類別況,可以採取以下策略:
- 使用
#[test]
屬性中的serial
特性來標記需要順序執行的測試 - 利用互斥鎖(Mutex)或讀寫鎖(RwLock)來保護分享資源
- 為每個測試建立獨立的資源例項,避免分享
這些策略確保了即使在平行測試環境中,我們的測試結果也是可靠和一致的。
有效測試的最佳實踐
根據以上討論,玄貓總結了一些在 Rust 中進行有效測試的最佳實踐:
- 利用屬性測試發現邊界情況:使用 proptest 等框架自動生成測試資料,發現手動難以識別的問題
- 著重測試業務邏輯:將測試重點放在編譯器無法驗證的業務邏輯上
- 明確處理溢位行為:根據應用需求明確選擇適當的溢位處理方式(環繞、飽和、檢查等)
- 測試錯誤處理:確保程式能夠正確處理各種錯誤情況
- 隔離平行測試:當測試涉及分享資源時,確保測試間不會相互幹擾
Rust 的類別系統和編譯時檢查為我們提供了強大的安全網,讓我們能夠專注於測試那些真正需要測試的部分。透過結合這些內建保障與屬性測試等先進技術,我們可以構建出更加健壯和可靠的系統。
在實際開發中,找到靜態檢查與動態測試之間的平衡點,是提高程式碼品質和開發效率的關鍵。Rust 的設計哲學鼓勵我們"讓編譯器成為你的盟友",這一點在測試策略中同樣適用。
Rust測試的基礎原則
在Rust程式設計中,測試是確保程式碼品質的重要一環。不同於其他語言,Rust的測試策略深度依賴於其嚴謹的型別系統,為開發者提供了強大的保障。
善用Rust的型別系統
Rust的型別系統是其最強大的功能之一,在測試中正確利用它能幫助開發者避免許多常見錯誤。玄貓在開發過程中發現,許多測試問題其實源於對Rust型別系統的誤用或濫用。
以下是幾個關鍵的測試原則:
- 謹慎使用Option和unwrap() - 過度使用這些功能可能導致難以發現的錯誤,特別是當它們被用來迴避邊緣案例處理時
- 避免不必要的unsafe程式碼 - unsafe程式碼繞過了Rust的安全檢查,增加了測試的複雜度和出錯風險
- 妥善處理I/O操作 - 執行I/O的函式應該回傳
Result
型別,以便正確處理可能的錯誤
// 不推薦的測試方式
fn process_data(input: &str) -> String {
// 直接unwrap可能導致測試失敗
let data = get_data_from_somewhere().unwrap();
format!("{}: {}", input, data)
}
// 推薦的測試方式
fn process_data(input: &str) -> Result<String, Error> {
let data = get_data_from_somewhere()?;
Ok(format!("{}: {}", input, data))
}
上面的程式碼展示了兩種處理資料的方法。第一種使用了unwrap()
,這在測試中是危險的,因為它會在錯誤時直接引發panic。第二種方法使用了?
運算元,將錯誤優雅地傳播回呼叫者,使測試更容易處理邊緣情況。這種方法使測試更加健壯,因為它允許測試程式碼明確地檢查錯誤情況。
平行測試的特殊案例與全域狀態處理
當使用Cargo執行單元測試時,Rust會平行執行測試以提高效率。大多數情況下,這種機制運作良好,但當測試需要分享全域狀態或測試裝置(fixtures)時,就可能遇到挑戰。
全域狀態的挑戰
讓我們先看一個簡單的例子,瞭解為什麼在Rust測試中處理全域狀態會比較棘手:
#[cfg(test)]
mod tests {
static mut COUNT: i32 = 0;
#[test]
fn test_count() {
COUNT += 1;
}
}
這個測試嘗試定義一個全域計數器並在測試中增加它的值。但這段程式碼無法透過編譯,Rust編譯器會產生錯誤,提醒我們使用可變的靜態變數是不安全的,需要用unsafe
區塊來包裹。這是因為可變的靜態變數可能被多個執行緒同時修改,導致資料競爭和未定義行為。
Rust的這種嚴格檢查是有意義的。如果在C語言中編寫類別的程式碼,它會編譯並執行,但可能在某些情況下導致難以追蹤的錯誤。
嘗試使用原子型別
既然我們需要一個在多執行緒環境中安全的計數器,最直觀的解決方案是使用原子型別:
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicI32, Ordering};
static mut COUNT: AtomicI32 = AtomicI32::new(0);
#[test]
fn test_count() {
COUNT.fetch_add(1, Ordering::SeqCst);
}
}
這裡我們嘗試使用AtomicI32
來解決多執行緒安全問題。fetch_add
方法是原子操作,可以安全地增加計數器的值。Ordering::SeqCst
引數指定了最嚴格的記憶體順序,確保原子操作在所有執行緒中以相同的順序被觀察到。
然而,這段程式碼仍然會產生與前一個例子相同的錯誤。問題在於,雖然AtomicI32
本身是執行緒安全的,但靜態變數COUNT
的所有權問題仍然存在。
嘗試使用Arc
接下來,我們嘗試使用Arc
(原子參照計數)來解決所有權問題:
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
static COUNT: Arc<AtomicI32> = Arc::new(AtomicI32::new(0));
#[test]
fn test_count() {
let count = Arc::clone(&COUNT);
count.fetch_add(1, Ordering::SeqCst);
}
}
在這個版本中,我們使用Arc
包裹AtomicI32
,這樣可以安全地在多個執行緒間分享所有權。在測試函式中,我們先複製Arc
,然後再對計數器進行操作。
但這段程式碼仍然無法編譯。新的錯誤提示我們:靜態變數中的呼叫被限制為常數函式、元組結構和元組變體。問題在於Arc::new()
不是一個常數函式,無法在編譯時求值。
使用lazy_static解決全域狀態問題
到目前為止,我們嘗試的方法都無法解決全域狀態的問題。這時,lazy_static
函式庫上用場了。
lazy_static的魔力
lazy_static
是一個非常實用的Rust函式庫讓建立靜態變數變得更加容易。它允許我們在執行時初始化靜態結構,並且只在第一次存取時進行初始化。
使用lazy_static
改寫我們的測試:
#[cfg(test)]
mod tests {
use lazy_static::lazy_static;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;
lazy_static! {
static ref COUNT: Arc<AtomicI32> = Arc::new(AtomicI32::new(0));
}
#[test]
fn test_count() {
let count = Arc::clone(&COUNT);
count.fetch_add(1, Ordering::SeqCst);
}
}
這次的程式碼終於可以編譯和執行了!lazy_static!
巨集理了在執行時初始化資料的細節。當變數第一次被存取時,它會自動被初始化,然後我們可以全域使用它。
lazy_static
的實作原理是根據Rust標準函式庫std::sync::Once
原語。當我們檢視lazy_static
生成的程式碼時,可以看到它實作了Deref
特徵,並使用了Once
來確保初始化程式碼執行一次。
簡化的lazy_static使用
由於lazy_static
已經提供了Send
特徵,我們可以進一步簡化程式碼,移除不必要的Arc
:
#[cfg(test)]
mod tests {
use lazy_static::lazy_static;
use std::sync::atomic::{AtomicI32, Ordering};
lazy_static! {
static ref COUNT: AtomicI32 = AtomicI32::new(0);
}
#[test]
fn test_count() {
COUNT.fetch_add(1, Ordering::SeqCst);
}
}
這是最終版本的全域計數器實作。使用lazy_static!
巨集我們可以直接定義一個AtomicI32
型別的靜態參照,而不需要額外的Arc
包裝。這個參照在第一次被存取時才會被初始化,並且可以安全地在多個測試間分享。
測試同步與執行順序
雖然lazy_static
幫助我們解決了分享全域狀態的問題,但它並不能幫助我們同步測試本身。如果我們需要確保測試按特定順序執行,或者一次只執行一個測試,我們需要額外的同步機制。
使用互斥鎖同步測試
以下是如何使用互斥鎖來同步測試執行的例子:
#[cfg(test)]
mod tests {
use lazy_static::lazy_static;
use std::sync::Mutex;
lazy_static! {
static ref MUTEX: Mutex<i32> = Mutex::new(0);
}
#[test]
fn first_test() {
let _guard = MUTEX.lock().expect("無法取得鎖");
println!("第一個測試正在執行");
}
#[test]
fn second_test() {
let _guard = MUTEX.lock().expect("無法取得鎖");
println!("第二個測試正在執行");
}
}
這段程式碼使用Mutex
來確保一次只有一個測試能夠執行。每個測試函式首先嘗試取得互斥鎖,只有成功取得鎖的測試才能繼續執行。當測試函式結束時,_guard
變數會被釋放,從而釋放鎖,允許其他測試執行。
值得注意的是,即使我們使用了互斥鎖,我們仍然無法保證測試的執行順序。如果使用cargo test --nocapture
多次執行這段程式碼,你可能會發現輸出的順序並不總是相同的。這是因為Rust的測試框架仍然嘗試平行執行這些測試,儘管互斥鎖確保了它們實際上是順序執行的。
控制測試執行順序的其他方法
如果需要更精確地控制測試執行順序,有幾種可能的方法:
- 實作自定義的
main()
函式:透過覆寫Rust的libtest函式庫可以完全控制測試的執行方式。 - 使用
--test-threads=1
引數:執行cargo test -- --test-threads=1
會限制Cargo只使用一個執行緒執行測試。 - 使用障礙(barrier)或條件變數:這些同步原語可以更精細地控制測試執行順序。
Rust的Send和Sync特徵
理解Rust的Send
和Sync
特徵對於處理多執行緒程式碼分享狀態至關重要。這些特徵是Rust提供執行緒安全保證的基礎:
- Send特徵:標記可以安全地在執行緒間移動的物件。
- Sync特徵:標記可以安全地在執行緒間分享參照的物件。
例如,如果要將變數從一個執行緒移動到另一個執行緒,它需要實作Send
特徵。如果要在多個執行緒間分享同一個變數的參照,則需要實作Sync
特徵。
這些特徵通常由編譯器自動實作,開發者不需要直接實作它們。相反,可以使用Arc
、Mutex
和RwLock
等組合來實作執行緒安全。
Rust的測試系統強大而靈活,但處理全域狀態和平行測試需要一些特殊技巧。透過正確使用lazy_static
和理解Rust的執行緒安全機制,我們可以編寫出既安全又高效的測試。
在實際開發中,玄貓建議遵循以下原則:
- 盡可能使用Rust的型別系統來避免錯誤,而不是依賴執行時檢查。
- 對於需要分享狀態的測試,優先考慮使用
lazy_static
。 - 瞭解並正確使用
Send
和Sync
特徵,確保多執行緒程式碼安全性。 - 當需要控制測試執行順序時,考慮使用互斥鎖或其他同步機制。
透過這些實踐,我們可以充分利用Rust的測試框架,確保程式碼的正確性和穩定性。Rust的嚴格編譯器檢查可能初期讓人感到挫折,但長期來
單元測試與重構:相輔相成的關係
在開發過程中,程式碼重構是不可避免的。隨著需求變更、理解加深,或發現更好的實作方式,我們常需要修改既有程式碼而不改變其對外行為。然而,這種修改總是伴隨著風險——可能不小心破壞現有功能。這就是單元測試的價值所在:它能在發布前捕捉到因重構導致的迴歸問題(regression)。
當我在進行大型系統重構時,發現一個現象:有完整單元測試覆寫的模組,重構過程遠比沒有測試保護的程式碼順利。測試就像一張安全網,讓我能夠放心大膽地進行改進。
單元測試的平衡藝術
在單元測試領域中,一個常見的問題是測試的範圍與深度。測試太少,無法提供足夠的保障;測試太多,又會增加維護成本。我認為理想的策略是:
- 專注測試公開介面,確保對外行為符合規範
- 針對複雜或易出錯的內部邏輯編寫補充測試
- 避免對簡單的實作細節過度測試
實際開發中,測試經常會失敗,並消耗大量時間進行除錯和維護。因此,只測試真正需要測試的部分,反而能節省時間並提供相同或更好的軟體品質。確定什麼需要測試可以透過分析測試覆寫率、明確規範需求,並移除不必要的測試(前提是不會導致破壞性變更)。
透過良好的測試,我們可以自信地進行徹底重構。但過度測試會使軟體變得僵化,我們將浪費時間在管理測試上。結合自動化測試工具(如根據屬性的測試、模糊測試)和程式碼覆寫率分析,能在不需要超能力的情況下,為我們提供高品質和靈活性。
重構工具:讓程式碼變得更好的幫手
擁有完善的測試和乾淨的API後,我們可以開始改進軟體的內部結構。重構的難度因任務而異,但有幾個工具可以讓這個過程更加順暢。
在討論具體工具前,讓我們先將重構任務分類別
- 格式調整 - 調整空白和重新排列符號以提高可讀性
- 重新命名 - 改變變數、符號、常數的名稱
- 重新定位 - 將程式碼從原始碼樹中的一個位置移動到另一個位置,可能移至不同的crate
- 重寫 - 完全重寫程式碼段落或演算法
格式調整:讓程式碼更易讀
對於程式碼格式調整,首選工具是rustfmt。使用rustfmt,你幾乎不需要手動重新格式化Rust程式碼。rustfmt可根據你的偏好進行設定,使用起來非常簡單,只需執行cargo fmt
即可,也能透過rust-analyzer直接整合到你的編輯器或IDE中。
重新命名:人工智慧所有參考
重新命名在某些複雜情況下可能是一項棘手的任務。大多數程式碼編輯器都包含某種查詢和替換工具,但這不總是進行大型重構的最佳方式。正規表示式非常強大,但有時我們需要更具上下文具。
rust-analyzer
工具可以人工智慧命名符號,並提供結構化搜尋和替換功能。在VS Code中,可以透過選擇符號並按F2,或使用上下文「重新命名符號」選項來重新命名符號。
使用rust-analyzer
的結構化搜尋和替換功能,可以透過命令面板或增加帶有替換字串的註解來完成。替換預設應用於整個工作區,這使重構變得輕而易舉。rust-analyzer
會解析語法樹以找到比對項,並以不會引入語法錯誤的方式對表示式、類別、路徑或條目執行替換。只有在結果有效的情況下才會應用替換。
例如,考慮之前章節中的Mutex
守護範例,我們可以使用$m.lock() => Mutex::lock(&$m)
替換,將MUTEX.lock()
呼叫形式改為Mutex::lock(&MUTEX)
形式。這兩種形式在功能上是等價的,但有些人可能更喜歡後者。結構化搜尋和替換是上下文如在上述範例中,我只需指定Mutex::lock()
而非std::sync::Mutex::lock()
。由於第4行的use std::sync::Mutex
陳述式,rust-analyzer
知道我要尋找的是std::sync::Mutex::lock()
。
重新定位:組織程式碼結構
截至撰寫本文時,rust-analyzer
尚未提供重新定位或移動程式碼的功能。例如,如果你想將結構體及其方法移至不同的檔案或模組,你需要手動完成此過程。
雖然我通常不推薦非社群專案,但值得一提的是IntelliJ IDE的Rust外掛提供了用於重新定位程式碼的移動功能(以及許多與rust-analyzer
相當的其他功能)。這個外掛專用於IntelliJ,據我所知不能與其他編輯器一起使用,不過它是開放原始碼的。
重寫:確保新舊程式碼行為一致
如果你發現需要重寫大量程式碼或單個演算法,測試新程式碼是否與舊程式碼完全相同的絕佳方法是使用proptest
套件,我們在本章前面已經討論過。
讓我們看一個FizzBuzz演算法的實作及其相應測試:
fn fizzbuzz(n: i32) -> Vec<String> {
let mut result = Vec::new();
for i in 1..(n + 1) {
if i % 3 == 0 && i % 5 == 0 {
result.push("FizzBuzz".into());
} else if i % 3 == 0 {
result.push("Fizz".into());
} else if i % 5 == 0 {
result.push("Buzz".into());
} else {
result.push(i.to_string());
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fizzbuzz() {
assert_eq!(fizzbuzz(3), vec!["1", "2", "Fizz"]);
assert_eq!(fizzbuzz(5), vec!["1", "2", "Fizz", "4", "Buzz"]);
assert_eq!(
fizzbuzz(15),
vec![
"1", "2", "Fizz", "4", "Buzz", "Fizz", "7", "8", "Fizz",
"Buzz", "11", "Fizz", "13", "14", "FizzBuzz"
]
)
}
}
這段程式碼實作了經典的FizzBuzz問題:對於1到n的每個數字,如果能被3整除則輸出"Fizz",能被5整除則輸出"Buzz",同時能被3和5整除則輸出"FizzBuzz",否則輸出數字本身。實作採用了簡單的條件判斷,並使用Vec
現在,我們對這個演算法非常有信心,但想嘗試寫一個不同版本的程式碼。我們可以使用HashMap實作一個新版本:
fn better_fizzbuzz(n: i32) -> Vec<String> {
use std::collections::HashMap;
let mappings = HashMap::from([(3, "Fizz"), (5, "Buzz")]);
let mut result = vec![String::new(); n as usize];
let mut keys: Vec<&i32> = mappings.keys().collect();
keys.sort();
for i in 0..n {
for key in keys.iter() {
if (i + 1) % *key == 0 {
result[i as usize].push_str(mappings.get(key)
.expect("couldn't fetch mapping"));
}
}
if result[i as usize].is_empty() {
result[i as usize] = (i + 1).to_string();
}
}
result
}
這個改進版的FizzBuzz採用了更靈活的方法。它建立了一個對映表,將除數(3和5)對映到對應的字元串(“Fizz"和"Buzz”)。然後,它預先分配結果向量,並對每個數字檢查每個除數。如果數字能被除數整除,就在結果字元串後面附加相應的值。這種方法更具可擴充套件性,因為可以輕鬆增加新的除數和對應字元串。最後,如果沒有比對任何除數,則使用數字本身作為字元串。
我們的新實作有點複雜,雖然它透過了所有測試案例,但我們對它的工作正確性信心不足。這就是proptest
發揮作用的地方:我們可以使用proptest
生成測試案例,並將它們與原始實作進行比較:
use proptest::prelude::*;
proptest! {
#[test]
fn test_better_fizzbuzz_proptest(n in 1i32..10000) {
assert_eq!(fizzbuzz(n), better_fizzbuzz(n))
}
}
這個屬性測試定義了一個隨機測試,對於1到9999範圍內的任意整數n,它斷言原始的fizzbuzz
函式和新的better_fizzbuzz
函式產生相同的結果。這種方法讓我們能夠自動測試大量輸入,而不必手動編寫每個測試案例。如果在任何輸入上兩個函式產生不同結果,測試將失敗。這是確保重寫的程式碼保持相同行為的強大方式。
程式碼覆寫率:評估測試效果的關鍵指標
程式碼覆寫率分析是評估測試品質和有效性的重要工具。在Rust中,我們可以使用名為Tarpaulin的套件自動生成程式碼覆寫率報告,它以Cargo命令的形式提供。
安裝方法很簡單:
cargo install cargo-tarpaulin
安裝後,就可以開始使用這個強大的工具來分析你的程式碼覆寫率了。
覆寫率分析的價值
當我剛開始使用程式碼覆寫率工具時,我犯了一個常見的錯誤:過度關注覆寫率百分比。追求100%的覆寫率聽起來很誘人,但實際上這可能會導致低品質的測試,只為了增加覆寫率而非真正測試功能。
覆寫率分析的真正價值在於:
- 識別完全未測試的程式碼區域
- 發現條件分支中缺失的測試路徑
- 幫助確定測試優先順序,聚焦於複雜與關鍵的部分
我建議將覆寫率視為診斷工具,而非績效指標。一個擁有80%高品質、有針對性測試覆寫的專案,通常比擁有100%低品質測試覆寫的專案更健壯。
覆寫率與重構的關係
程式碼覆寫率與重構之間存在密切關係。高覆寫率提供了重構的信心,而重構過程又可能揭示測試中的盲點。我發現這種迴圈非常有益:
- 分析覆寫率,找出測試不足的區域
- 增加測試覆寫關鍵區域
- 進行重構以改程式式碼
- 重新分析覆寫率,發現新的測試機會
這種迭代方法不僅提高了程式碼品質,還增強了對系統行為的理解。
平衡重構與測試的藝術
在軟體開發過程中,找到重構與測試之間的平衡點是一門藝術。過度測試會導致系統僵化,任何小變動都需要修改大量測
程式碼覆寫率與測試策略:提升 Rust 程式品質
在開發穩健的 Rust 應用程式時,測試策略是確保軟體品質的關鍵環節。我在多個專案中發現,結合程式碼覆寫率分析與不同層次的測試方法,能大幅提高程式碼的可靠性。本文將探討如何使用 Rust 的覆寫率工具以及實作有效的整合測試。
利用 Tarpaulin 生成覆寫率報告
Rust 生態系統提供了強大的工具來分析測試覆寫率,其中 cargo-tarpaulin
是我經常使用的工具。要生成 HTML 格式的本地覆寫率報告,只需執行:
cargo tarpaulin --out Html
這將產生詳細的覆寫率報告,顯示哪些程式碼行已被測試覆寫,哪些尚未被測試到。
此命令啟動 tarpaulin 工具,它會執行專案中的所有測試並追蹤哪些程式碼行在測試過程中被執行。--out Html
引數指示 tarpaulin 將結果輸出為 HTML 格式,方便視覺化檢查。這種報告對於識別測試盲點非常有價值。
覆寫率報告的解讀與應用
覆寫率報告提供了兩個關鍵資訊:
- 總體覆寫率百分比 - 顯示已測試的程式碼比例
- 每個檔案的詳細覆寫率 - 突顯哪些區域需要額外測試
理想情況下,像 lib.rs
這樣的核心檔案應該達到接近 100% 的覆寫率,表示每一行程式碼都被測試執行過。然而,我發現在實際專案中,追求 100% 覆寫率可能會適得其反。
覆寫率追蹤與整合
覆寫率報告不僅可以在本機檢查,還可以整合到 CI/CD 系統中,以便隨時間追蹤覆寫率變化。對於開放原始碼專案,可以使用以下服務的免費方案:
- Codecov (https://about.codecov.io/)
- Coveralls (https://coveralls.io/)
這些服務能夠追蹤覆寫率變化、與 GitHub 提取請求整合,並方便測量進度。例如,dryoc
這個 crate 就使用了 Codecov。
關於覆寫率的反思
經過多個專案的實踐,我認為程式碼覆寫率是一個有用但不應過度依賴的指標。達到 100% 覆寫率不應該成為目標,因為:
- 某些情況下,測試每一行程式碼幾乎不可能
- 覆寫率資料主要用於衡量是否隨時間改善,或至少沒有惡化
- 覆寫率本身是一個武斷的指標,不具有定性實質
正如伏爾泰所說:“完美是好的敵人”。在追求高覆寫率時,我們更應關注測試的品質而非數量。
整合測試與單元測試的比較
在上一部分討論了單元測試,現在讓我們深入瞭解整合測試,以及它與單元測試的區別。這兩種測試策略都是提高軟體品質的強大工具,通常一起使用但目標略有不同。
整合測試與單元測試的根本差異
整合測試是從公共介面測試個別模組或模組群組。相比之下,單元測試是測試軟體中最小可測試元件,有時包括私有介面。公共介面是那些對軟體外部消費者公開的介面,例如公共程式函式庫或命令列應用程式中的 CLI 命令。
在 Rust 中,整合測試與單元測試幾乎沒有共同點:
- 整合測試位於主原始碼樹之外
- Rust 將整合測試視為獨立的 crate
- 整合測試只能存取公開的函式和結構
快速排序演算法實作
讓我們實作一個泛型版本的快速排序演算法,這是許多電腦科學研究中常見的排序方法:
pub fn quicksort<T: std::cmp::PartialOrd + Clone>(slice: &mut [T]) {
if slice.len() < 2 {
return;
}
let (left, right) = partition(slice);
quicksort(left);
quicksort(right);
}
fn partition<T: std::cmp::PartialOrd + Clone>(
slice: &mut [T]
) -> (&mut [T], &mut [T]) {
let pivot_value = slice[slice.len() - 1].clone();
let mut pivot_index = 0;
for i in 0..slice.len() {
if slice[i] <= pivot_value {
slice.swap(i, pivot_index);
pivot_index += 1;
}
}
if pivot_index < slice.len() - 1 {
slice.swap(pivot_index, slice.len() - 1);
}
slice.split_at_mut(pivot_index - 1)
}
上方程式碼實作了快速排序演算法的兩個關鍵部分:
quicksort()
函式是公開的(使用pub
關鍵字標記),可供外部呼叫partition()
函式是私有的,只能在本模組記憶體取
quicksort()
函式使用泛型實作,可以對任何實作了 PartialOrd
和 Clone
特徵的類別進行排序。演算法遵循經典的快速排序流程:
- 如果切片長度小於 2,則直接回傳(基本情況)
- 否則,對切片進行分割槽,獲得左右兩部分
- 遞迴對左右兩部分進行排序
partition()
函式實作了快速排序中的分割槽邏輯,選擇最後一個元素作為基準值,並重新排列陣列使得小於等於基準值的元素位於左側,大於基準值的元素位於右側。
Rust 中的整合測試結構
整合測試位於專案頂層的 tests
目錄中,並由 Cargo 自動發現。一個小型函式庫 src/lib.rs
中)和單個整合測試的目錄結構如下:
.
├── Cargo.lock
├── Cargo.toml
├── src
│ └── lib.rs
└── tests
└── quicksort.rs
測試函式標記為 #[test]
屬性,將由 Cargo 自動執行。整合測試作為單獨的 crate 處理,可以透過在 tests
目錄中建立目錄來擁有多個獨立的 crate 集合。
整合測試例項
以下是快速排序實作的整合測試範例:
use quicksort::quicksort;
#[test]
fn test_quicksort() {
let mut values = vec![12, 1, 5, 0, 6, 2];
quicksort(&mut values);
assert_eq!(values, vec![0, 1, 2, 5, 6, 12]);
let mut values = vec![1, 13, 5, 10, 6, 2, 0];
quicksort(&mut values);
assert_eq!(values, vec![0, 1, 2, 5, 6, 10, 13]);
}
這段整合測試程式碼匯入了公共 quicksort
函式並進行測試。測試函式中:
- 建立了含有未排序整數的向量
- 將這些向量傳遞給
quicksort
函式進行排序 - 使用
assert_eq!
巨集證排序結果是否符合預期
注意,整合測試只能存取公開的 API(這裡是 quicksort
函式),而不能存取私有的 partition
函式。
對比單元測試
相比之下,單元測試可以測試私有函式。以下是同一快速排序實作的單元測試:
#[cfg(test)]
mod tests {
use crate::{partition, quicksort};
#[test]
fn test_partition() {
let mut values = vec![0, 1, 2, 3];
assert_eq!(
partition(&mut values),
(vec![0, 1, 2].as_mut_slice(), vec![3].as_mut_slice())
);
let mut values = vec![0, 1, 2, 4, 3];
assert_eq!(
partition(&mut values),
(vec![0, 1, 2].as_mut_slice(), vec![3, 4].as_mut_slice())
);
}
#[test]
fn test_quicksort() {
let mut values = vec![1, 5, 0, 6, 2];
quicksort(&mut values);
assert_eq!(values, vec![0, 1, 2, 5, 6]);
let mut values = vec![1, 5, 10, 6, 2, 0];
quicksort(&mut values);
assert_eq!(values, vec![0, 1, 2, 5, 6, 10]);
}
}
單元測試與整合測試的主要區別在於:
- 單元測試位於與原始碼相同的檔案中,而整合測試位於專用的
tests
目錄 - 單元測試可以測試非公開函式(如
partition
),而整合測試只能測試公開函式 - 單元測試可以存取模組內的所有內容,而整合測試僅能存取公開 API
雖然這個例子中單元測試和整合測試看起來相似,但它們有不同的目的和範圍。
為何同時需要兩種測試?
即使單元測試已經覆寫了功能,仍然應該進行整合測試,原因在於:
- 整合測試模擬外部使用者何使用程式函式庫. 整合測試確保公開 API 按照預期運作
- 整合測試驗證不同元件之間的互動
透過從外部視角測試,整合測試幫助確保我們的程式函式庫用程式對於下游使用者常工作。不同於單元測試,整合測試只能存取公開介面,這迫使我們以與實際使用者同的方式編寫測試。
應對變化中的生態系統
Rust 不斷改進和更新,無論是在語言本身、核心函式庫是 Rust 生態系統中的所有 crate。雖然走在技術前沿很好,但這也帶來了一些挑戰,特別是在維護向前和向後相容性方面。
測試在持續維護中的作用
我發現單元測試和整合測試在持續維護中扮演著重要角色,特別是在面對不斷變化的目標時。雖然可能會有固定依賴版本並避免更新的誘惑,但長期來看這會弊大於利,尤其是當依賴相互交織時。
即使只有少量測試,也能在很大程度上幫助檢測迴歸問題,特別是來自第三方函式庫或預料之外的語言變化。在我的經驗中,良好的測試套件是應對 Rust 快速發展的最佳保障。
整合測試的實踐思考
經過多個專案的實踐,我總結了一些關於 Rust 整合測試的關鍵考量:
- 模擬真實使用場景:整合測試應該模仿實際使用者如何與您的函式庫用程式互動
- 關注公共 API:確保您的公共 API 行為符合檔案和預期
- 測試邊界條件:特別是在處理使用者入的情況下
- 持續更新測試:隨著 API 變化更新測試,以確保它們仍然有效
- 平衡測試範圍:不要僅依賴整合測試或單元測試,兩者結合使用效果最佳
Rust 的強大、靜態類別系統、嚴格的編譯器和借用檢查器減輕了單元測試的負擔,因為不需要像其他語言那樣測試執行時類別錯誤。內建的測試功能雖然簡單,但有多個 crate 可以
整合測試:設計與實作的平衡藝術
測試驅動開發(TDD)曾經風靡一時,它根據先寫測試再寫程式的理念。雖然近年來TDD似乎不再那麼流行,但它提供的洞見對整合測試仍有重要價值。在開發過程中,我發現API設計的重要性往往與測試本身同等關鍵。無論你是在開發函式庫令列應用程式,還是網頁、桌面或行動應用,軟體的人體工學都至關重要。
整合測試的獨特視角
整合測試迫使我們從使用者的角度思考軟體,這與單元測試的視角有本質區別。在撰寫整合測試時,我們需要考慮的不僅是演算法的正確性或邏輯實作,更要關注軟體的使用體驗。
整合測試和單元測試並非互斥關係,它們應該互相補充。整合測試不應該以與單元測試相同的方式編寫,因為我們測試的目標不同:
- 單元測試:專注於個別功能單元的正確性
- 整合測試:關注元件間的互動與整體功能
從使用者視角出發的設計
整合測試不僅是驗證程式碼是否正常運作的方式,更是測試軟體使用體驗的重要工具。在開發過程中,我們很容易陷入隧道視野,只關注當前實作的細節而忽略整體設計。整合測試則提供了一個全域視角。
我在開發dryoc
函式庫遇到過這個問題。當時我過於專注於一些選用功能的實作,直到撰寫整合測試時才發現介面設計存在嚴重問題。最終不得不大幅重構設計,以提升函式庫用性。
至於是否應該在編寫函式庫用程式前先撰寫整合測試?這並非我常用的實踐,但也不認為這是錯誤的方法。關鍵在於保持設計的彈性,並勇於重構。正如建築師富勒所言:
“當我解決問題時,從不考慮美感;但當完成後,如果解決方案不夠美觀,我就知道它是錯的。” —— R. Buckminster Fuller
最佳化PI設計:從測試中獲得的啟示
讓我們以前面的快速排序演算法為例,看看如何透過整合測試改進介面設計。目前我們有一個獨立的quicksort()
函式,它接受一個切片作為輸入。這種設計可行,但我們可以透過建立特徵(trait)使程式碼更符合Rust風格。
pub trait Quicksort {
fn quicksort(&mut self) {}
}
impl<T: std::cmp::PartialOrd + Clone> Quicksort for [T] {
fn quicksort(&mut self) {
quicksort(self);
}
}
這段程式碼定義了一個名為Quicksort
的公共特徵,包含一個方法quicksort()
。我選擇命名為quicksort()
而非sort()
,以避免與Rust標準函式庫片和Vec已有的sort()
方法衝突。
接著,我們為任何實作了PartialOrd
和Clone
特徵的類別T的切片[T]
實作這個特徵。這裡的實作只是簡單地呼叫我們之前定義的獨立quicksort
函式。
有了這個特徵後,我們可以更新測試程式碼:
#[test]
fn test_quicksort_trait() {
use quicksort_trait::Quicksort;
let mut values = vec![12, 1, 5, 0, 6, 2];
values.quicksort();
assert_eq!(values, vec![0, 1, 2, 5, 6, 12]);
let mut values = vec![1, 13, 5, 10, 6, 2, 0];
values.quicksort();
assert_eq!(values, vec![0, 1, 2, 5, 6, 10, 13]);
}
這個測試匯入了Quicksort
特徵,並直接在向量上呼叫quicksort()
方法,而不是使用quicksort(&mut values)
形式。這種寫法不僅更簡潔(少打4個字元),而與更符合Rust的使用習慣,提升了API的人體工學。
表面上看,程式碼差異不大,但這種語法糖使介面更加直觀與符合Rust的慣用法。呼叫arr.quicksort()
替代quicksort(&mut arr)
不僅看起來更優雅,還不需要明確指定可變借用。
內建整合測試與外部整合測試的取捨
Rust的內建整合測試機制能滿足大多數需求,但並非萬能。在某些情況下,外部整合測試工具可能更為適合。例如,測試HTTP服務時,像curl或HTTPie這類別乎無處不在的工具可能是更好的選擇。這些工具不是Rust專用的,而是在系統層面而非語言層面運作的通用工具。
對於用Rust編寫的命令列應用,我發現使用Rust編寫整合測試並不總是最佳方案。Rust專為安全性和效能而設計,而測試框架通常不需要這些特性,只需要正確即可。在許多情況下,使用Bash、Ruby或Python指令碼寫整合測試會比使用Rust程式更簡單。
決定是否使用Rust進行整合測試時,需要權衡所需時間與複雜度。動態指令碼言對非關鍵應用提供了許多優勢,即使你是Rust工作者,通常也能以較少的努力快速實作功能。
然而,僅使用Rust進行整合測試也有一個重大優勢:你可以在任何支援Rust的平台上執行測試,除了Rust工具鏈外無需其他外部工具。這在受限環境中特別有優勢。此外,如果你發現Rust是你最具生產力的語言,那麼使用它進行整合測試也完全合理。
Rust生態系中的整合測試工具
大多數用於單元測試的工具和函式庫適用於整合測試。不過,有一些專門為整合測試設計的函式庫工作變得更輕鬆。
使用assert_cmd測試命令列應用
對於測試命令列應用,assert_cmd
函式庫絕佳選擇,它讓執行命令並檢查結果變得非常簡單。讓我們為快速排序實作建立一個命令列介面,該介面從命令列引數中排序整數:
use std::env;
fn main() {
use quicksort_proptest::Quicksort;
let mut values: Vec<i64> = env::args()
.skip(1)
.map(|s| s.parse::<i64>().expect(&format!("{s}: bad input: ")))
.collect();
values.quicksort();
println!("{values:?}");
}
這個程式從命令列讀取引數(跳過第一個引數,它永遠是程式名稱),將每個值(字串)解析為i64類別,然後收集到一個向量中。接著對向量進行排序,最後輸出排序後的結果。
我們可以透過執行cargo run 5 4 3 2 1
來測試,這將輸出[1, 2, 3, 4, 5]
。
現在,讓我們使用assert_cmd
撰寫一些測試:
use assert_cmd::Command;
#[test]
fn test_no_args() -> Result<(), Box<dyn std::error::Error>> {
let mut cmd = Command::cargo_bin("quicksort-cli")?;
cmd.assert().success().stdout("[]\n");
Ok(())
}
這個測試函式使用assert_cmd
函式庫試我們的命令列應用。它回傳一個Result
類別,允許我們使用?
運算元簡化錯誤處理。測試建立了一個指向我們的quicksort-cli
二進位檔案的命令,然後斷言該命令成功執行並輸出了[]\n
(空陣列加換行符)。在沒有提供引數的情況下,我們期望排序一個空陣列,結果應該是空陣列。
這個測試方法的優點在於它測試的是實際編譯後的二進位檔案,完全模擬了最終使用者使用場景。這種黑盒測試方法能夠驗證整個應用流程,從命令列引數解析到最終輸出。
整合測試的最佳實踐
在開發過程中,我總結了一些整合測試的最佳實踐:
1. 從使用者視角設計測試
整合測試應該模擬真實使用者的行為。不要關注內部實作細節,而是關注軟體的對外行為。這種方法能幫助你發現單元測試可能忽略的問題,比如元件間的互動錯誤或使用體驗問題。
2. 測試真實環境
盡可能在接近生產環境的條件下進行測試。如果你的應用需要與資料函式庫部API互動,考慮使用測試容器或模擬服務來模擬這些依賴。
3. 平衡測試範圍與執行速度
整合測試通常比單元測試執行得慢,因為它們測試的是較大的系統部分。識別關鍵路徑和核心功能,優先為這些部分編寫整合測試。
4. 使用適合的工具
選擇適合問題領域的測試工具。對於HTTP API,可能是專用的HTTP客戶端;對於命令列工具,可能是shell指令碼專用測試函式庫要被語言或技術限制,選擇最有效的工具。
5. 持續重新評估API設計
透過整合測試獲得的反饋來改進API設計。如果測試編寫困難,這通常表明API設計存在問題。優秀的API自然易於測試。
整合測試與API設計的相輔相成
整合測試不僅是驗證程式碼的工具,更是改進API設計的寶貴機會。當我們從使用者角度編寫測試時,會自然而然地思考如何使API更直觀、更易用。
在Rust中,特徵系統為我們提供了強大的工具來設計富有表現力與符合語言習慣的API。透過像前面展示的Quicksort
特徵這樣的方法,我們可以建立既符合Rust風格又易於使用的介面。
整合測試與API設計是相輔相成的過程。好的API設計使整合測試編寫變得容易,而編寫整合測試又能揭示API設計中的缺陷。這種反饋迴圈是開發優質軟體的關鍵。
在我的開發實踐中,我發現整合測試是發現設計問題的最佳方式之一。當我嘗試從外部使用自己開發的函式庫具時,那些設計中的不便之處會變得異常明顯。這種「吃自己的狗糧」的方法能夠有效提升軟體的整體品質。
整合測試不應該是開發過程中的事後考慮,而應該是核心實踐的一部分。當與精心設計的API結合時,它能確保你的軟體不僅功能正確,而與易於使用和維護。透過平衡技術實作與使用者經驗,我們能夠建立既強大又令人愉悅使用的軟體。
Rust整合測試的深度實踐
整合測試是確保應用程式各元件協同工作的關鍵環節。在Rust生態系統中,我們有豐富的工具和方法來實作有效的整合測試。這篇文章將帶你探索Rust整合測試的各種技術和最佳實踐,從基本的命令列測試到進階的模糊測試,幫助你構建更穩健的應用程式。
命令列應用的整合測試策略
在測試命令列工具時,我們需要模擬使用者的輸入並驗證輸出。Rust生態系統提供了一系列工具來簡化這個過程。
以下是一個使用assert_cmd
測試排序命令列工具的例子:
fn test_cli_well_known() -> Result<(), Box<dyn std::error::Error>> {
let mut cmd = Command::cargo_bin("quicksort-cli")?;
cmd.args(&["14", "52", "1", "-195", "1582"])
.assert()
.success()
.stdout("[-195, 1, 14, 52, 1582]\n");
Ok(())
}
這段測試程式碼在做什麼?我們使用Command::cargo_bin()
建立一個指向我們的排序命令列工具的命令例項。然後我們提供一組數字作為引數,執行命令,並使用assert
方法鏈來驗證幾件事:
- 命令成功執行(沒有當機或回傳錯誤碼)
- 輸出確實是已排序的數字列表,格式完全符合我們的預期
這裡的Ok(())
回傳值表示測試本身成功完成。()
是Rust的單位類別,可視為一個沒有值的佔位符,相當於零元素的元組。
根據檔案的測試夾具
直接在程式碼中硬編碼測試資料有時不夠靈活。一個更好的方法是使用檔案系統來儲存測試資料和預期結果。
首先,我們建立一個簡單的目錄結構來儲存測試夾具:
tests/fixtures
├── 1
│ ├── args
│ └── expected
├── 2
│ ├── args
│ └── expected
└── 3
├── args
└── expected
每個編號目錄包含兩個檔案:
args
: 包含命令列引數expected
: 包含預期輸出
然後,我們可以編寫一個測試,迭代每個目錄,讀取引數和預期結果,執行測試並檢查結果:
#[test]
fn test_cli_fixtures() -> Result<(), Box<dyn std::error::Error>> {
use std::fs;
// 讀取fixtures目錄中的所有專案
let paths = fs::read_dir("tests/fixtures")?;
for fixture in paths {
let mut path = fixture?.path();
// 讀取引數檔案
path.push("args");
let args: Vec<String> = fs::read_to_string(&path)?
.trim()
.split(' ')
.map(str::to_owned)
.collect();
// 讀取預期結果檔案
path.pop();
path.push("expected");
let expected = fs::read_to_string(&path)?;
// 執行命令並檢查結果
let mut cmd = Command::cargo_bin("quicksort-cli")?;
cmd.args(args).assert().success().stdout(expected);
}
Ok(())
}
這個測試函式做了什麼?它遍歷tests/fixtures
目錄中的每個子目錄,對每個子目錄:
讀取
args
檔案並將其內容解析為命令列引數向量trim()
移除尾部換行符split(' ')
按空格分割內容map(str::to_owned)
將每個&str
轉換為擁有所有權的String
collect()
收集結果到Vec<String>
讀取
expected
檔案取得預期輸出執行命令並驗證輸出與預期比對
這種方法的優點是測試資料與測試邏輯分離,便於維護和擴充套件。我在處理複雜的命令列工具時發現,這種根據檔案的測試夾具特別有用,因為它允許非工程師(如產品經理或品質保證團隊)也能輕鬆增加新的測試案例。
結合屬性測試與整合測試
前面我們討論了特定輸入的測試,但為了更全面地測試我們的排序實作,可以結合屬性測試。
以下是使用proptest
對排序實作進行整合測試的例子:
use proptest::prelude::*;
proptest! {
#[test]
fn test_quicksort_proptest(
vec in prop::collection::vec(prop::num::i64::ANY, 0..1000)
) {
use quicksort_proptest::Quicksort;
// 使用標準函式庫作為參考
let mut vec_sorted = vec.clone();
vec_sorted.sort();
// 使用我們的快速排序實作
let mut vec_quicksorted = vec.clone();
vec_quicksorted.quicksort();
assert_eq!(vec_quicksorted, vec_sorted);
}
}
這個測試使用proptest
生成隨機資料來測試我們的快速排序實作。它做了什麼?
- 使用
prop::collection::vec
生成一個隨機整數向量,長度在0到1000之間 - 複製這個向量並使用Rust標準函式庫sort`方法進行排序,作為參考結果
- 再次複製原始向量,使用我們的
quicksort
實作進行排序 - 比較兩個排序結果是否相同
這種方法比固定測試案例更強大,因為它可以測試各種邊界情況,包括空向量、只有一個元素的向量、全部相同值的向量等。
值得注意的是,當在整合測試中使用自動生成測試資料的工具時,如果測試有外部副作用(如網路請求或寫入外部資料函式庫可能會產生意外後果。在設計測試時應考慮這點,透過在每次測試前後設定和拆卸整個環境,或提供其他方式在測試執行前後回傳到已知的良好狀態。
其他實用的整合測試工具
除了上面介紹的工具外,以下是一些值得一提的可以增強整合測試能力的Rust crate:
- rexpect - 自動化和測試互動式命令列應用程式
- assert_fs - 為消費或產生檔案的應用程式提供檔案系統夾具
在我的專案中,這些工具大大提高了測試效率和覆寫範圍。特別是assert_fs
,它提供了一種類別安全的方式來設定和驗證檔案系統操作,減少了測試中的錯誤和樣板程式碼
模糊測試:尋找隱藏的漏洞
模糊測試與屬性測試類別,但有一個關鍵區別:模糊測試使用隨機生成的資料測試程式碼這些資料不一定是有效的。在屬性測試中,我們通常將輸入限制為我們認為有效的值,因為測試所有可能的輸入通常不現實。
模糊測試則拋開了有效和無效的概念,只是簡單地將隨機位元組饋入你的程式碼看看會發生什麼。模糊測試在安全敏感的環境中特別受歡迎,因為你想了解當程式碼濫用時會發生什麼。
模糊測試的實際應用場景
一個常見的例子是公共資料源,如網路表單。網路表單可以填充來自任何來源的資料,這些資料需要被解析、驗證和處理。由於這些表單在外部環境中,沒有什麼可以阻止有人填入隨機資料。
例如,想像一個帶有使用者和密碼的登入表單,有人(或某些程式)可能會嘗試每種使用者和密碼組合,或最常見組合的列表,以取得系統存取權,要麼透過猜測正確的組合,要麼透過注入一些"魔術"位元組集合,導致內部程式碼敗並繞過認證系統。這類別洞出人意料地常見,模糊測試是緩解這些問題的一種策略。
模糊測試的主要問題是,測試每組可能的輸入可能需要不可行的時間,但實際上,你不一定需要測試每種輸入位元組組合來發現錯誤。你可能會驚訝於模糊測試能多快找到你認為堅不可摧的程式碼的錯誤。
使用libFuzzer進行模糊測試
我們將使用一個名為libFuzzer的函式庫模糊測試,它是LLVM專案的一部分。我們可以直接透過FFI使用libFuzzer,但更簡單的方法是使用cargo-fuzz
crate,它為libFuzzer提供了Rust API並為我們生成樣板程式碼
以下是一個看似可行但實際包含微妙錯誤的函式範例:
pub fn parse_integer(s: &str) -> Option<i32> {
use regex::Regex;
let re = Regex::new(r"^-?\d{1,10}$").expect("Parsing regex failed");
if re.is_match(s) {
Some(s.parse().expect("Parsing failed"))
} else {
None
}
}
這個函式接受一個字元串作為輸入,並將其解析為i32
整數,前提是它的長度在1到10位數字之間,並可選地以-
(負號)為字首。如果輸入不符合模式,則回傳None
。
乍看之下,這個函式似乎應該能夠處理任何輸入而不會導致程式當機。然而,這裡有一個微妙的錯誤:
- 正規表示式確保字元串只包含1-10位數字,可選帶負號
- 如果字元串比對這個模式,我們使用
parse()
將其轉換為i32
,並在失敗時使用expect()
- 問題在於,即使字元串符合正規表示式,它仍然可能表示一個超出
i32
範圍的數字(如"2147483648"
) - 當這種情況發生時,
parse()
會失敗,expect()
會導致程式當機
這種錯誤很容易被忽視,因為在正常使用中可能很少遇到邊界情況。這正是模糊測試能夠發揮作用的地方。