つきなみGo

Goのカバレッジツールを使いこなす

はじめに

テストでコード品質を担保していくことは、継続的インテグレーションの観点などで必要不可欠です。そして、十分なテストコードが書かれているかどうかの指標として、よく使われるものといえばテストカバレッジがあります。

Goではgo testコマンドと、go tool coverコマンドがカバレッジ計測の機能を担っています。今回は、これらのツールをより深く使い込んでいくために、既存機能の一歩進んだ使い方や最新機能について紹介します。

なお、本記事で紹介しているコマンドなどはmacOSで実行した場合の例となります。

オリジナルのカバレッジ統計データを集計する

まずは既存のカバレッジの統計データを取得する方法を振り返り、より詳細な情報を集計するアプローチについて紹介します。

Goのカバレッジツールで出力できる統計データ

既存のgo testコマンドおよびgo tool coverコマンドで出力できるカバレッジの統計データは次の3種類です。

  1. パッケージ単位のカバレッジ(%)
  2. 関数ごとのカバレッジ(%)
  3. 全体のカバレッジ(%)

それぞれ次のコマンドを実行することで確認できます。

# 1
$ go test -cover ./...

# 2, 3
$ go test -coverprofile=./coverage.txt ./... 
$ go tool cover -func coverage.txt

これらの方法で最低限の情報は得られます。しかし、アプリケーションの規模が大きくなってくると違った観点の情報もほしくなってきます。たとえば「ファイル・ディレクトリ単位でのカバレッジがほしい⁠⁠、⁠%情報だけでなく行数もほしい」といったケースがあるかもしれません。

そこで次に、go tool coverコマンドにはない形式でカバレッジ情報を出力する方法を紹介します。

-coverprofileオプションで出力されるレポート形式

鍵となるのが、go testコマンドの-coverprofileオプションを指定して出力されるレポートです。このオプションを使うと、次のような形式でファイル出力されます。

mode: set
sample/a/a.go:3.17,5.2 1 1
sample/a/aa/aa.go:3.29,4.18 1 1
sample/a/aa/aa.go:7.2,7.14 1 1
sample/a/aa/aa.go:4.18,6.3 1 0

一見不可思議な数値が並んでいますが、それぞれ意味は次のとおりです。2つ目の開始行から5つ目の終了列はコードブロックに関してです。

{ファイルパス}:{開始行}.{開始列},{終了行}.{終了列} {ブロックの行数} {テスト通過回数}

なお、このことはcoverパッケージのparseLine関数からわかります

このレポート情報と元のコードを照らしあわせると次のようになります。

図1

これらの情報を踏まえて詳細を読み取っていきます。具体的には次のことがわかります。

  • aa.goのcode blockの総行数は3行
  • aa.goのテストされているcode blockの行数は2行で、カバレッジは66.7%(2/3)
  • 結果がfalseになるテストがある
  • 結果がtrueになるテストはない

大切なのは、-coverprofileオプションで出力されるレポートにはファイルパスとコードブロックごとのテスト情報が含まれていることです。tool coverにはない形式でカバレッジ情報を出力したい場合は、このデータをパースした後、それぞれの目的に合わせて集計することになります。

ディレクトリ単位で行数を含めて集計する

この-coverprofileオプションで出力されるレポートを活用して、オリジナルのカバレッジ統計データを集計する具体例を紹介します。

筆者の所属するナレッジワークでテストを拡充する計画をした際、それを細かくブレイクダウンするために、ディレクトリ単位のカバレッジを求めて判断材料に使いました。このケースを例に挙げて進めます。

ここでは仮に、全体のカバレッジを2%上げるという目標を立てたとします。目標達成のために何個テストを追加すればよいのか、そのためにどれくらいの工数が必要なのかを見積もらなければなりません。

しかし、標準で用意されている関数とパッケージ単位のカバレッジデータだけでは情報が足りないと判断しました。たとえば、ある関数のカバレッジが0%だった場合、それを100%にすると全体のカバレッジがどのくらいに増えるかは、コード全体の行数および対象とする関数の行数がわからなければ計算できません。

また、パッケージ数が多い場合、複数のパッケージをまとめたディレクトリ単位でカバレッジを集計すると全体感が把握しやすくなります。

