Go 測試的 20 個實用建議

2023 年 11 月初,Go 語言技術負責人 Russ Cox 在 GopherCon Australia 2023[1] 大會上進行了題爲 “Go Testing By Example”[2] 的演講:

12 月初 Russ Cox 重新錄製了該演講內容的視頻,並在個人網站 [3] 上放了出來。這個演講視頻是關於如何編寫好的 Go 測試的,Russ Cox 介紹了 20 個實用建議,非常值得 Go 初學者甚至 Go 資深開發者學習並應用到實踐中。這裏是基於該視頻整理的文字稿 (可能並非逐字逐句),供廣大 Gopher 參考。

注:在 GopherCon Australia 2023,退休後暫定居澳大利亞的 Go 語言之父 Rob Pike 也做了一個名爲 “What We Got Right, What We Got Wrong” 的主題演講。在 Go 開源 14 年 [4] 之後,有很多事情值得思考。這個演講 “事後諸葛亮般地” 探討了 Go 迄今爲止取得的一些經驗教訓:不僅包括進展順利的方面,還包括本可以做得更好的方面。可惜目前該演講視頻或文字稿並未放出,我們也只能等待。


大家好!這是幾周前我在 GopherCon Australia 2023 進行的一次演講,演講的內容是關於如何編寫好的測試。

不過首先讓我們來思考一下爲什麼我們要編寫測試。一些有關編程的書中常講到:測試是爲了發現程序中的錯誤!比如 Brian W. Kernighan 和 Rob Pike 合著的《The Practice of Programming[5]》一書中講到:“測試是一種堅定的、系統地嘗試,旨在破壞你認爲可以正確運行的程序”。這是真實的。這就是爲什麼程序員應該編寫測試。但對於今天在這裏的大多數人來說,這不是我們編寫測試的原因,因爲我們不僅僅是程序員,我們是軟件工程師。什麼意思呢?我想說的是,軟件工程就是當你編程時增加時間和其他程序員時所發生的事情。編程意味着讓程序運行,你有一個問題需要解決,你編寫一些代碼,運行它,測試它,調試它,得到答案,你就完成了。這本已經相當困難了,而測試是該過程的重要組成部分。但軟件工程意味着你在長期與其他人一起開發的程序中完成所有這些工作,這改變了測試的性質。

讓我們先看一個對二分查找函數的測試:

如圖所示,這個函數接受一個有序 (sorted) 切片、一個目標值 (target) 和一個比較函數(cmp)。它使用二分搜索算法查找並返回兩個內容:第一,如果目標存在,則返回其索引(index),第二是一個布爾值,指示目標是否存在。

大多數二分查找算法的實現都有錯誤,這個也不例外。我們來測試一下。

下面是一個很好的二分搜索的交互式測試:

你輸入兩個數字 n 和 t,測試程序便創建一個包含 n 個元素的切片,其元素值按 10 倍增,然後程序在切片中搜索 t 並打印結果,然後你反覆重複這一過程。

這可能看起來不足爲奇,但有多少人曾經通過運行這種交互式測試程序來測試生產環境用的代碼 (production code)?我們所有人都這樣做過。當你獨自編程時,像這樣的交互式測試程序對於查找 bug 非常有用,到目前爲止代碼看起來可以正常工作。

但這個交互式測試程序只適合獨自編程時使用,如果你從事軟件工程,意味着你要長時間保持程序的運行,並與其他人合作,那麼這種類型的測試程序就不太有用了。

你需要一種每個人都可以在日常工作中運行的測試程序,可以在他們編寫代碼的同時運行,並且可以由計算機在每次代碼提交時自動運行。問題在於僅通過手動測試程序只能確保它在今天正常工作,而自動化、持續的測試可以確保它在明天和未來都可以正常工作,即使其他不熟悉這段代碼的人開始對其進行維護。並且我們要明確一點:那個不太熟悉代碼的人可能是指未來六個月甚至六週後的你。

這是一個軟件工程師的測試。你可以在不瞭解代碼工作原理的情況下運行它。任何同事或任何計算機都可以使用 "go test" 運行該測試,並可以立即知道該測試是否通過。我肯定你已經見過這樣的測試了。

軟件工程的理想是擁有能夠捕捉到後續可能出現的所有錯誤的測試。如果你的測試達到了這個理想狀態,那麼當你的所有測試都通過時,你應該可以放心地自動將你的代碼部署到生產環境中,這就是人們所稱的持續部署。如果你還沒有這樣做,如果這個想法讓你感到緊張,那麼你應該問問自己爲什麼。要麼你的測試已經足夠好,要麼它們還不夠好。如果它們足夠好,那爲什麼不這樣做呢?而如果它們不夠好,那就傾聽這些疑慮,並找出它們告訴你哪些測試被遺漏了。

