Go 部落格

封面故事

Rob Pike
2013年12月2日

引言

從專案一開始,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 工具可以為您提供測試覆蓋率資訊。但是,如果您是更常用的 Go 編譯器套件 gc 的使用者,那麼在 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 工具生成的第二個只讀資料結構,計數器與語句的原始原始碼位置相關聯。當測試執行完成後,將收集計數器,並透過檢視有多少計數器被設定來計算百分比。

儘管那個註釋賦值語句可能看起來很昂貴,但它被編譯成單個“move”指令。因此,其執行時開銷適中,在執行典型(更現實)的測試時僅增加約 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 釋出
部落格索引