Godogの実装を見てみる
この記事はただの集団 Advent Calendar 2018の23日目の記事です。
昨日は hajimeniさんのプログラム上で時間を扱う際に気をつけることでした。
Advent Calendarもいよいよ終りが見えてきました。
本記事では、GolangでATTDを実践するためのツールであるGodogがどのように動いているかを探ってみます。
TL;DR
- Godogはコマンド実行時にファイルを読み込んでテスト用のソースコードを生成し、それをコンパイルしたものを実行している
go/build
やgo/parser
、go/ast
などのパッケージを活用してソースコードを解析して、対象となるstep関数を見つけ出している
はじめに
GodogとはCucurmberのGolang版です。GithubのCucumberのリポジトリには存在しませんが、 Cucurmberチームの開発メンバーがメンテナンスしているようなので安心して使って良いと思われます。
Godogは以下のような自然言語で書かれたfeatureファイルと、それに対応するstepファイルを記述しgodogコマンドを実行することでテストが実行されます。
Feature: eat godogs In order to be happy As a hungry gopher I need to be able to eat godogs Scenario: Eat 5 out of 12 Given there are 12 godogs When I eat 5 Then there should be 7 remaining
package main import ( "github.com/DATA-DOG/godog" ) func thereAreGodogs(arg1 int) error { return godog.ErrPending } func iEat(arg1 int) error { return godog.ErrPending } func thereShouldBeRemaining(arg1 int) error { return godog.ErrPending } func FeatureContext(s *godog.Suite) { s.Step(`^there are (\d+) godogs$`, thereAreGodogs) s.Step(`^I eat (\d+)$`, iEat) s.Step(`^there should be (\d+) remaining$`, thereShouldBeRemaining) }
godogコマンドが実行されたときにどのようにしてこれらのテストが実施されるのでしょうか? godogコマンドはどのようにしてstepファイルを認識しているのでしょうか? これらの疑問を解消するため、実際にgodogのコードを見てみることにしました。
Godogの動き
call graphは以下のようになっています。今回着目する箇所は印がついている部分になります。
ちなみに、こちらはgo-callvisを使って出力しました。
main
godog
を実行するとはじめに、cmd/godog/main.goのmainが呼び出されます。
https://github.com/DATA-DOG/godog/blob/master/cmd/godog/main.go#L61-L105
package main ... func main() { (省略) status, err := buildAndRun() // ここからスタート if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } // it might be a case, that status might not be resolved // in some OSes. this is attempt to parse it from stderr if parsedStatus > status { status = parsedStatus } os.Exit(status) }
mainでは、引数のParseなどを行ったあと、同じファイル内のbuildAndRun
関数を呼び出しています。
buildAndRun
https://github.com/DATA-DOG/godog/blob/master/cmd/godog/main.go#L17-L59
package main ... var parsedStatus int func buildAndRun() (int, error) { var status int bin, err := filepath.Abs("godog.test") if err != nil { return 1, err } if build.Default.GOOS == "windows" { bin += ".exe" } if err = godog.Build(bin); err != nil { // テストの実行ファイルをビルド return 1, err } defer os.Remove(bin) // 処理が終わったら実行ファイルを削除 cmd := exec.Command(bin, os.Args[1:]...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin cmd.Env = os.Environ() if err = cmd.Start(); err != nil { // テストを実行 return status, err } (省略) }
buildAndRun
の中で行っていることはgodog.test
という実行ファイルをgodog.Build
で生成し、
それをexec.Command
で実行するということです。
どうやら、godogコマンドを実行すると直接テストが走るのではなく、 テスト用の実行ファイルをビルドしそれを実行することで テストが動くという仕組みになっているようです。
では、Buildの中の処理を追っていきます。
Build(前半)
https://github.com/DATA-DOG/godog/blob/master/builder.go#L48-L219
Buildの中で最初に重要なのは以下の部分です。
func Build(bin string) error { abs, err := filepath.Abs(".") // (1) if err != nil { return err } // we allow package to be nil, if godog is run only when // there is a feature file in empty directory pkg := importPackage(abs) // (2) src, anyContexts, err := buildTestMain(pkg) // (3) if err != nil { return err } ... }
はじめに、(1)のfilepah.Abs(".")
でカレントディレクトリの絶対パスを取得しています。
次に(2)で、 同じファイル内のimportPackage
を呼び出しています。
importPackage
ではgo/build
パッケージのImportDir
メソッドを呼び出すことで、
指定したディレクトリ以下のGoファイルを調べ、goのパッケージ情報を抽出しています。
返り値は以下のような情報を持つPackage
の構造体へのポインタです。
https://golang.org/pkg/go/build/#Package
type Package struct { Dir string // directory containing package sources Name string // package name ... // Source files GoFiles []string // .go source files (excluding CgoFiles, TestGoFiles, XTestGoFiles) ... Imports []string // import paths from GoFiles, CgoFiles ImportPos map[string][]token.Position // line information for Imports // Test information TestGoFiles []string // _test.go files in package TestImports []string // import paths from TestGoFiles ... XTestGoFiles []string // _test.go files outside package XTestImports []string // import paths from XTestGoFiles ... }
その後(3)で、抽出したパッケージ情報をbuildTestMain
関数の引数として呼び出しています。
buildTestMain(前半)
https://github.com/DATA-DOG/godog/blob/master/builder.go#L272-L303
buildTetMain
関数ではtestのmainとなるソースコードを生成します。
ここで生成されたコードがコンパイルされexec.Command
で実行されることでgodogは動いています。
func buildTestMain(pkg *build.Package) ([]byte, bool, error) { var contexts []string var importPath string name := "main" if nil != pkg { ctxs, err := processPackageTestFiles( // (1) pkg.TestGoFiles, pkg.XTestGoFiles, ) if err != nil { return nil, false, err } contexts = ctxs importPath = pkg.ImportPath name = pkg.Name } ... }
buildTetMain
の前半で重要なのは(1)の箇所です。
受け取ったパッケージ情報の中から、godog_test.go
のようなテストファイル
の名前をprocessPackageTestFiles
に渡します。
processPackageTestFiles
https://github.com/DATA-DOG/godog/blob/master/builder.go#L322-L347
processPackageTestFiles
では、受け取ったファイル名を
go/parse
のParseFile
メソッド
に渡してASTを生成します (1)。
その後、生成したASTをastContexts
に渡しメソッド名を抽出します (2)。
// processPackageTestFiles runs through ast of each test // file pack and looks for godog suite contexts to register // on run func processPackageTestFiles(packs ...[]string) ([]string, error) { var ctxs []string fset := token.NewFileSet() for _, pack := range packs { for _, testFile := range pack { node, err := parser.ParseFile(fset, testFile, nil, 0) // (1) if err != nil { return ctxs, err } ctxs = append(ctxs, astContexts(node)...) // (2) } } ... }
astContexts
https://github.com/DATA-DOG/godog/blob/master/ast.go
astContexts
では、受け取ったASTを解析して、
func (*godog.Suite)
型の関数が存在していたら、その関数名を返します。
というわけでgodogがどうやって処理する関数を見つけ出しているかは、ここにかかれていました。
func astContexts(f *ast.File) []string { var contexts []string for _, d := range f.Decls { switch fun := d.(type) { case *ast.FuncDecl: for _, param := range fun.Type.Params.List { switch expr := param.Type.(type) { case *ast.StarExpr: switch x := expr.X.(type) { case *ast.Ident: if x.Name == "Suite" { contexts = append(contexts, fun.Name.Name) } case *ast.SelectorExpr: switch t := x.X.(type) { case *ast.Ident: if t.Name == "godog" && x.Sel.Name == "Suite" { contexts = append(contexts, fun.Name.Name) } } } } } } } return contexts }
buildTestMain(後半)
https://github.com/DATA-DOG/godog/blob/master/builder.go#L292-L303
processPackageTestFiles
の処理が終わったら、buildTestMain
に戻ります。
if nil != pkg { ctxs, err := processPackageTestFiles( pkg.TestGoFiles, pkg.XTestGoFiles, ) ... data := struct { Name string Contexts []string ImportPath string }{name, contexts, importPath} var buf bytes.Buffer if err := runnerTemplate.Execute(&buf, data); err != nil { // (1) return nil, len(contexts) > 0, err } return buf.Bytes(), len(contexts) > 0, nil
buildTestMain
では、テンプレートにimportPackage
やprocessPackageTestFiles
で抽出した情報を埋め込み、
testのmainとなるソースコードを生成します。
templateは以下のようになっており、
main
関数を持つgoのファイルを生成していることがわかります。
https://github.com/DATA-DOG/godog/blob/master/builder.go#L30-L46
var runnerTemplate = template.Must(template.New("testmain").Parse(`package main import ( "github.com/DATA-DOG/godog" {{if .Contexts}}_test "{{.ImportPath}}"{{end}} "os" ) func main() { status := godog.Run("{{ .Name }}", func (suite *godog.Suite) { os.Setenv("GODOG_TESTED_PACKAGE", "{{.ImportPath}}") {{range .Contexts}} _test.{{ . }}(suite) {{end}} }) os.Exit(status) }`))
実際にgodogのテストを実行するのはgodog.Run()
の部分です。
godog.Run()
の内部ではこれまでの処理で発見した、ステップ関数の実行を行う処理が書かれています。
ここまでで、ようやくbuildTestMain
の処理が完了し、実行すべきtestmain
のソースコードが得られました。
Build(後半)
最後にBuild
関数で得られたソースコードをコンパイルします。
exec.Command(compiler, args...)
(1)やexec.Command(linker, args...)
(2)を実行して
コンパイルが行われていることが確認できます。
func Build(bin string) error { (省略) src, anyContexts, err := buildTestMain(pkg) if err != nil { return err } (省略) args = append(args, "-pack", testmain) cmd = exec.Command(compiler, args...) // (1) cmd.Env = os.Environ() out, err = cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to compile testmain package: %v - output: %s", err, string(out)) } // link test suite executable args = []string{ "-o", bin, "-buildmode=exe", } for _, link := range pkgDirs { args = append(args, "-L", link) } args = append(args, testMainPkgOut) cmd = exec.Command(linker, args...) // (2) cmd.Env = os.Environ() out, err = cmd.CombinedOutput() if err != nil { msg := `failed to link test executable: reason: %s command: %s` return fmt.Errorf(msg, string(out), linker+" '"+strings.Join(args, "' '")+"'") } return nil }
まとめ
Godogがどのように実行されるかを理解するために実装を見てみました。
最初はGodogの使い方を解説する記事にしようとしていたのに、
いつのまにかGodogの内部の処理を追ってしまっていましたが、
go/build
やgo/ast
など普段はあまり使わない
パッケージの使い方を知ることができてよかったです。