[検証中]更新のあったファイルだけgo testを走らせテストを高速化する #golang #gae


## はじめに

`go test`（GAE/Goの場合は`goapp test`）で、自身のプロジェクトのテストを走らせる際、非常に時間がかかってしまいテストの実行が億劫になることがあります。
特にGAEだと`aetest`パッケージを使わなくてもそれなりに時間がかかります。
GAE/Goのテストを早くするには、[favclip/testerator](https://github.com/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行目以降はどのファイルのどの部分を何回通ったかという情報のようです（[参考](https://github.com/golang/go/blob/master/src/cmd/cover/profile.go#L56)）。
ちなみに、この処理は再帰的にはできず、1つのパッケージに対して1回ずつ`go test -coverprofile`を実行する必要があります。
そのため、パッケージの数だけ`coverprofile`が作成されます。

この`coverprofile`を`go tool cover`を使ってカバレッジを可視化する方法は、[こちらの記事](https://blog.golang.org/cover)を参考にするとよいかなと思います。

この`coverprofile`からファイル名が含まれているか検索すれば、差分のあったファイルに対応するテストだけ走らせることができるのでは？と考えました。
つまり、

1. 予め、自分のプロジェクトのすべてのパッケージに対して`coverprofile`を作成する
2. `.go`ファイルを編集する（通常の開発）
3. すべてのパッケージの`coverprofile`の中から編集したファイルがあるか検索する
4. 編集したファイルの中に`_test.go`があればそのパッケージも対象にする
5. ヒットしたパッケージの`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`からの検索はそんなに難しくありません。
少し長いですが、ざっと殴り書いたものを以下に貼っておきます。

```deppkg.go
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のテストが遅くて悩んでる方の少しでもお役に立てたらと思います。

なお、このパターンの修正したときにテストが漏れるぞという指摘があればコメントで教えてください。