幾年前,我正在爲新的 Go 官方網站 go.dev 編寫代碼。那時我們還在手動部署該網站,並且至少每週一次。我做的一項代碼變更在我的機器上運行正常,但在部署到生產環境後便無法正常工作了,這着實令人非常煩惱和尷尬。解決辦法是進行更好的測試和自動化的持續部署。現在,每當代碼庫中有新的提交時,我們使用一個 Cloud Build 程序來運行本地測試,並將代碼推送到一個全新的服務器,然後運行一些只能在生產環境中運行的測試。如果一切正常,我們會將流量打到新的服務器。這樣做改善了兩點。首先,我不再導致令人尷尬的網站宕機。其次,每個人都不再需要考慮如何部署網站。如果他們想做變更,比如修復拼寫錯誤或添加新的博客文章,他們只需發送更改請求,對其進行審覈、測試和提交,然後自動化流程會完成其餘工作。

要確信當其他人更改代碼時你的程序不會出錯,要確信只要測試通過就可以隨時將程序推送到生產環境,你需要一套非常好的測試。但是什麼樣的測試纔算是好的呢?

一般來說,使測試代碼優秀的因素與使非測試代碼優秀的因素是相同的:勤奮 (hard work)、專注(attention) 和時間 (time)。對於編寫優秀的測試代碼,我沒有什麼“銀彈式” 的或硬性的規則,就像編寫優秀的非測試代碼一樣。然而,我確實有一系列基於我們在 Go 上的良好實踐的建議,我將在這次演講中分享 20 個編寫優秀測試代碼的實用建議。

建議 1:讓添加新測試用例變得容易

這是最重要的建議。因爲如果添加一個新測試用例很困難,你就不會去做。在這方面,Go 已經提供了很好的支持。

上圖是函數 Foo 的一個最簡單的測試。我們專門設計了 Go 測試,使其非常容易編寫。沒有繁雜的記錄或儀式會妨礙你。在包級別的測試中,這已經相當不錯了,但在特定的包中,你可以做得更好。

我相信你已經瞭解了表驅動測試。我們鼓勵使用表驅動測試,因爲它們非常容易添加新的測試用例。這是我們之前看到的那個測試用例:假設我們只有這一個測試用例,然後我們想到了一個新的測試用例。我們根本不需要編寫任何新的代碼,只需要添加一行新的數據。如果目標是 “使添加新的測試用例變得容易”,那麼對於像這樣的簡單函數,向表中添加一行數據就足夠了。不過,這也引出了一個問題:我們應該添加哪些測試用例?這將引導我們來到下一個建議。

建議 2:使用測試覆蓋率來發現未經測試的代碼

畢竟,測試無法捕捉到未運行的代碼中的錯誤。Go 內置了對測試覆蓋率的支持。下面是它的樣子:

你可以運行 “go test -coverprofile” 來生成一個覆蓋率文件,然後使用 “go tool cover” 在瀏覽器中查看它。在上圖的顯示中,我們可以看到我們的測試用例還不夠好:實際的二分查找代碼是紅色的,表示完全未經測試。下一步是查看未經測試的代碼,並思考什麼樣的測試用例會使這些代碼行運行。

經過仔細檢查,我們只測試了一個空切片,所以讓我們添加一個非空的切片的測試用例。現在我們可以再次運行覆蓋率測試。這次我將用我寫的一個小命令行程序 “uncover” 來讀取覆蓋率文件。Uncover 會顯示未被測試覆蓋的代碼行。它不會給你網頁視圖那樣的全局視圖,但它可以讓你保持在一個終端窗口中。Uncover 向我們展示了只剩下一行代碼未被測試執行。這是進入切片的第二半部分的行,這是有道理的,因爲我們的目標是第一個元素。讓我們再添加一個測試,搜索最後一個元素。

當我們運行測試時,它通過了,我們達到了 100% 的覆蓋率。很棒。我們完成了嗎?沒有,這將引導我們到下一個實用建議。

建議 3:覆蓋率不能替代思考

覆蓋率對於指出你可能忽略的代碼部分非常有用,但機械工具無法替代對於高難度的輸入、代碼中的微妙之處以及可能導致代碼出錯的情況進行的實際思考。即使代碼擁有 100% 的測試覆蓋率,仍然可能存在 bug,而這段代碼就存在 bug。這個提示也適用於覆蓋率驅動的模糊測試 (fuzzing test)[6]。模糊測試只是嘗試通過代碼探索越來越多的路徑,以增加覆蓋率。模糊測試也非常有幫助,但模糊測試也不能替代思考。那麼這裏缺少了什麼呢?

需要注意的一點是,唯一一個無法找到目標的測試用例是一個空輸入切片。我們應該檢查在具值的切片中無法找到目標的情況。具體來說,我們應該檢查當目標小於所有值、大於所有值和位於值的中間時會發生什麼。所以讓我們添加三個額外的測試用例。

