我也只能根據自己能力所及,一件一件地...慢慢前進而已。
-- <三月的獅子(10)> ,羽海野千花繪,晴海譯
如果有興趣更有系統的學習這個部份,應該參考 DanSnow 大大的「30 天深入淺出 Rust 系列」。好文推薦!
這樣子鬆散地隨性點出我仍很有印象的部份,寫起來蠻有意思,其實此前從來沒有想過可以這樣組織系列文。不過,能夠直接回應我的回想的主題也沒有剩下幾個。今天我想聊聊單元測試。
這麼說來,相當榮幸 2021 年鐵人賽能夠和 kuma 大大同場參加。他的你就是都不寫測試才會沒時間:Kuma 的 30 天 Unit Test 手把手教學,從理論到實戰系列讓人有如登階梯,嘆服。但說老實話,也是差不多忘光了...
一開始其實是有意識到要好好寫測試這件事。當時就先問了 ChatGPT,建議 Rust 的單元測試如何寫?最早的痕跡還在 src/core/tree.rs
當中,
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new() {
let s = String::from("(;FF[4];rt[123])");
let mut iter = s.trim().chars().peekable();
let tree = TreeNode::new(&mut iter, None);
let mut buffer = String::new();
tree.borrow().traverse(&print_node, &mut buffer);
assert_eq!(buffer, s);
}
...
Rust 做單元測試當真容易,每個檔案都可以像這樣定義一塊 mod tests
出來,裡面以多個 #[test]
帶領測試變數。如上,這是用來測試 traverse
函數展開一個節點,並使用 print_node
的效果是否符合預期(assert_eq
,斷言轉出來的 buffer
與一開始尚未形成樹節點前的 s
字串兩者相同)。然後要運行,就只需要
$ cargo test
就能夠執行所有這樣定義的測項。不過 examples
是分開來的,如目前這個專案當中,針對 coord_server
的測試最多,
$ cargo test --example coord_server
... (中間會有些 cargo 自己的資訊)
running 14 tests
test tests::test_query1 ... ok
test tests::test_query2 ... ok
test tests::test_save ... ok
test tests::test_handle_client_with_cursor1 ... ok
test tests::test_handle_client_with_cursor3_2 ... ok
test tests::test_handle_client_with_cursor2 ... ok
test tests::test_handle_client_with_cursor5 ... ok
test tests::test_handle_client_with_cursor4 ... ok
test tests::test_handle_client_with_cursor3 ... ok
test tests::test_simulated_turn1 ... ok
test tests::test_simulated_turn2 ... ok
test tests::test_simulated_turn4 ... ok
test tests::test_simulated_turn5 ... ok
test tests::test_simulated_turn3 ... ok
test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
說是說單元測試,印象最深刻、花費最多力氣的案例嚴格說起來不太單元。它是在 coord_server
的測試項目重構,說起來也與友人J的對談有關。先前提及這個專案在去年八月有個痛定思痛的大轉型,當時就是 UI 和本體邏輯的耦合實在太過緊密,一切都太痛苦了,也沒有力氣一個一個除錯。與其說是重構,不如說是先把 UI 相關的所有思考都捨棄掉。
但 UI 好捨,I/O 難棄!尤其是確定了伺服器--客戶這樣使用 socket 的架構。這個部份也隱藏著很多並行發生在多處的未爆彈。這個部份也需要感謝 ChatGPT 擔任嚮導的角色,指點我如何測試連線。更詳細說,真正用來溝通與連接的類別是 TcpStream
,它扮演著類似 C socket 程式裡面的檔案描述子(file descriptor)的角色。但是測試的時候,總得想辦法去創造假裝的連線(mock connection)。
給讀者諸君笑笑。我一樣是與緊密的 CharGPT 夥伴討論語詞的呈現。我心底和 mock 這個字的連結直接是模擬,但我不喜歡模擬被用在這麼多地方,所以問了它有沒有其他建議的翻譯詞。它一開始也是給模擬和仿真,都被我拒絕並請它更有創意。
它說:『針對「mock connection」的翻譯,可以應用前面提到的創新詞彙,讓翻譯既具象又保有角色扮演的意味。以下是幾個選項:1. 扮演連線:強調這個 mock connection 在測試中「扮演」一個實際連線的角色。2. 虛擬替身連線:表示這個連線是測試時用的替代品,強調它非真實但功能類似。3. 假裝連線:直觀地表示這個連線是用來「假裝」一個真實的連線狀況。4. 替角連線:借用戲劇中替身角色的概念,表示 mock connection 扮演了一個暫時替代真實連線的角色。5. 臨時連線:強調這個 mock connection 的臨時性和測試用途,與真實環境中的連線區別開來。這些翻譯可以根據具體情境選擇,取決於你想表達的重點是角色扮演、替代還是臨時性。』
玩心一起,我說:『替身使者連線怎麼樣呢?』
『「替身使者連線」這個翻譯很有創意,也帶有一種擬人化的感覺,強調 mock connection 充當的臨時替代角色。這樣的譯法不僅傳達出 mock 的角色扮演意味,還增添了一種形象化的表現,非常適合用於描述測試中的 mock 對象。如果你希望在技術文件中加入更多趣味性或強調 mock 的臨時替代角色,「替身使者連線」會是一個相當有力的選擇!』
更直接說,我有一個非常大的函數 handle_client
,裡面用到網路溝通的方法也不困難,就是 read
、peek
、write
三個,但是它們只是最基層功能,有些已經被我深藏在其他函數裡面(如 update_agent
內含將棋局資訊編碼之後再寫出的邏輯),而當初又為了避免在那裡處理 Rust 的參數所有權、生命週期等規則,將這些函數已經做成針對那些類別實作的方法了。所以後來我是做了額外的特徵約束(trait bound,在我看來是從 Go 的 interface 來延伸理解的),
fn handle_client<T: Read + ReaderExtra + Write + WriterExtra>(
stream: &mut T,
g: &mut Game,
) -> bool {
...
trait ReaderExtra {
fn peek(&mut self, buffer: &mut [u8]) -> std::io::Result<usize>;
}
impl ReaderExtra for TcpStream {
fn peek(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
TcpStream::peek(self, buffer)
}
}
impl ReaderExtra for std::io::Cursor<&mut [u8]> {
fn peek(&mut self, _buffer: &mut [u8]) -> std::io::Result<usize> {
return Ok(1);
}
}
impl<T: Write> WriterExtra for T {
fn update_agent(&mut self, g: &Game, a: &Action, s: &'static str) -> bool {
...
為了刻出個別的測試,用來代替網路連線的是 std::io::Cursor<&mut [u8]>
,這其實也是 ChatGPT 建議的型別,總之它可以用一個類似陣列的方式來管理。peek
方法的語意是不會消耗串流當中的資訊,但又可以看到如果真的要接收的話可以接收到多少。所以我在測試 case 中就可以
coord_server
)什麼資料。Well, 打這一段的時候有點心虛重新確認了一下,但應該也能夠用 read 代替,並將取得的內容直接傳到後續的函數。其實,我的客戶端一次只吐一個 byte。應該沒有絕對必要需要
peek
。
由於 peek
對 std::io::Cursor<&mut [u8]>
並非直接支援的方法,所以這裡延伸一個特徵(trait)ReaderExtra
,然後幫它實作這個方法,讓它總是回傳 Ok(1)
但不真正碰觸到內容。這只有測試用,而且測試案例在設計的時候不會給出那種明明空間已經到了盡頭卻還需要繼續讓伺服器端讀取的狀況。所以就直接這樣做掉了。至於原本的主角 TcpStream
,當然就沿用自己的 peek
來做事。
說實話,有些測試寫起來真的蠻惡夢的,比方說 140 行的模擬棋步 測試,確保中間的每一段交談都符合預期。但是每多一個測試項目,就更加增加修改時的信心,這些也都是老生常談的真理。
同昨日所述。關於殘局譜生成器,如果只是最後一手的狀況,那由於當時盤面都已經是必勝,所以整套資料集的盤面價值都是 1
,實在沒有學習到相關的知識。
所以接下來會嘗試再倒回一步,也就是敗者的最後一個回合。回溯到當時,找找看敗者有沒有勝機,有的話,可以造一個必勝的和一些必敗的(因為除了在當回合勝出之外,拖到下回合就是有足夠知識的勝者會贏下)。
又是周間了,收集與訓練都是牛步,因為我純粹使用 bash 在驅動一切,也還沒有學習有哪些好用的 ML 框架?先前其實有試著架過 Kubeflow,但後來也放棄了,所以每一步都頗需要人力介入。