メルカリのインターンでGoのテストコードの雛形生成ツールを作りました

はじめまして、oribeです。

メルカリの「Online Summer Internship for Gophers 2020」に参加しました。 mercan.mercari.com

前半でGoの静的解析についての講義を受け、後半の開発期間で静的解析を行うツールの開発を行いました。
講義資料はこちらで公開されています。 engineering.mercari.com

gentest

概要

後半のツール開発ではGoの関数からテストコードの雛形を生成するツールを作りました。 github.com

gentestは対象としたい関数のファイル内でのオフセットを与えると、その関数の引数・返り値に合わせたテストコードの雛形を生成します。

package sample

func hello(s string) string {
    return "Hello " + s
}
$ go vet -vettool=`which gentest` -gentest.offset=21 sample.go

func TestHello(t *testing.T) {

        type input struct {
                s string
        }
        type expected struct {
                gotString string
        }
        tests := []struct {
                Name     string
                Input    input
                Expected expected
        }{
                // TODO: Add test cases.
        }

        for _, test := range tests {
                test := test
                t.Run(test.Name, func(t *testing.T) {

                        gotString := hello(test.Input.s)

                        assert.Equal(t, test.Expected.gotString, gotString)
                })
        }
}

Table Driven Testの雛形が生成されるので、// TODO: Add test cases.の箇所に具体的なテストケースを記述するだけでテストコードが完成します。
引数・返り値が複数ある関数や返り値にerrorを含む関数、メソッドなどにも対応しています。
具体的なケース別の出力についてはREADMEに記載しています。

なお、生成されるテストコードではtestifyのassertパッケージを使用しています。

公式の拡張機能で生成されるテストコードとの違い

VSCodeのGoの公式拡張機能でも関数を指定してのテストコードの雛形生成が行えます。(Golandでも同様の機能があるそうです)
私は普段VSCodeで開発しているのですが、この雛形生成機能の存在はこのツールがある程度形になってきた段階で初めて知りました。
こういったツールを開発している人はすでにいるだろう、とは思っていたものの公式で存在していたとは……と少々落ち込みました。
ので、「この公式の拡張機能よりも使いやすいものを!」という目標を途中から追加しました。
具体的にどのような違いがあるのかを紹介します。

testifyを使用

上述の通りgentestで生成されるコードはtestifyを使用しています。
対して公式の拡張機能で生成されるコードは標準パッケージのみで構成されています。
テストコードにtestifyを使用するべきか否かは主義が別れるところになりそうですが、testifyを使用したテストコードを書きたい人にはgentestがオススメです。

並行実行されるテストコードを生成できる

実行の際に-gentest.parallel=trueを追加すると、t.Parallelt.Cleanupを追加した並行に実行されるテストコードが生成されます。

package sample

func hello(s string) string {
    return "Hello " + s
}
go vet -vettool=`which gentest` -gentest.offset=21 -gentest.parallel=true sample.go

func TestHello(t *testing.T) {
        t.Parallel()
        type input struct {
                s string
        }
        type expected struct {
                gotString string
        }
        tests := []struct {
                Name     string
                Input    input
                Expected expected
        }{
                // TODO: Add test cases.
        }

        for _, test := range tests {
                test := test
                t.Run(test.Name, func(t *testing.T) {
                        t.Parallel()
                        t.Cleanup(
                        // TODO: Add function.
                        )
                        gotString := hello(test.Input.s)

                        assert.Equal(t, test.Expected.gotString, gotString)
                })
        }
}

ポインタレシーバを変更するメソッドに対応

ポインタレシーバのフィールド、またはポインタレシーバそのものの値を変更するメソッドが対象となった場合、変更後のレシーバの状態を記述するUsedExpectedが自動で追加されます。
なおメソッド内で呼び出される関数やメソッドによる変更の有無までは追いません。

package sample

type T struct {
    Hoge string
}

func (t *T) Method() string {
    t.Hoge = "hoge"
    return t.Hoge
}
func TestT_Method(t *testing.T) {

        type expected struct {
                gotString string
        }
        tests := []struct {
                Name        string
                Use         *sample.T
                Expected    expected
                UseExpected *sample.T
        }{
                // TODO: Add test cases.
        }

        for _, test := range tests {
                test := test
                t.Run(test.Name, func(t *testing.T) {

                        gotString := test.Use.Method()

                        assert.Equal(t, test.Expected.gotString, gotString)
                        assert.Equal(t, test.UseExpected, test.Use)
                })
        }
}

ツールの仕組み

まず与えられたoffsetに対応する関数のast.FuncDeclast.FileのフィールドDeclsの中から探します。
ast.FuncDeclに対応するtypes.Signatureを取得し、そのフィールドから引数や返り値、レシーバの情報を取得します。
そうして得られた情報を元にtext/templateなどを使用した文字列処理でコード生成を行い、最後にgo/formatの関数Sourceでフォーマットをかけてから出力します。

実装したコードの雛形はskeletonで生成しました。 github.com skeletonで雛形を生成すると、メインの静的解析を行う部分を書くだけでツールが完成させることができます。

今後の展望

実行時に対象としたい関数のオフセットがわかっている必要があるため、そのままCLIツールとして使用するにはやや不便であるというのが現状です。
いずれgentestを組み込んだVSCode拡張を作ってその点を解消できればいいなと考えています。

終わりに

Goの静的解析を行うことが初めてだったため、前半の講義で学んだことを組み合わせて作り上げて行きました。
Goは標準パッケージで得られる情報が大変豊富なので、ある程度使い方を習得してからは快適に実装を進めることができました。
最終的には自分でも「今後使って行きたい」と思えるようなツールになったのでとても嬉しいです。

講義資料の内容は初めはわからないことだらけでしたが、わかりやすい講義と合間に挟まれる演習のおかげで段々と理解できるようになっていったのが楽しかったです。
開発中はメンターさんに的確なアドバイスや質問対応、丁寧なコードレビューをしていただけました。
またコードレビューの際にはgolden testingといったコード生成のためのテストを行いやすくする手法なども教えていただき、静的解析以外の領域の学びも多く得ることができました。

たった5日間だったとは信じられないほど密度の高い、充実したインターンでした。
社員の皆さん、ありがとうございました!!!