注意添加新測試用例是多麼容易。如果你想到一個你的代碼可能無法正確處理的情況,添加該測試用例應該儘可能簡單,否則你就會覺得麻煩而不去添加。如果太困難,你就不會添加。你還可以看到我們正在開始列舉這個函數可能出錯的所有重要路徑。這些測試對未來的開發進行了約束,以確保二分查找至少能夠正常工作。當我們運行這些測試時,它們失敗了。返回的索引 i 是正確的,但表示 target 是否找到的布爾值是錯誤的。所以讓我們來看看這個問題。

閱讀代碼,我們發現返回語句中的布爾表達式是錯誤的。它只檢查索引是否在範圍內。它還需要檢查該索引處的值是否等於 target 值。所以我們可以進行這個更改,如圖所示,然後測試通過了。現在我們對這個測試感到非常滿意:覆蓋率是良好的,我們也經過了深思熟慮。還能做什麼呢?

建議 4:編寫全面的測試

如果你能夠測試函數的每一個可能輸入,那就應該這樣做。但現實中可能無法做到,但通常你可以在一定約束條件下測試特定數量以內的所有輸入。下面是一個二分查找的全面測試:

我們首先創建一個包含 10 個元素的切片,具體來說就是從 1 到 19 的奇數。然後我們考慮該切片的所有可能長度的前綴。對於每個前綴,我們考慮從 0 到兩倍長度的所有可能目標,其中 0 是小於切片中的所有值,兩倍長度是大於切片中的所有值。這將詳盡地測試每個可能的搜索路徑,以及長度不超過我們的限制 10 的所有可能尺寸的切片。但是現在我們怎麼知道答案是什麼呢?我們可以根據測試用例的具體情況進行一些數學計算,但有一種更好、更通用的方法。這種方法是編寫一個與真正實現不同的參考實現。理想情況下,參考實現應該明顯是正確的,但它只需與真實實現採用不同的方法即可。通常,參考實現將是一種更簡單、更慢的方法,因爲如果它更簡單和更快,你會將其用作真正的實現。在這種情況下,我們的參考實現稱爲 slowFind。測試檢查 slowFind 和 Find 是否可以在答案上達成一致。由於輸入很小,slowFind 可以採用一個簡單的線性搜索。

通過生成所有可能的輸入並將結果與簡單的參考實現進行比較,這種模式非常強大。它做的一件重要的事情是覆蓋了所有基本情況,例如 0 個元素的切片、1 個元素的切片、長度爲奇數的切片、長度爲偶數的切片、長度爲 2 的冪的切片等等。大多數程序中的絕大多數錯誤都可以通過小規模的輸入進行重現,因此測試所有小規模的輸入非常有效。事實證明,這個全面測試通過了。我們的思考相當不錯。

現在,如果全面測試失敗,那意味着 Find 和 slowFind 不一致,至少有一個有 bug,但我們不知道是哪一個有問題。添加一個直接測試 slowFind 會有所幫助,而且很容易,因爲我們已經有了一個測試數據表。這是表驅動測試的另一個好處:可以使用這些表來測試多個實現。

建議 5:將測試用例與測試邏輯分開

在表驅動測試中,測試用例在表中,而處理這些測試用例的循環則是測試邏輯。正如我們剛纔所看到的,將它們分開可以讓你在多個上下文中使用相同的測試用例。那麼現在我們的二分查找函數完成了嗎?事實證明沒有,還有一個 bug 存在,這引導我們到下一個問題。

建議 6:尋找特殊情況

即使我們對所有小規模情況進行了全面測試,仍然可能存在潛在的 bug:

現在,這裏再次展示了代碼。還剩下一個 bug。你可以暫停視頻,花一些時間來查看它。

有人看出 bug 在哪裏了嗎?如果你沒有看到,沒關係。這是一個非常特殊的情況,人們花了幾十年的時間才注意到它。Knuth 告訴我們,儘管二分查找在 1946 年發表,但第一個正確的二分查找實現直到 1964 年才發表。但是這個 bug 直到 2006 年才被發現。

bug 是這樣的,如果切片中的元素數量非常接近 int 的最大值,那麼 i+j 會溢出,因此 i+j/2 就不是切片中間位置的正確計算方法了。這個 bug 於 2006 年在一個使用 64 位內存和 32 位整數的 C 程序中被發現,這個程序用於索引包含超過 10 億個元素的數組。在 Go 語言中,這種特定組合基本上不會發生,因爲我們要求使用 64 位內存時,也要使用 64 位整數,這正是爲了避免這種 bug。但是,由於我們瞭解到這個 bug,而且你永遠不知道你或其他人將來如何修改代碼,所以避免這個 bug 是值得的。

有兩種常見的修復方法可以避免數學計算溢出。速度稍快的方法是進行無符號除法。假設我們修復了這個問題。現在我們完成了嗎?不。因爲我們還沒有編寫測試。

建議 7:如果你沒有添加測試,那就沒有修復 bug

這句話在兩個不同的方面下都是正確的。

