packagestestのExported.Expectを使ってGoのソースからマーカーを収集する

golang.org/x/tools/go/packages/packagestestパッケージは静的解析など、Goのソースを読み込んで何かしらの処理を行うようなツールのテスト用にダミープロジェクトを作るためのパッケージです。

そういったツールを作る際、テストで期待する結果を完全に決め打ちで_test.goに書いておいても良いかもしれませんが、パターンを増やす時にはダミーのソースとそれに対応する期待結果をそれぞれ追加していかなければいけないので、数が増えてくるとメンテナンスが大変になってしまいます。

そのため、ダミーのソースの中にマーカーを埋め込んでおいて、それを元に期待する結果をテスト時に組み立てるようにすると、パターンを追加する時の変更箇所が1つになり、またテストコード自体の見通しも良くなります。

具体例はgoplsのテストを見るのが良いですが、簡単にまとめると、

1) テスト関数の本体でpackagestest.TestAllを呼び、

func Test(t *testing.T) {
    packagestest.TestAll(t, test)
}

2) testの中でpackagestest.Exportを使ってダミープロジェクトをセットアップし、

// Moduleにはtestdataにあるモジュールを指定しても良い
exported := packagestest.Export(t, e, []packagestest.Module{
    {
        Name: "example.com/pkg", // モジュール名
        Files: map[string]interface{}{
            "foo/foo.go": `package foo

const Foo = 100 //@Foo, aaa(Foo, "const")
`,
            "bar/bar.go": `package bar

import "example.com/pkg/foo"

const Bar = foo.Foo * 10 //@bbb("Bar", Foo)
`,
        },
    },
})
defer exported.Cleanup()

3) その戻り値のExpectメソッドでマーカーを収集する、

if err := exported.Expect(map[string]interface{}{
    // @aaa()用
    "aaa": func(pos token.Pos, arg string) {
        t.Logf("pos = %v, arg = %v", pos, arg)
    },
    // @bbb()用
    "bbb": func(pos token.Pos, arg token.Pos) {
        t.Logf("pos = %v, arg = %v", pos, arg)
    },
}); err != nil {
    t.Fatal(err)
}

という流れになります。
あとは収集したマーカーを使って期待する結果を作ってあげればOKです。

ただExpectメソッドがどうやって位置に変換しているのか、少しわかりにくかったのでメモを残しておきます。

  • マーカーの文字列からtoken.Posに変換する場合
// OK: ↓をそのまま文字列にする
const Foo = 100 //@aaa("Foo", "const")

// NG: 同じ行にない場合はエラー
//@aaa("Foo", "const")
const Foo = 100
  • マーカーの識別子からtoken.Posに変換する場合
// OK:            ↓識別子をマーカーにしておく
const Foo = 100 //@Foo, aaa(Foo, "const")

// どこかにマーカーがあれば別の場所でも使える
const Bar = foo.Foo * 10 //@bbb("Bar", Foo)

// NG: Barはマーカーになっていないのでエラー
const Bar = foo.Foo * 10 //@bbb(Bar, Foo)
// NG: 識別子にドットは使えないのでfoo.Fooにはできない
const Bar = foo.Foo * 10 //@bbb("Bar", foo.Foo)

// OK: かわりにmarkで識別子の名前を"fooFoo"にしておくと、
const Foo = 100 //@mark("fooFoo", Foo)
// 別の場所でfooFooが使えるようになる
const Bar = foo.Foo * 10 //@bbb("Bar", fooFoo)
  • 別のマーカーを流用してtoken.Posに変換する場合
//                     ↓を
type A string //@type("AString", "A")
//                             ↓で識別子として使いたい
type Alias = A //@bbb("Alias", AString)

Expectbbbが呼ばれる前に、typeMarkするExpectを呼んでおく。

if err := exported.Expect(map[string]interface{}{
    "type": func(name string, r packagestest.Range, _ []string) {
        exported.Mark(name, r)
    },
}); err != nil {
    t.Fatal(err)
}

もし需要がありそうならゴリラ.Goのネタにするかもしれません。