[検証中]更新のあったファイルだけgo testを走らせテストを高速化する #golang #gae
October 19, 2016
この記事はQiitaの記事をエクスポートしたものです。内容が古くなっている可能性があります。
はじめに
go test
(GAE/Goの場合はgoapp test
)で、自身のプロジェクトのテストを走らせる際、非常に時間がかかってしまいテストの実行が億劫になることがあります。
特にGAEだとaetest
パッケージを使わなくてもそれなりに時間がかかります。
GAE/Goのテストを早くするには、favclip/testeratorを使ってインスタンスを使いまわすという方法もあります。
しかしながら、それでもそれなりに時間がかかるので、どうにか更新のあったソースコードに関わるテストだけ実行できないかと考えました。
なお、この記事ではgit
などを使ってバージョン管理している前提で書いています。
また、おそらくちゃんと動くだろうとは思いますが、まだ検証中なのでデプロイする前には、ちゃんとすべてのテストを実行することをオススメします。
coverprofileを使う
go test
のオプションには、-coverprofile
というオプションがあります。
このオプションは、指定したパッケージのテストのカバレッジの解析結果を記録するためのものです。
例えば、fmt
パッケージのcoverprofile
を出力してみましょう。
$ go version
go version go1.6.2 darwin/amd64
$ go test -coverprofile=~/Desktop/coverprofile fmt
ok fmt 0.115s coverage: 92.3% of statements
$ head ~/Desktop/coverprofile
mode: set
fmt/format.go:30.13,31.29 1 1
fmt/format.go:31.29,34.3 2 1
fmt/format.go:67.28,69.2 1 1
fmt/format.go:71.33,74.2 2 1
fmt/format.go:77.85,80.11 3 1
fmt/format.go:84.2,85.11 2 1
fmt/format.go:96.2,96.8 1 1
fmt/format.go:80.11,83.3 2 0
fmt/format.go:85.11,86.21 1 1
これを見ると、2行目以降はどのファイルのどの部分を何回通ったかという情報のようです(参考)。
ちなみに、この処理は再帰的にはできず、1つのパッケージに対して1回ずつgo test -coverprofile
を実行する必要があります。
そのため、パッケージの数だけcoverprofile
が作成されます。
このcoverprofile
をgo tool cover
を使ってカバレッジを可視化する方法は、こちらの記事を参考にするとよいかなと思います。
このcoverprofile
からファイル名が含まれているか検索すれば、差分のあったファイルに対応するテストだけ走らせることができるのでは?と考えました。
つまり、
- 予め、自分のプロジェクトのすべてのパッケージに対して
coverprofile
を作成する .go
ファイルを編集する(通常の開発)- すべてのパッケージの
coverprofile
の中から編集したファイルがあるか検索する - 編集したファイルの中に
_test.go
があればそのパッケージも対象にする - ヒットしたパッケージの
coverprofile
を作成する
のように、テストする度にcoverprofile
を更新していけば必要なパッケージだけテストされるという訳です。
それでは具体的な方法をみていきましょう。
coverprofileの作成
まずは各パッケージのcoverprofile
を作成する方法を説明します。
上述のとおり1パッケージずつテストを走らせる必要があります。
$PJROOT
をプロジェクトのsrc
ディレクトリとして場合に以下のようにシェルを実行すればOKです。
各パッケージのcoverprofile
は各パッケージのディレクトリ配下に作成されます。
for PKG in `find $PJROOT -type d`
do
goapp test -coverprofile=$PJROOT/$PKG/coverprofile $PKG
done
ここでは、作成したcoverprofile
をgit
でコミットしてバージョン管理に載せる前提で書いています。
プロジェクトの事情でコミットできない場合は、.gitignore
に入れて、手元のソースコードを更新するたびに、変更のあったファイルに依存するcoverprofile
を更新する必要があります。
coverprofileからの検索
coverprofile
からの検索はそんなに難しくありません。
少し長いですが、ざっと殴り書いたものを以下に貼っておきます。
package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"go/build"
"io/ioutil"
"os"
"path/filepath"
"strings"
)
var (
pjroot string
profile string
)
func init() {
flag.StringVar(&pjroot, "pjroot", ".", "project root")
flag.StringVar(&profile, "profile", "coverprofile", "coverprofile file name")
}
// files は.goファイルと_test.goファイルを抽出します。
func files() (gofiles, testfiles []string) {
for _, f := range flag.Args() {
if filepath.Ext(f) != ".go" {
continue
}
if strings.HasSuffix(f, "_test.go") {
testfiles = append(testfiles, f)
} else {
gofiles = append(gofiles, f)
}
}
return
}
// DependencyFinder は任意のファイルが依存するパッケージを探す機能を提供します。
type DependencyFinder struct {
pkgs map[string]bool
testfiles []string
gofiles []string
profiles []string
pjroot string
coverprofile string
pfs map[string]*bytes.Buffer
}
// NewDependencyFinder は新しい DependencyFinder を作成します。
func NewDependencyFinder(pjroot, coverprofile string, gofiles, testfiles []string) *DependencyFinder {
return &DependencyFinder{
pkgs: map[string]bool{},
testfiles: testfiles,
gofiles: gofiles,
pjroot: pjroot,
coverprofile: coverprofile,
}
}
// packageFromTestFiles はテストファイルからそのテストファイルが対象としてパッケージを抽出します。
func (df *DependencyFinder) fromTestFiles() {
for _, tf := range df.testfiles {
path, err := filepath.Abs(tf)
if err != nil {
continue
}
dir := filepath.Dir(path)
pkg, err := build.ImportDir(dir, build.FindOnly|build.IgnoreVendor)
if err != nil {
continue
}
df.pkgs[pkg.ImportPath] = true
}
}
// findProfiles はプロファイルファイルを探します。
func (df *DependencyFinder) findProfiles() error {
return filepath.Walk(df.pjroot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() && info.Name() == df.coverprofile {
f, err := filepath.Rel(df.pjroot, path)
if err != nil {
return err
}
df.profiles = append(df.profiles, f)
}
return nil
})
}
// fromProfiles は coverprofile から指定した.goファイルが依存するパッケージを取得します。
func (df *DependencyFinder) fromProfiles() error {
df.pfs = map[string]*bytes.Buffer{}
for _, gofile := range df.gofiles {
for _, profile := range df.profiles {
gf, err := filepath.Rel(df.pjroot, gofile)
if err != nil {
return err
}
pkg, err := df.dependPackage(gf, profile)
if err != nil {
return err
}
if pkg != "" {
df.pkgs[pkg] = true
}
}
}
return nil
}
// dependPackage は coverprofile に指定したGoファイルが依存しているかどうか取得します。
// 依存している場合は、パッケージ名を取得します。
func (df *DependencyFinder) dependPackage(gf, pf string) (string, error) {
buf, ok := df.pfs[pf]
if !ok {
b, err := ioutil.ReadFile(filepath.Join(df.pjroot, pf))
if err != nil {
return "", err
}
buf = bytes.NewBuffer(b)
df.pfs[pf] = buf
}
defer buf.Reset()
s := bufio.NewScanner(buf)
for s.Scan() {
if strings.Contains(s.Text(), gf) {
pkg := filepath.Dir(pf)
return pkg, nil
}
}
return "", s.Err()
}
// Find はGoファイルに依存しているパッケージを取得します。
func (df *DependencyFinder) Find() ([]string, error) {
df.fromTestFiles()
if err := df.findProfiles(); err != nil {
return nil, err
}
if err := df.fromProfiles(); err != nil {
return nil, err
}
var pkgs []string
for pkg := range df.pkgs {
pkgs = append(pkgs, pkg)
}
return pkgs, nil
}
func main() {
flag.Parse()
gofiles, testfiles := files()
df := NewDependencyFinder(pjroot, profile, gofiles, testfiles)
pkgs, err := df.Find()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v", err)
os.Exit(1)
}
for _, pkg := range pkgs {
fmt.Println(pkg)
}
}
このコードでは、コマンドライン引数として、ファイル一覧を取得します。
ファイル一覧は、git diff
などから渡されることを想定しています。
渡されたファイル一覧のうち.go
と_test.go
を対象に、依存しているパッケージの一覧を標準出力に出力します。
また、オプションとして、GOPATH
直下のプロジェクトのsrc
ディレクトリのパスとcoverprofile
のファイル名を渡します。
coverprofile
は各パッケージのディレクトリ配下にある前提で動作します。
ビルドは普通にgo build
でできます。
$ go build -o deppkg deppkg.go
差分テストを実行する
作成したツールは、コマンドライン引数にファイル一覧を取るので、以下のようにgit diff --name-only
の結果をパイプで渡すと良いでしょう。
$ git diff --name-only | deppkg -pjroot=$PJROOT
このままだと標準出力に依存パッケージが表示されるだけなので、さらに以下のようなシェルを書いておくと便利です。
これをgit
のpre-commit
フックに仕掛けておけば、コミットする前にテストができて便利ですね。
なお、GAEの場合は、go test
のところをgoapp test
に置き換えて下さい。
#!/bin/sh
PJROOT=`dirname $0`/src
DEPPKG=`which deppkg`
for PKG in `git diff --name-only HEAD | xargs deppkg -pjroot $PJROOT`
do
echo "start test for $PKG"
go test -coverprofile=$PJROOT/$PKG/coverprofile $PKG
if [ $? != 0 ]; then
exit 1
fi
done
coverprofile
をコミットしていない場合は、pull
する度に上記のシェルを使ってcoverprofile
を更新してください。
おわりに
この記事では、coverprofile
を使った差分のあったファイルだけをテストする方法について説明しました。
GAE/Goのテストが遅くて悩んでる方の少しでもお役に立てたらと思います。
なお、このパターンの修正したときにテストが漏れるぞという指摘があればコメントで教えてください。