集計処理は次の4ステップで構成できます。とてもシンプルです。

  1. レポートファイルをパースしてGoファイルのパス、ブロックごとの行数およびテスト回数を取得する
  2. ファイルに含まれるブロックの行数およびテスト済みの行数の和を集計する
  3. ファイルパスから木構造を組み立てる
  4. 葉(ファイル)から根(ルートディレクトリ)にむかって全体行数およびテスト済みの行数を集計する

この集計処理の実装例はリポジトリ上で公開しています

なお前項のレポートファイルの例だと、次の図のような集計結果になります。

図2

ここで紹介した集計以外にも、ブロック数を集計すればコードの分岐処理の数がわかるため、関数やファイルごとのおよその複雑さを把握できます。またカバレッジレポートにはブロックの行・列情報も含まれているため、goパッケージで取得した抽象構文木(AST)と組み合わせれば、より複雑な集計・分析が行えるかもしれません。

このように、標準のカバレッジ情報で物足りない場合は、自身で集計できないか是非検討してみてください。

統合テストでカバレッジを計測する

続いて、統合テストにおけるカバレッジ計測について紹介します。

Go 1.19までは、go testコマンドで実行した時のカバレッジしか集計できませんでした。たとえば、APIの自動テストを構築したとしても、APIテストでサーバーのソースコードをどの程度網羅できているかを計測することはできません。

こういった課題に対して、2023年2月にリリースされたGo 1.20で新しい機能が追加されました。

go buildコマンドの-coverオプション

その追加された機能がgo buildコマンドにおける、カバレッジ集計用のオプション-coverです。

go testコマンドとgo tool coverコマンドを使ったGo 1.19までのカバレッジ集計と大きく異なるのは、mainパッケージをビルドして生成したバイナリ(実行可能ファイル)を直接実行してカバレッジを計測できることです。

まずは基本的な使い方を見てみましょう。

最初に-coverオプションを指定してビルドします。その後、カバレッジレポート保存用のディレクトリを環境変数GOCOVERDIRで指定し、バイナリを実行します。

$ go build -cover
$ mkdir _coverage
$ GOCOVERDIR=_coverage ./main

実行すると指定したディレクトリにカバレッジレポートが出力されます。

$ ls _coverage
covcounters.b8766fa017dad81194d2a53bce9517fa.50700.1676182425749415000
covmeta.b8766fa017dad81194d2a53bce9517fa

出力されたファイルのうち、covcountersという名前で始まるファイルがカバレッジ情報です。もう一方のcovmetaで始まるファイルは、ソースコードに関するメタデータで、ファイル名などが含まれます。

どちらのファイルもバイナリデータであるため、解析するためにはGo 1.20で新しく入ったgo tool covdataコマンドを使います。

go tool coverで集計できるパッケージごとの集計や関数ごとの集計が可能で、go testコマンドの-coverprofileオプションで出力される従来のテキスト形式への変換もサポートされています。

# パッケージ単位のカバレッジ
$ go tool covdata percent -i=_coverage
main  coverage: 83.3% of statements

# 関数単位のカバレッジ
$ go tool covdata func -i=_coverage
sample/main.go:9:       main            83.3%
total                   (statements)    83.3%

# テキスト形式への変換
$ go tool covdata textfmt -i=coverage -o coverage.txt
$ cat coverage.txt
mode: set
main/main.go:9.13,14.16 5 1
main/main.go:14.16,16.3 1 0

また、これら以外にも複数の実行結果のマージや差分取得の機能も用意されています。詳しくは公式ドキュメントをご覧ください。

このように、ビルド時に-coverオプションを指定するだけで、生成されたバイナリを実行することでカバレッジを簡単に取得できます。しかし、一つ大きな制約があります。

それは、実行が正常に終了した場合のみ、カバレッジデータが出力される点です。たとえば、APIサーバーの統合テストのカバレッジを取得しようとしても、サーバーが動き続けている限りはカバレッジデータは出力されません。また、強制終了させてもカバレッジデータは失われます。

この制約に対して、具体的な対策が公式に用意されています。

runtime/coverageパッケージ

正常終了するプログラムしかカバレッジレポートが出力されない制約への対策には、同じくGo 1.20から追加されるruntime/coverageパッケージを使います。

runtime/coverageパッケージには、実行途中でカバレッジデータを出力する関数が用意されています。これを用いて好きなタイミングでカバレッジデータを出力できます。

ここで、次のような3つのAPI(文字列を返す2種類のAPIと、カバレッジを記録するAPI)からなるサーバーのソースコードで実際の挙動を確認してみましょう。