第一個是編程方面。如果你沒有進行測試,bug 可能根本沒有被修復。這聽起來可能很愚蠢,但你有多少次遇到過這種情況?有人告訴你有一個 bug,你立即知道修復方法。你進行了更改,並告訴他們問題已經修復。然後他們卻回來告訴你,不,問題還存在。編寫測試可以避免這種尷尬。你可以說,很抱歉我沒有修復你的 bug,但我確實修復了一個 bug,並會再次查看這個問題。

第二個是軟件工程方面,即 “時間和其他程序員” 的方面。bug 並不是隨機出現的。在任何給定的程序中,某些錯誤比其他錯誤更有可能發生。因此,如果你犯了一次這個錯誤,你或其他人很可能在將來再次犯同樣的錯誤。如果沒有測試來阻止它們,bug 就會重新出現。

現在,這個特定的測試很難編寫,因爲輸入範圍非常大,但即使測試很難編寫,這個建議仍然成立。實際上,在這種情況下,這個建議通常更爲正確。

爲了測試這種情況,一種可能性是編寫一個僅在 32 位系統上運行的測試,對兩千兆字節的 uint8 進行二分查找。但這需要大量的內存,並且我們現在已經沒有多少 32 位系統了。對於測試這種難以找到的 bug,通常還有更巧妙的解決方案。我們可以創建一個空結構體的切片,無論它有多長,都不會佔用內存。這個測試在一個包含 MaxInt 個空結構體的切片上調用 Find 函數,尋找一個空結構體作爲目標,但是它傳入了一個總是返回 - 1 的比較函數,聲稱切片元素小於目標。這將使二分查找探索越來越大的切片索引,從而導致溢出問題。如果我們撤銷我們的修復並運行這個測試,那麼測試肯定會失敗。

而使用了我們的修復後,測試通過了。現在 bug 已經修復了。

建議 8:並非所有東西都適合放在表中

這個特殊情況不適合放在表中,但這沒關係。但是很多東西確實適合放在表中。

這是我最喜歡的一個測試表之一。它來自 fmt.Printf 的測試用例。每一行都是一個 printf 格式、一個值和預期的字符串。真實的表太大了,無法放在幻燈片上,但這裏摘錄了一些表中的代碼行。

如果你仔細閱讀整個表,你會看到其中一些明顯是修復 bug 的內容。記住建議 7:如果你沒有添加測試,那就沒有修復 bug。表格使得添加這些測試變得非常簡單,並且添加這些測試可以確保這些 bug 不會再次出現。

表格是將測試用例與測試邏輯分離並且方便添加新的測試用例的一種方法,但有時你會有很多測試,甚至寫 Go 語法的開銷也是不必要的。例如,這裏是 strconv 包的一個測試文件,用於測試字符串與浮點數之間的轉換。你可能認爲編寫解析器來處理這個輸入太麻煩了,但一旦你知道了如何處理,其實並不需要太多工作,而且定義測試專用的小型語言實際上非常有用。

因此,我將快速介紹一下解析器,以展示它並不複雜。我們讀取文件,然後將其分割成行。對於每一行,我們計算錯誤消息的行號。切片元素 0 表示第 1 行。我們去掉行尾的任何註釋。如果行爲空白行,我們跳過它。到目前爲止,這是相當標準的樣板代碼。現在是重點。我們將行分割爲字段,並提取出四個字段。

然後根據類型字段在 float32 或 float64 的數學運算中進行轉換。myatof64 基本上是 strconv.ParseFloat64 的變體,不同之處在於它處理允許我們按照從論文中複製的方式編寫測試用例的十進制 p 格式。

最後,如果結果不是我們想要的,我們打印錯誤。這非常類似於基於表格的測試。我們只是解析文件,而不是遍歷表格。它無法放在一個幻燈片上,但在開發時它可以放在一個屏幕上。

建議 9:測試用例可以放在 testdata 文件中

測試不必都要放在源代碼中。

作爲另一個例子,Go 正則表達式包包含了一些從 AT&T POSIX 正則表達式庫複製過來的 testdata 文件。我不會在這裏詳細介紹,但我很感激他們選擇爲該庫使用基於文件的測試,因爲這意味着我可以重用 testdata 文件,將其用於 Go。這是另一種 ad-hoc 格式,但它易於解析和編輯。

建議 10:與其他實現進行比較

與 AT&T 正則表達式的測試用例進行比較有助於確保 Go 的包以完全相同的方式處理各種邊緣情況。我們還將 Go 的包與 C++ 的 RE2 庫進行比較。爲了避免需要編譯 C++ 代碼,我們以記錄所有測試用例的方式運行它,並將該文件作爲 testdata 提交到 Go 中。

在文件中存儲測試用例的另一種方法是使用成對的文件,一個用於輸入,一個用於輸出。爲了實現 go test -json,有一個名爲 test2json 的程序,它讀取測試輸出並將其轉換爲 JSON 輸出。測試數據是成對的文件:測試輸出和 JSON 輸出。

這是最簡短的文件。測試輸出位於頂部,它是 test2json 的輸入,應該生成底部的 JSON 輸出。以下是實現,展示了從文件中讀取測試數據的慣用方法。

