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 工具可以提供測試涵蓋率資訊。然而如果您是 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 會執行「涵蓋率」工具(一個包含在發行版中的獨立程式),在編譯前重新編寫原始程式碼。以下是重新編寫後的 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"
}

程式中的每個可執行區段都附註著一個指派陳述式,在執行時會記錄該區段已執行。計數器透過第二個唯讀資料結構繫結到這些陳述式的原始程式碼位置,而此資料結構也是由涵蓋率工具所產生。當測試執行完畢時,會收集計數器,並藉由查看已設定的數量來計算百分比。

雖然註解指派看起來代價昂貴,但它編譯為單一「移動」指令。因此執行時間開銷很少,在執行典型(較實際的)測試時僅增加約 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()

並未嘗試分別編錄對 fg 呼叫,無論事實是什麼,它看起來總是執行相同的次數,也就是 f 執行的次數。

公平地說,即使是 gcov 也會有問題。該工具正確編錄,但簡報是基於行,因此可能會錯過一些細微差別。

全貌

這就是 Go 1.2 中的測試覆蓋率。一個具有有趣實作的新工具不僅能夠提供測試覆蓋率統計資料,還能提供易於詮釋的簡報,甚至可以擷取剖析資訊。

測試是軟體開發中很重要的一部分,而測試覆蓋率是一種為測試策略增添紀律的簡單方式。向前邁進,測試並提供覆蓋率。

下一篇:深入了解 Go Playground
前一篇:Go 1.2 已發布
部落格索引