[検証中]更新のあったファイルだけ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が作成されます。

このcoverprofilego tool 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

ここでは、作成したcoverprofilegitでコミットしてバージョン管理に載せる前提で書いています。 プロジェクトの事情でコミットできない場合は、.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

このままだと標準出力に依存パッケージが表示されるだけなので、さらに以下のようなシェルを書いておくと便利です。 これをgitpre-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のテストが遅くて悩んでる方の少しでもお役に立てたらと思います。

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