Godogの実装を見てみる

この記事はただの集団 Advent Calendar 2018の23日目の記事です。
昨日は hajimeniさんのプログラム上で時間を扱う際に気をつけることでした。
Advent Calendarもいよいよ終りが見えてきました。

本記事では、GolangでATTDを実践するためのツールであるGodogがどのように動いているかを探ってみます。

adventar.org

TL;DR

  • Godogはコマンド実行時にファイルを読み込んでテスト用のソースコードを生成し、それをコンパイルしたものを実行している
  • go/buildgo/parsergo/astなどのパッケージを活用してソースコードを解析して、対象となるstep関数を見つけ出している

はじめに

GodogとはCucurmberのGolang版です。GithubのCucumberのリポジトリには存在しませんが、 Cucurmberチームの開発メンバーがメンテナンスしているようなので安心して使って良いと思われます。

github.com

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)
}

f:id:takatorix:20181222171824j:plain

godogコマンドが実行されたときにどのようにしてこれらのテストが実施されるのでしょうか? godogコマンドはどのようにしてstepファイルを認識しているのでしょうか? これらの疑問を解消するため、実際にgodogのコードを見てみることにしました。

Godogの動き

call graphは以下のようになっています。今回着目する箇所は印がついている部分になります。

f:id:takatorix:20181222180312j:plain

ちなみに、こちらはgo-callvisを使って出力しました。

github.com

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/parseParseFileメソッド に渡して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では、テンプレートにimportPackageprocessPackageTestFilesで抽出した情報を埋め込み、 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/buildgo/astなど普段はあまり使わない パッケージの使い方を知ることができてよかったです。