Go 部落格
封面故事
引言
從專案伊始,Go 的設計就考慮到了工具。這些工具包括 Go 技術中最具標誌性的一些部分,例如文件展示工具 godoc、程式碼格式化工具 gofmt 和 API 重寫工具 gofix。也許最重要的是 go
命令,它是一個程式,僅使用原始碼作為構建規範,就能自動安裝、構建和測試 Go 程式。
Go 1.2 的釋出引入了一個新的測試覆蓋率工具,它採用了一種不同尋常的方式來生成覆蓋率統計資訊,這種方法建立在 godoc 等工具奠定的技術基礎之上。
對工具的支援
首先,介紹一些背景:一門語言“支援良好的工具”意味著什麼?這意味著該語言易於編寫優秀的工具,並且其生態系統支援構建各種型別的工具。
Go 有許多特性使其適合工具開發。首先,Go 具有規則的語法,易於解析。其語法旨在避免需要複雜機制來分析的特殊情況。
在可能的情況下,Go 使用詞法和語法結構來簡化語義屬性的理解。例如,使用大寫字母定義匯出名稱,以及與 C 傳統中的其他語言相比,其作用域規則得到了極大的簡化。
最後,標準庫提供了可用於詞法分析和解析 Go 原始碼的生產級軟體包。更不尋常的是,它們還包含一個用於美化列印 Go 語法樹的生產級軟體包。
這些軟體包結合起來構成了 gofmt 工具的核心,但美化列印器尤其值得一提。因為它能夠接收任意 Go 語法樹並輸出標準格式、易於人類閱讀且正確的程式碼,這就創造了構建能夠轉換解析樹並輸出修改後但仍然正確且易於閱讀的程式碼的工具的可能性。
一個例子是 gofix 工具,它自動化了重寫程式碼以使用新語言特性或更新庫的過程。gofix 使我們在 Go 1.0 釋出之前能夠對語言和庫進行根本性的更改,並且有信心使用者只需執行該工具即可將他們的原始碼更新到最新版本。
在 Google 內部,我們使用 gofix 對一個巨大的程式碼倉庫進行了大規模更改,這在我們使用的其他語言中幾乎是不可想象的。不再需要支援某個 API 的多個版本;我們可以使用 gofix 在一次操作中更新整個公司的程式碼。
當然,這些軟體包不僅僅支援這些大型工具。它們還使編寫更小巧的程式變得容易,例如 IDE 外掛。所有這些專案相互促進,透過自動化許多工,使 Go 環境更加高效。
測試覆蓋率
測試覆蓋率是一個術語,描述了透過執行軟體包的測試,有多少軟體包程式碼得到了執行。如果執行測試套件導致軟體包 80% 的源語句執行,我們就說測試覆蓋率為 80%。
在 Go 1.2 中提供測試覆蓋率的程式是最新利用 Go 生態系統工具支援的一個例子。
計算測試覆蓋率的常用方法是檢測二進位制檔案。例如,GNU gcov 程式在二進位制檔案執行的分支處設定斷點。當每個分支執行時,斷點被清除,並且分支的目標語句被標記為“已覆蓋”。
這種方法是成功的並且被廣泛使用。早期的一個 Go 測試覆蓋率工具甚至也採用同樣的方式工作。但它存在問題。它難以實現,因為分析二進位制檔案的執行是具有挑戰性的。它還需要一種可靠的方式將執行跟蹤與原始碼關聯起來,這也很困難,任何使用原始碼級偵錯程式的人都可以證明這一點。那裡的問題包括不準確的除錯資訊以及行內函數等問題使分析複雜化。最重要的是,這種方法非常不可移植。它需要為每個架構重新實現,並且在一定程度上需要為每個作業系統重新實現,因為不同系統的除錯支援差異很大。
然而,它的確有效,例如如果您是 gccgo 的使用者,gcov 工具可以為您提供測試覆蓋率資訊。但是,如果您是 gc(更常用的 Go 編譯器套件)的使用者,直到 Go 1.2 之前,您都無法使用此功能。
Go 的測試覆蓋率
對於 Go 的新測試覆蓋率工具,我們採用了另一種方法,避免了動態除錯。這個想法很簡單:在編譯之前重寫軟體包的原始碼以新增檢測程式碼,然後編譯並執行修改後的原始碼,最後匯出統計資訊。重寫很容易實現,因為 go
命令控制著從原始碼到測試再到執行的流程。
下面是一個例子。假設我們有一個簡單的、包含一個檔案的軟體包,像這樣
package size func Size(a int) string { switch { case a < 0: return "negative" case a == 0: return "zero" case a < 10: return "small" case a < 100: return "big" case a < 1000: return "huge" } return "enormous" }
以及這個測試
package size import "testing" type Test struct { in int out string } var tests = []Test{ {-1, "negative"}, {5, "small"}, } func TestSize(t *testing.T) { for i, test := range tests { size := Size(test.in) if size != test.out { t.Errorf("#%d: Size(%d)=%s; want %s", i, test.in, size, test.out) } } }
要獲取軟體包的測試覆蓋率,我們可以透過向 go
test
提供 -cover 標誌來執行啟用覆蓋率的測試
% go test -cover
PASS
coverage: 42.9% of statements
ok size 0.026s
%
注意,覆蓋率為 42.9%,這不太理想。在我們討論如何提高這個數字之前,先看看它是如何計算的。
當啟用測試覆蓋率時,go
test
會執行“cover”工具(隨發行版包含的另一個程式),在編譯之前重寫原始碼。下面是重寫後的 Size
函式的樣子
func Size(a int) string { GoCover.Count[0] = 1 switch { case a < 0: GoCover.Count[2] = 1 return "negative" case a == 0: GoCover.Count[3] = 1 return "zero" case a < 10: GoCover.Count[4] = 1 return "small" case a < 100: GoCover.Count[5] = 1 return "big" case a < 1000: GoCover.Count[6] = 1 return "huge" } GoCover.Count[1] = 1 return "enormous" }
程式的每個可執行部分都被添加了一個賦值語句進行註釋,當該語句執行時,就記錄該部分已執行。計數器透過另一個由 cover 工具生成的只讀資料結構,與它所計數的語句的原始原始碼位置關聯。測試執行完成後,收集計數器,並透過檢視有多少計數器被設定來計算百分比。
儘管這種註釋賦值看起來可能開銷很大,但它會被編譯成一條簡單的“移動”指令。因此,它的執行時開銷很小,在執行典型(更實際)的測試時只會增加大約 3%。這使得將測試覆蓋率作為標準開發流程的一部分變得合理。
檢視結果
我們示例的測試覆蓋率很差。為了弄清原因,我們要求 go
test
為我們寫入一個“覆蓋率配置檔案”,這是一個包含收集到的統計資訊的檔案,以便我們可以更詳細地研究它們。這很容易做到:使用 -coverprofile 標誌指定輸出檔案
% go test -coverprofile=coverage.out
PASS
coverage: 42.9% of statements
ok size 0.030s
%
(-coverprofile 標誌會自動設定 -cover 來啟用覆蓋率分析。)測試執行與之前一樣,但結果儲存在檔案中。為了研究它們,我們自己執行測試覆蓋率工具,而不是透過 go
test
。作為開始,我們可以要求按函式細分覆蓋率,儘管在這種情況下,由於只有一個函式,這不會提供太多資訊
% go tool cover -func=coverage.out
size.go: Size 42.9%
total: (statements) 42.9%
%
檢視資料的一種更有趣的方式是獲取帶有覆蓋率資訊的原始碼 HTML 展示。此顯示透過 -html 標誌呼叫
$ go tool cover -html=coverage.out
執行此命令後,會彈出一個瀏覽器視窗,顯示已覆蓋(綠色)、未覆蓋(紅色)和未檢測(灰色)的原始碼。下面是一個螢幕截圖