我們首先使用 filepath.Glob 查找所有的 testdata。如果失敗或找不到任何文件,我們會報錯。否則,我們循環遍歷所有文件。對於每個文件,我們通過獲取基本文件名(不包括 testdata / 目錄名和文件後綴)來創建子測試名稱。然後我們用該名稱運行一個子測試。如果你的測試用例足夠複雜,每個文件一個子測試通常是有意義的。這樣,當一個測試用例失敗時,你可以使用 go test -run 只運行特定的文件。

對於實際的測試用例,我們只需要讀取文件,運行轉換器,並檢查結果是否匹配。對於檢查,我最開始使用了 bytes.Equal,但隨着時間的推移,編寫一個自定義的 diffJSON 函數來解析兩個 JSON 結果並打印實際差異的詳細說明變得更有價值。

建議 11:使測試失敗易讀

回顧一下,我們已經在二分查找中看到了這一點。

我認爲我們都同意粉色框不是一個好的失敗。但是黃色框中有兩個細節使得這些失敗尤爲出色。首先,我們在單個 if 語句中檢查了兩個返回值,然後在簡潔的單行中打印了完整的輸入和輸出。其次,我們不會在第一個失敗處停止。我們使用 t.Error 而不是 t.Fatal,以便執行更多的測試用例。結合起來,這兩個選擇讓我們可以看到每個失敗的完整細節,並在多個失敗中尋找模式。

回到 test2json,這是它的測試失敗的情況。它計算出哪些事件是不同的,並清晰地標記它們。重要的是,在你編寫測試時,你不必寫這種複雜的代碼。bytes.Equal 在開始時是可以的,並且可以專注於代碼。但是隨着失敗變得更加微妙,並且你發現自己花費太多時間只是閱讀失敗輸出,這是一個好的信號,它告訴你是時候花一些時間使其更易讀了。此外,如果確切的輸出發生更改並且你需要更正所有的測試數據文件,這種類型的測試可能會有點麻煩。

建議 12:如果答案可能會改變,編寫代碼來更新它們

通常的做法是在測試中添加一個 “-update” 標誌。這是 test2json 的更新代碼示例。

測試定義了一個新的 “-update 標誌”。當標誌爲 true 時,測試將計算的答案寫入答案文件,而不是調用 diffJSON。現在,當我們對 JSON 格式進行有意的更改時,“go test -update” 會更新所有答案。你還可以使用版本控制工具如 “git diff” 來審查更改,並在看起來不正確時撤銷更改。在談論測試文件的主題上,有時將一個測試用例分割成多個文件會很煩人。如果我今天編寫這個測試,我就不會這樣做。

建議 13: 使用 txtar 進行多文件測試用例

注:導入 txtar:import "golang.org/x/tools/txtar"

Txtar 是我們幾年前專門爲解決多文件測試用例問題而設計的一種新的存檔格式。其 Go 解析器位於 golang.org/x/tools/txtar 中,我還找到了用 Ruby、Rust 和 Swift 編寫的解析器。

Txtar 的設計有三個目標。首先,足夠簡單,可以手動創建、編輯和閱讀。其次,能夠存儲文本文件的樹形結構,因爲我們在 go 命令中需要這個功能。第三,能夠在 git 歷史記錄和代碼審查中進行良好的差異比較。其他的包括成爲完全通用的存檔格式、存儲二進制數據、存儲文件模式 (file mode)、存儲符號鏈接等都不是目標,因爲存檔文件(archived file) 格式往往變得十分複雜,而複雜性與第一個目標直接相矛盾。這些目標和非目標導致了一個非常簡單的格式。下面是一個示例:txtar 文件以註釋開頭。

本例中爲 "Here are some greetings.",然後通常會有零個或多個文件,每個文件由形如 "-- 文件名 --" 的行引入。這個存檔包含兩個單行文件,hello 和 g'day。就是這樣,這就是整個格式。沒有轉義,沒有引用,沒有對二進制數據的支持,沒有符號鏈接,沒有可能的語法錯誤,沒有複雜之處。下面是一個在測試數據中使用 txtar 文件的真實示例。

該測試數據用於計算差異的包:在這種情況下,註釋對於人們來說很有用,用於記錄正在進行的測試,然後在這個測試中,每個用例由兩個文件和它們的差異後面跟隨的兩個文件組成。

使用 txtar 文件幾乎和編寫它們一樣簡單。下面是我們之前查看的 diff 包的測試。

這是通常的基於文件的循環,但我們在文件上調用了 txtar.ParseFile。然後我們堅持認爲存檔包含三個文件,第三個文件的名稱爲 diff。然後我們對兩個輸入文件進行差異比較,並檢查結果是否與預期的差異匹配。

這就是整個測試。你可能已經注意到,在使用之前,文件數據會被傳遞給 "clean" 函數進行清理。clean 函數允許我們在不使 txtar 格式本身複雜化的情況下添加一些特定於 diff 的擴展。