example.com/api/api.go
package api

import (
	"fmt"
	"net/http"
	"runtime/coverage"
)

func HelloWorld(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Hello World")
}

func HelloWorld2(w http.ResponseWriter, r *http.Request) {
	fmt.Fprint(w, "Hello World2")
}

func Record(w http.ResponseWriter, r *http.Request) {
	coverage.WriteCountersDir("./coverage")
}
main.go
package main

import (
	"log"
	"net/http"

	"example.com/api"
)

func main() {
	http.HandleFunc("/hello", api.HelloWorld)
	http.HandleFunc("/hello2", api.HelloWorld2)
	http.HandleFunc("/record", api.Record)
	err := http.ListenAndServe(":8080", nil)
	if err != nil {
		log.Fatal("failed", err)
	}
}

まずこのコードを-coverオプションをつけてビルドし、実行します。

$ go build -cover
$ GOCOVERDIR=coverage ./main

そして別のシェルを起動し、curlコマンドを用いてサーバーに対して次のようにhttp://localhost:8080/hellohttp://localhost:8080/recordにそれぞれリクエストを送ります。そうすると、サーバーは起動したままで、coverageディレクトリにレポートが出力されます。

$ curl http://localhost:8080/hello
Hello World

$ curl http://localhost:8080/record

$ ls coverage
covcounters.b8766fa017dad81194d2a53bce9517fa.59654.1676187445218891000
covmeta.b8766fa017dad81194d2a53bce9517fa

レポートを解析すると、sample/api/api.go:13.58,15.2 1 0となっており、3つのAPIのうち、HelloWorld2だけがテストされていないことが確認できます。

$ go tool covdata textfmt -i=coverage -o coverage.txt
$ cat coverage.txt 
mode: set
sample/main.go:9.13,14.16 5 1
sample/main.go:14.16,16.3 1 0
sample/api/api.go:9.57,11.2 1 1
sample/api/api.go:13.58,15.2 1 0
sample/api/api.go:17.53,19.2 1 1

このように、runtime/coverageパッケージを使うとプログラムの実行途中でカバレッジレポートを出力できます。またcoverage.WriteCounters関数は、io.Writer型を引数に取れるため、好きな場所に出力できます。そのため、クラウドストレージに結果を出力したい場合も簡単に実装できます。

前述したように、出力されるバイナリ形式のレポートファイルは複数の結果をマージすることも可能です。テストを並列実行してそれぞれレポートを出力したあとにマージするといったアプローチも取れるでしょう。

ここでは簡単な例として、レポート出力用のAPIを別で定義しましたが、レポート出力用のインターセプターやミドルウェアをハンドラに追加して、API実行の度にレポートを出力するといった方法を取ることもできます。

APIの自動テストと組み合わせてカバレッジを計測できるようになったのは素晴らしいです。

おわりに

本記事では次のようなことを紹介しました。

  • go test -coverprofileコマンドで出力されるレポートには、ファイルパス・コードのブロック情報(行、列、行数⁠⁠、テスト実行情報が含まれている
  • プロファイルレポートをパースして集計することで、go tool coverコマンドにはない独自の集計が可能になる
  • Go 1.20でgo buildコマンドに-coverオプションが追加され、バイナリ実行時にカバレッジを計測できるようになった
  • バイナリ実行時のカバレッジ計測では正常終了する必要があるため、異常終了するコードや終了しないサーバのコードの場合は、runtime/coverageパッケージを使って実行途中でレポートを出力する

コードの健全性を保つには、何かしらの方法でコードの健全性を定量化し、その指標を計測し続ける必要があります。健全性の指標としてテストのカバレッジは銀の弾丸ではないものの、少なくとも現実的な解決策の一つだと言えるでしょう。

開発を進める中で、カバレッジが少しずつ減っていくような傾向がみられたとしたら、⁠テストしづらい実装になってしまっている」⁠テストを書く時間がないほどスケジュールに追われている」など、なにか解決すべき問題がチームに潜んでいるかもしれません。

カバレッジの計測はこのような悪い兆候に気づくための手段の一つです。加えてカバレッジを計測すること自体がテストコードを書くモチベーションにも繋がります。ぜひみなさんの環境にあった適切なカバレッジを計測して、健全な開発の一助としていただければと思います。

おすすめ記事

記事・ニュース一覧