透過這種展示,很明顯問題出在哪裡:我們忽略了測試其中的幾個情況!而且我們可以清楚地看到是哪幾個,這使得改進我們的測試覆蓋率變得容易。
熱力圖
這種原始碼級測試覆蓋率方法的一個巨大優勢在於,可以輕鬆地以不同的方式檢測程式碼。例如,我們不僅可以詢問某個語句是否已被執行,還可以詢問它執行了多少次。
go
test
命令接受 -covermode 標誌來將覆蓋模式設定為以下三種之一
- set: 每個語句是否執行?
- count: 每個語句運行了多少次?
- atomic: 類似於 count,但在並行程式中精確計數
預設設定是‘set’,我們已經見過。atomic 設定只有在執行並行演算法時需要精確計數的情況下才需要。它使用 sync/atomic 軟體包中的原子操作,這可能會相當昂貴。不過,對於大多數情況,count 模式執行良好,並且像預設的 set 模式一樣,開銷非常小。
我們嘗試對標準軟體包 fmt 格式化軟體包進行語句執行計數。我們執行測試並輸出覆蓋率配置檔案,以便之後可以很好地展示資訊。
% go test -covermode=count -coverprofile=count.out fmt
ok fmt 0.056s coverage: 91.7% of statements
%
這比我們之前的示例具有更好的測試覆蓋率比例。(覆蓋率比例不受覆蓋模式的影響。)我們可以顯示函式細分情況
% go tool cover -func=count.out
fmt/format.go: init 100.0%
fmt/format.go: clearflags 100.0%
fmt/format.go: init 100.0%
fmt/format.go: computePadding 84.6%
fmt/format.go: writePadding 100.0%
fmt/format.go: pad 100.0%
...
fmt/scan.go: advance 96.2%
fmt/scan.go: doScanf 96.8%
total: (statements) 91.7%
最大的收穫體現在 HTML 輸出中
% go tool cover -html=count.out
下面是 pad
函式在該展示中的樣子