第一個擴展處理以空格結尾的行,在差異中確實會出現這種情況。許多編輯器希望去除這些尾隨空格,因此測試允許在 txtar 的數據行末尾放置 ,並且函數會刪除該。在這個示例中,標記的行需要以一個空格結尾。

此外,txtar 要求文件中的每一行都以換行符結尾,但我們希望測試 diff 在不以換行符結尾的文件上的行爲。因此,測試允許在結尾處放置一個字面意義上的 “尖號 D”。clean 函數會刪除“尖號 D” 和其後的換行符。在這種情況下,'new'文件最終沒有最後的換行符,而 diff 正確報告了這一點。因此,儘管 txtar 非常簡單,你也可以輕鬆地在其上添加自己的格式調整。當然,重要的是要記錄這些調整,以便下一個參與測試的人能夠理解它們。

建議 14:對現有格式進行註解 (annotation) 來創建測試迷你語言

對現有格式進行註釋,比如在 txtar 中添加 $ 和尖號 D,是一個強大的工具。

這裏是對現有格式進行註釋的一個示例。這是 Go 類型檢查器 (type checker) 的一個測試。這是一個普通的 Go 輸入文件,但是期望的類型錯誤已經以 /ERROR/ 註釋的形式添加了進去。我們使用 /* 註釋,這樣我們就可以將它們放置在錯誤報告的確切位置上。測試運行類型檢查器,並檢查它是否在預期位置產生了預期的消息,並且沒有產生任何意外的消息。下面是類型檢查器的另一個示例。

在這個測試中,我們在通常的 Go 語法之上添加了一個 assert 註釋。這使我們能夠編寫常量算術的測試,就像這個例子一樣。類型檢查器已經計算了每個常量表達式的布爾值,所以檢查 assert 其實只是檢查常量是否被求值爲 true。下面是另一個帶有註釋的格式示例。

Ivy 是一個交互式計算器。你輸入程序,通常是簡單的表達式,它會打印出答案。測試用例是看起來像這樣的文件:未縮進的行是 Ivy 的輸入,縮進的行是註釋,指示 Ivy 應該打印出預期的輸出。編寫新的測試用例再也沒有比這更簡單的了。這些帶註釋的格式擴展了現有的解析器和打印器 (printer)。有時編寫自己的解析器和打印器是有幫助的。畢竟,大多數測試涉及創建或檢查數據,當你可以使用方便的形式處理數據時,這些測試總是可以更好。

建議 15:編寫解析器和打印器來簡化測試

這些解析器和打印器不一定是用於 testdata 中數據文件的獨立腳本。你也可以在常規的 Go 代碼中使用它們。

這是一個運行 deps.dev 代碼的一個測試片段。這個測試設置了一些數據庫錶行。它調用了一個使用數據庫並正在進行測試的函數。然後它檢查數據庫是否包含了預期的結果。Insert 和 Want 調用使用了一個專門爲這些測試編寫的用於數據庫內容的迷你語言。解析器就像它看起來的那樣簡單:它將輸入分割成行,然後將每行分割成字段。第一行給出了列名。就是這樣。這些字符串中的確切間距並不重要,但是如果它們都對齊,當然看起來更美觀。

因此,爲了支持這個測試,deps.dev 團隊還有一個專門爲這些測試編寫的代碼格式化程序。它使用 Go 標準庫解析測試源代碼文件。然後它遍歷 Go 語法樹,查找 Insert 或 Want 的調用。它提取字符串參數並將它們解析爲表格。然後它將表格重新打印爲字符串,將字符串重新插入語法樹中,並重新打印語法樹爲 Go 源代碼。這只是 gofmt 的一個擴展版本,使用了與 gofmt 相同的包。我這裏不會展示這些代碼,但代碼量其實不多。

解析器和打印器需要花費了一些時間來編寫。但現在,每當有人編寫一個測試時,編寫測試就更容易了。每當一個測試失敗或需要更新時,調試也更容易了。如果你正在進行軟件工程,收益將隨着程序員數量和項目生命週期的增加而擴大。對於 deps.dev 來說,已經花費在這個解析器和打印器上的時間已經多次節省了。或許更重要的是,因爲測試更容易編寫,你可能會寫更多的測試,這將導致更高質量的代碼。

建議 16:代碼質量受測試質量限制

如果你不能編寫高質量的測試,你將無法編寫足夠的測試,並且最終無法得到高質量的代碼。

現在我想向你展示一些我曾經參與的最高質量的測試,這些測試是針對 go 命令的測試。它們將我們到目前爲止看到的許多思想彙集在一起。這是一個簡單但真實的 go 命令測試。這是一個 txtar 輸入,其中包含一個名爲 hello.go 的文件。archive comment 是一個逐行簡單命令語言編寫的腳本。在腳本中,"env" 設置一個環境變量來關閉 Go module 機制。井號引入註釋。而 "go" 運行 go 命令,它應該運行 hello world。該程序應該將 hello world 打印到標準錯誤中。"stderr" 命令檢查前一個命令打印的標準錯誤流是否與正則表達式匹配。因此,這個測試運行 "go run hello.go" 並檢查它是否將 hello world 打印到標準錯誤中。

這裏是另一個真實的測試。請注意底部的 a.go 是一個無效的程序,因爲它導入了一個空字符串。第一行開頭的感嘆號是一個 "非" 操作符。NOT go list a.go 意味着 go list a.go 應該失敗。下一行的 "NOT stdout ." 表示標準輸出不應該有與正則表達式 "." 匹配的內容,也就是不應該打印任何文本。接下來,標準錯誤流應該有一個無效的導入路徑的消息。最後,不應該發生 panic。

建議 17:使用腳本可以編寫很好的測試

這些腳本使添加新的測試用例變得非常容易。

這是我們最小的測試用例:兩行代碼。最近我在破壞了 unknown command 的錯誤消息後添加了這個測試用例。總共,我們有超過 700 個這樣的腳本測試,從兩行到 500 多行不等。

這些測試腳本取代了一個更傳統的使用方法 (method) 的測試框架。這張幻燈片展示了其中一個真實的測試,前面是腳本編寫的測試用例,後面是等價的 Go 編寫的傳統測試代碼。細節並不重要,只需注意腳本要比傳統測試方法更容易編寫和理解。

建議 18:嘗試使用 rsc.io/script 來創建基於腳本的測試用例

距離我們創建 go 腳本測試已經過去了大約五年時間,我們對這個特定的腳本引擎非常滿意。Bryan Mills 花了很多時間爲它提供了一個非常好的 API,早在 11 月份,我將其發佈到了 rsc.io/script 以供導入使用。現在我說 "嘗試" 是因爲它還比較新,並且具有諷刺意味的是,它本身的測試還不夠多,因爲可導入的包只有幾周的歷史,但你仍然可能會發現它很有用。當我們對其有更多經驗時,我們可能會將其放在更官方的位置上。如果你嘗試了它,請告訴我結果如何。

提取腳本引擎的動機是爲了在 go 命令測試的不同部分中重用它。這個腳本正在準備一個包含我們想要在常規 go 命令腳本測試中導入的模塊的 Git 存儲庫 (repo)。你可以看到它設置了一些環境變量,運行了真正的 git init,設置了時間,在存儲庫中運行了更多的 git 命令來添加一個 hello world 文件,然後檢查我們得到了我們想要的存儲庫。再一次,測試並不是從一開始就是這樣的,這引出了下一個實用建議。

建議 19:隨着時間的推移改進你的測試

最初,我們沒有這些存儲庫腳本。我們手工創建小型測試存儲庫,並將它們發佈到 GitHub、Bitbucket 和其他託管服務器,具體取決於我們所需的版本控制系統。這種方法還算可以,但這意味着如果這些服務器中的任何一個宕機,測試就會失敗。最終,我們花時間構建了自己的雲服務器,可以爲每個版本控制系統提供存儲庫服務。現在,我們手工創建存儲庫,將其壓縮並複製到服務器上。這樣做更好,因爲現在只有一個服務器可能會使我們的測試失敗,但有時也會出現網絡問題。測試存儲庫本身也沒有進行版本控制,並且與使用它們的測試不在一起,這也是一個問題。作爲測試的一部分,基於腳本的版本完全可以在本地構建和提供這些存儲庫。而且現在很容易找到、更改和審查存儲庫的描述。這需要很多基礎設施,但也測試了很多代碼。如果你只有 10 行代碼,你完全不需要擁有數千行的測試框架。但是如果你有十萬行代碼,這大約是 go 命令的規模,那麼開發幾千行代碼來改進測試,甚至是一萬行代碼,幾乎可以肯定是一個不錯的投資。

建議 20:追求持續部署

也許出於策略原因,你無法每次都實際部署那些通過了所有測試的代碼提交,但無論如何都要追求這一目標。正如我在演講開始時提到的,對於持續部署的任何疑問都是有益的小聲音,它們告訴你需要更好的測試。而更好的測試的關鍵當然是讓添加新測試變得容易。即使你從未實際啓用持續部署,追求這一目標也可以幫助你保持誠實,提高測試的質量和代碼的質量。

我之前提到過 Go 官方網站 [7] 使用了持續部署。在每次提交時,我們運行測試來決定是否可以部署最新版本的代碼並將流量路由到它。此時,你不會感到驚訝,我們爲這些測試編寫了一個測試腳本語言。上圖是它們的樣子。每個測試以一個 HTTP 請求開始。這裏我們 GET 主頁 go.dev。然後對響應進行斷言。每個斷言的形式爲 "字段 (field),運算符(operator),值(value)"。這裏字段(field) 是 body,運算符 (operator 是 contains,值(value) 是 body 中必須包含的字面值。這個測試檢查頁面是否渲染過了,因此它檢查基本文本以及一個副標題。爲了更容易編寫測試,根本沒有引號。值就是運算符後面的其餘部分。接下來是另一個測試用例。出於歷史原因,/about 需要重定向到 pkg.go.dev。

這是另一個案例。這裏沒有什麼特別的,只是檢查案例研究頁面是否渲染 (rendering) 了,因爲它是由許多其他文件合成的。測試可以檢查的另一個字段是 HTTP 響應代碼,這是一個錯誤修復。我們錯誤地在 Go 存儲庫根目錄中提供了這些文件,就好像它們是 Go 網站頁面一樣。我們希望改爲返回 404。你還可以測試標頭 foo 的值,其中 foo 是某個標頭。在這種情況下,標頭 Content-Type 需要正確設置爲主博客頁面及其 JSON feed。

這是另一個示例。這個示例使用正則表達式匹配運算符 tilde 和 “\s+” 語法,以確保頁面具有正確的文本,無論單詞之間有多少空格。這變得有點老套了,所以我們添加了一個名爲 trimbody 的新字段,它是將所有空格序列替換爲單個空格後的 body。這個示例還顯示了值可以作爲多個縮進的行提供,以便更容易進行多行匹配。

我們還有一些無法在本地運行但在生產環境中仍值得運行的測試,因爲我們將實時流量遷移到服務器之前需要進行這些測試。下面是其中兩個。這些依賴於對生產環境 playground 後端的網絡訪問。這些案例除了 URL 不同之外都是相同的。這不是一個非常易讀的測試,因爲這些是我們唯一的 POST 測試。如果我們添加了更多這樣的測試,我可能會花時間使它們看起來更好,以隨着時間推移改進你的測試。但是現在它們還可以,它們起到了重要的作用。

最後,和往常一樣,添加錯誤修復很容易。在問題 51989 中,live web 站點根本沒有呈現。因此,這個測試檢查頁面確實呈現幷包含一個獨特的文本片段。問題 51989 不會再次發生,至少不會在實際的網站上。肯定會有其他錯誤,但那個問題已經徹底解決了,這就是進步。以上這些是我有時間向你展示的這些例子。

小結

最後一個想法。我相信你經歷過追蹤錯誤並最終發現一個重要的代碼片段是錯誤的情況。但不知何故,這個代碼片段的錯誤大部分時間都無關緊要,或者錯誤被其他錯誤的代碼抵消了。你可能會想:“這段代碼以前是怎麼工作的?” 如果是你自己編寫的代碼,你可能會認爲自己很幸運。如果是別人編寫的代碼,你可能會對他們的能力產生質疑,然後又認爲他們很幸運。但是,大多數時候,答案並不是運氣。對於這段代碼爲什麼會工作的問題的答案几乎總是:因爲它有一個測試。當然,代碼是錯誤的,但測試檢查了它足夠正確,使系統的其他部分可以正常工作,這纔是最重要的。也許編寫這段代碼的人確實是一個糟糕的程序員,但他們是一個優秀的軟件工程師,因爲他們編寫了一個測試,這就是爲什麼包含該代碼的整個系統能夠工作的原因。

我希望你從這次演講中得出的結論不是任何特定測試的具體細節,儘管我希望你可以留意對小型解析器和打印機的良好使用帶來的好處。任何人都可以學會編寫它們,並且有效地使用它們可以成爲軟件工程的超能力。最終,這對這些軟件包來說是好測試。對於你的軟件包,好測試可能看起來會有所不同。這沒關係。但要使添加新的測試用例變得容易,並確保你擁有良好、清晰、高質量的測試。請記住,代碼質量受測試質量的限制,因此逐步投入改進測試。你在項目上工作的時間越長,你的測試就應該變得越好。並且要追求持續部署,至少作爲一種思想實驗,以瞭解哪些方面的測試還不夠充分。

總的來說,要像編寫優秀的非測試代碼一樣,思考並投入同樣的思想、關心和努力來編寫優秀的測試代碼,這絕對是值得的


Gopher Daily(Gopher 每日新聞) - https://gopherdaily.tonybai.com

我的聯繫方式:

參考資料

[1] 

GopherCon Australia 2023: https://gophercon.com.au/

[2] 

“Go Testing By Example”: https://research.swtch.com/testing

[3] 

個人網站: https://research.swtch.com/testing

[4] 

Go 開源 14 年: https://tonybai.com/2023/11/11/go-opensource-14-years/

[5] 

The Practice of Programming: https://book.douban.com/subject/1459281/

[6] 

覆蓋率驅動的模糊測試 (fuzzing test): https://tonybai.com/2021/12/01/first-class-fuzzing-in-go-1-18

[7] 

Go 官方網站: https://go.dev

[8] 

“Gopher 部落” 知識星球: https://public.zsxq.com/groups/51284458844544

[9] 

鏈接地址: https://m.do.co/c/bff6eed92687

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/yJqwcYqYmV1wWuo147fX4Q