注意綠色強度的變化。更亮的綠色語句執行次數更高;飽和度較低的綠色代表執行次數較低。您甚至可以將滑鼠懸停在語句上,以在工具提示中檢視實際計數。撰寫本文時,計數如下所示(為了更容易展示,我們將計數從工具提示移動到行首標記處)
2933 if !f.widPresent || f.wid == 0 {
2985 f.buf.Write(b)
2985 return
2985 }
56 padding, left, right := f.computePadding(len(b))
56 if left > 0 {
37 f.writePadding(left, padding)
37 }
56 f.buf.Write(b)
56 if right > 0 {
13 f.writePadding(right, padding)
13 }
這是關於函式執行的大量資訊,這些資訊可能對效能分析有用。
基本塊
您可能已經注意到,上一個示例中帶有閉合大括號的行上的計數與您預期的不同。這是因為,測試覆蓋率始終是一門不精確的科學。
不過,這裡發生的事情值得解釋。我們希望覆蓋率註釋由程式中的分支來界定,就像傳統方法檢測二進位制檔案時那樣。然而,透過重寫原始碼很難做到這一點,因為分支不會明確出現在原始碼中。
覆蓋率註釋的作用是檢測基本塊,基本塊通常由大括號界定。通常情況下要做到這一點非常困難。所使用演算法的一個結果是,閉合大括號看起來屬於它關閉的基本塊,而開放大括號看起來屬於基本塊外部。一個更有趣的結果是,在一個像這樣的表示式中
f() && g()
沒有嘗試單獨檢測對 f
和 g
的呼叫。無論事實如何,看起來它們總是運行了相同的次數,也就是 f
執行的次數。
公平地說,即使是 gcov
在這裡也存在問題。那個工具檢測得很正確,但展示是基於行的,因此可能會遺漏一些細節。
總結
這就是 Go 1.2 中關於測試覆蓋率的故事。一個具有有趣實現的全新工具不僅提供了測試覆蓋率統計資訊,還提供了易於理解的展示方式,甚至可以提取效能分析資訊。
測試是軟體開發的重要組成部分,而測試覆蓋率是為您的測試策略增加規範性的簡單方法。前進吧,測試,並覆蓋。
下一篇文章:Go Playground 內部
上一篇文章:Go 1.2 釋出了
部落格索引