GoでDBのテストにgithub.com/cockroachdb/copyistを使う

久しぶりにMySQLを使ったシステムを触ることになったものの、現状のテストが遅すぎたので高速化に取り組むことにした。

遅い原因としては、

  • テストでDBを使うパッケージが多いので接続時間がそれなりになってしまう
  • テストケースごとにTRUNCATE→INSERTでデータを入れ直している

といったところ。
なので今回はDBにアクセスしないでテストができるようになるcopyistを試してみようと思う。

github.com

仕組みとしては実行されたSQLを記録しておき、以降はその時のデータを使うという

などと似たようなものとなる。

README.mdにはMySQLは非対応と書いてあったが、普通に使うことができた。
(もしかしたら一部正しく記録/再生できないクエリがあるのかもしれない)

MySQL版のサンプルコードはこちら。

mysql_test.go

package example

import (
    "database/sql"
    "log"
    "testing"

    "github.com/cockroachdb/copyist"
    _ "github.com/go-sql-driver/mysql"
)

// GetName をテストする。
func GetName(db *sql.DB, id int64) (string, error) {
    var name string
    err := db.QueryRow("SELECT name FROM `user` WHERE id=?", id).Scan(&name)
    return name, err
}

// resetDBで使うためにグローバル変数にしておく。
var db *sql.DB

// 記録時にcopyist.Open()の中で実行される。
func resetDB() {
    db.Exec("DROP TABLE `user`")
    db.Exec("CREATE TABLE `user` (`id` INT(11) unsigned NOT NULL AUTO_INCREMENT, `name` VARCHAR(20) NOT NULL, PRIMARY KEY (`id`))")
    db.Exec("INSERT INTO `user` (id, name) VALUES (?, ?)", 1, "Andy")
}

func TestMain(m *testing.M) {
    // ドライバ名は"copyist_mysql"になる。
    copyist.Register("mysql", resetDB)

    // resetDBとテストで使うdbを作る。
    var err error
    db, err = sql.Open("copyist_mysql", "admin:pass@/copyist")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    m.Run()
}

func TestGetName(t *testing.T) {
    defer copyist.Open(t).Close()

    // found
    name, err := GetName(db, 1)
    if err != nil {
        t.Fatal(err)
    }
    if name != "Andy" {
        t.Error("failed test")
    }

    // not found
    if _, err := GetName(db, 100); err != sql.ErrNoRows {
        t.Error(err)
    }
}

// subtest版
func TestGetName_subtest(t *testing.T) {
    t.Run("found", func(t *testing.T) {
        defer copyist.Open(t).Close()

        name, err := GetName(db, 1)
        if err != nil {
            t.Fatal(err)
        }
        if name != "Andy" {
            t.Error("failed test")
        }
    })

    t.Run("not found", func(t *testing.T) {
        defer copyist.Open(t).Close()

        if _, err := GetName(db, 100); err != sql.ErrNoRows {
            t.Error(err)
        }
    })
}

1回目は以下のように -record をつけてテストを実行する。

$ go test -v -record
=== RUN   TestGetName
--- PASS: TestGetName (0.09s)
=== RUN   TestGetName_subtest
=== RUN   TestGetName_subtest/found
=== RUN   TestGetName_subtest/not_found
--- PASS: TestGetName_subtest (0.12s)
    --- PASS: TestGetName_subtest/found (0.06s)
    --- PASS: TestGetName_subtest/not_found (0.06s)
PASS

そうするとtestdataディレクトに以下のようなmysql_test.copyistが生成される。

1=DriverOpen 1:nil
2=ConnPrepare   2:"SELECT name FROM `user` WHERE id=?"  1:nil
3=StmtNumInput  3:1
4=StmtQuery 1:nil
5=RowsColumns   9:["name"]
6=RowsNext  11:[10:QW5keQ]  1:nil
7=RowsNext  11:[]   7:EOF

"TestGetName"=1,2,3,4,5,6,2,3,4,5,7
"TestGetName_subtest/found"=1,2,3,4,5,6
"TestGetName_subtest/not_found"=1,2,3,4,5,7

この状態で -record をつけずに実行すると↑のデータが使われる。

$ go test -v
=== RUN   TestGetName
--- PASS: TestGetName (0.00s)
=== RUN   TestGetName_subtest
=== RUN   TestGetName_subtest/found
=== RUN   TestGetName_subtest/not_found
--- PASS: TestGetName_subtest (0.00s)
    --- PASS: TestGetName_subtest/found (0.00s)
    --- PASS: TestGetName_subtest/not_found (0.00s)
PASS

なお、記録する前に -record 無しで実行するとpanicになる。

$ go test -v
=== RUN   TestGetName
--- FAIL: TestGetName (0.00s)
panic: no recording exists with this name: TestGetName [recovered]
        panic: no recording exists with this name: TestGetName

また、以下のようにクエリを変えて、

--- a/mysql_test.go
+++ b/mysql_test.go
@@ -12,7 +12,7 @@ import (
 // GetName をテストする。
 func GetName(db *sql.DB, id int64) (string, error) {
        var name string
-       err := db.QueryRow("SELECT name FROM `user` WHERE id=?", id).Scan(&name)
+       err := db.QueryRow("SELECT name FROM `user` WHERE id=? AND TRUE", id).Scan(&name)
        return name, err
 }

記録し直さずにそのまま実行してもpanicになる。

$ go test -v
=== RUN   TestGetName
--- FAIL: TestGetName (0.00s)
panic: mismatched argument to ConnPrepare, expected SELECT name FROM `user` WHERE id=? AND TRUE, got SELECT name FROM `user` WHERE id=? - regenerate recording [recovered]
        panic: mismatched argument to ConnPrepare, expected SELECT name FROM `user` WHERE id=? AND TRUE, got SELECT name FROM `user` WHERE id=? - regenerate recording

ただし、プレースホルダーの値が変わっても返ってくるレコードは変わらないのでその点は注意が必要。
例えば以下のようにidを逆にしてもテストはPassしてしまう。

--- a/mysql_test.go
+++ b/mysql_test.go
@@ -45,7 +45,7 @@ func TestGetName(t *testing.T) {
        defer copyist.Open(t).Close()

        // found
-       name, err := GetName(db, 1)
+       name, err := GetName(db, 100)
        if err != nil {
                t.Fatal(err)
        }
@@ -54,7 +54,7 @@ func TestGetName(t *testing.T) {
        }

        // not found
-       if _, err := GetName(db, 100); err != sql.ErrNoRows {
+       if _, err := GetName(db, 1); err != sql.ErrNoRows {
                t.Error(err)
        }
 }
@@ -63,7 +63,7 @@ func TestGetName_subtest(t *testing.T) {
        t.Run("found", func(t *testing.T) {
                defer copyist.Open(t).Close()

-               name, err := GetName(db, 1)
+               name, err := GetName(db, 100)
                if err != nil {
                        t.Fatal(err)
                }
@@ -75,7 +75,7 @@ func TestGetName_subtest(t *testing.T) {
        t.Run("not found", func(t *testing.T) {
                defer copyist.Open(t).Close()

-               if _, err := GetName(db, 100); err != sql.ErrNoRows {
+               if _, err := GetName(db, 1); err != sql.ErrNoRows {
                        t.Error(err)
                }
        })

細かい部分はまだ何とも言えないが、とりあえず使えそうだし高速化にも期待ができそう。

さて、あとはこれをどうやって既存のテストに組み込んでいくか。
既存のテストではxormが使われているが、copyistに対応していないので少し特殊な初期化をしないといけないようだ。

engine, err := xorm.NewEngine("mysql", "")
// エラーハンドリングやengineの設定など
// ...

// DBをsql.Open("copyist_mysql", ...)したものと差し替える
engine.DB().DB = db

これを何とかするのが一番大変なのかもしれない...

ISUCON10に初参戦してみた

ゴリラさんと出てみようと話していたものの、申し込みのタイミングを逃してオワタと思っていたら@inductorさんに拾ってもらい、カンガルーと犬とゴリラとしてなんとかISUCON10に参加することができました。

当日は開始ちょっと前*1にDiscordとGoogleドキュメントでコミュニケーションすることを決め、 開始直後は

  • 環境を整備する
  • アプリを見る
  • ドキュメントを見る

といった感じで役割分担。

30分くらい経って、これからはベンチマークを走らせながらどこから手を付けるか考えようと思っていましたがトラブルのためなかなか実行できず、New Relicの設定をしたあとはしばらくWebアプリをポチポチしたりホストの中をウロウロしたりしていました。

ベンチマークが走ってからは、

  • 検索系
    • インデックス
    • DB分割
      • 最終的にはApp1台 + DB2台に
  • nazotte
    • LIMITでループを抜ける
    • それ以上のチューニングは結局できず...
  • low_priced
    • 静的に返したり、キャッシュしたり
  • botからのアクセス
    • nginxで弾く

をなんとかすることに。
これでスコアが480→805になったのがだいたい19:00で、そこから都合により20:20くらいまで離席...

戻ってきたタイミングで検索系がさらに最適化されてスコアは1280まで伸びましたが、最終的には1244で競技終了の21:00になりました。

以前に参加した社内ISUCONでは個人でもチームでもスコアアップに繋がるようなことがほとんどできなかったので*2、それに比べれば多少はできるようになったかもしれませんが、まだまだ力不足*3なことを痛感しました。。。

*1:12:20開始になったので11:50くらい

*2:初期スコアからほぼ変わらなかったはず

*3:特にMySQL、そしてインフラは完全にinductorさん任せ!

goplsのSymbolMatcherとSymbolStyleオプション

以下の記事で、workspace/symbolのオプションは補完と同じmatcherと紹介したが、現在のv0.4.4ではsymbolMatchersymbolStyleになっている。 daisuzu.hatenablog.com

まずはv0.4.1matchersymbolMatcherに変わり、それまでと同様、

  • fuzzy
  • caseSensitive
  • caseInsensitive(デフォルト)

の3つが補完と独立して設定できるようになった。

その後、v0.4.4symbolStyleが追加され、

  • package(デフォルト)
  • full
  • dynamic

を指定できるようになった。

packageの場合は今までと同様、パッケージ名をクエリに含める際にはコードで使う時のようにcontext.Contextといった形式で検索する。

新たに追加されたfullではgolang.org/x/net/context.Contextのようにすることで同じパッケージ名の別モジュールを区別することが可能になった。

dynamicはpackage→fullの順番でマッチさせるため、パッケージ名を含んだクエリはpackageと同じ結果になり、インポートパスから指定したクエリはfullと同じ結果になる。

Goでprotobufの定義をimportせずにフィールドの値を使う

以下のようなコードでreqにあるフィールドを使いたい場合、

opt := grpc.WithUnaryInterceptor(func(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
    // ここでreqから特定のフィールド値を取得したい
    return nil
})

型が定義されているパッケージをインポートして、

if v, ok := req.(*pb.Req); ok {
    // v.Bodyでフィールドにアクセスできる
}

のように変換することになる。

この時、インポートしたくない or インポートできないのであれば、protocが生成する定義にはGetterも同時に生成されるため、対象フィールドのGetterがあるinterfaceで型アサーションすると手軽にできる。

if v, ok := req.(interface{ GetBody() []byte }); ok {
    // v.GetBody()でフィールドの値を取得できる
}

あまり使い所はないかもしれないが、google.golang.org/appengineは定義がinternal配下にあるため、例えばテストでWithAPICallFuncを使う際など、以下のようにしてtaskqueue.Addのリクエスト(TaskQueueAddRequest)からフィールドの値を取得できる。

var got []byte
ctx = appengine.WithAPICallFunc(ctx, func(ctx context.Context, service, method string, in, out proto.Message) error {
    if service == "taskqueue" && method == "Add" {
        if v, ok := in.(interface{ GetBody() []byte }); ok {
            got = v.GetBody()
        }
    }
    return nil
})
DoSomething(ctx) // 関数内でtaskqueue.Add()が呼ばれる

if !reflect.DeepEqual(got, want) {
    t.Errorf("body = %q, want %q", got, want)
}

なお、フィールドがインポートできない型になっているとinterfaceも作れないのでこの方法は使えない。
それでも必要な場合はencoding/jsonパッケージでMarshalしてからmap[string]interface{}や独自型にUnmarshalすれば良い。

gopls(v0.4.0)の機能

goplsは更新頻度が高く、何ができるのかをちゃんと把握できていなかったので、LSPのメソッドベースで今の時点(v0.4.0)の機能をざっと調べてみることにしました。

補完(textDocument/completion)

入力中の変数や定数、関数といった各種定義などをbuiltinも含めて補完してくれます。
この機能のためにgoplsを使っているという人もけっこう多いはず。

基本的に文脈に合わせた候補を返したり、優先度が高くなるようになっていますが、

  • おかしな候補が返ってくる
  • 期待する候補が返ってこない

という場合はコントリビュート(issue報告や修正する)チャンスかもしれません。

例えば、

  • スコープに存在しない定義は候補にならない
    • スコープ外の変数など
  • カーソル位置で使えないキーワードは候補にならない
    • iota: const入力中のみ
    • range: for入力中のみ
    • break: for、switch、select内
    • continue: for内
    • など
  • 型が合わないものは候補から外されることがある
    • 代入時

のような制御があります。

また、以下の場合は補完で入力される内容自体が変化します。

  • 参照が要求される場合、値には&がつく
// vへの代入時、
var v *int
i := 1
v = // iは&iになる

// func f(i *int)を呼び出す際、
f( // iは&iに変換される
  • 値が要求される場合、参照には*がつく
// iへの代入時、
var v *int
var i int = // vは*vになる

// func f(i int)を呼び出す際、
f( // vは*vに変換される
  • 可変長引数の場合に...が展開される
func fn(v ...int) {}

func do(v []int) {
    fn( // vはv...になる
}
  • 引数の型がわかる場合はその型が補完される
    • func(...[]int) を呼び出す際は[]int{}が候補になる
    • var _ []int = make()の第1引数は[]intが候補になる

ただし、プレースホルダ*1スニペット*2が有効になっていないと変換されなかったり、候補として表示されません。
VS Codeはデフォルトで有効になっているはずですが、Vimの場合はプラグインによっては有効になっていないかもしれません。
そしてシグネチャを補完・展開する際にはこれらが必須となっているため、もしうまく動かない時は設定を確認してみましょう。

他にも、次の設定で補完の動作を変えることができます。

  • initializationOptions.matcher
    • デフォルトは"fuzzy"なので多少違っている候補も返ってきますが、
    • "caseSensitive""caseInsensitive"に変更できます
  • initializationOptions.completeUnimported
    • デフォルトはtrueなので未importのパッケージからも候補が返ってきますが、
    • falseで無効化できます
  • initializationOptions.deepCompletion
    • デフォルトはtrueなので構造体のフィールドも候補として返ってきますが、
    • falseで無効化できます
// deepCompletion=trueだと

type param struct{ i int }

func fn(i int) {}

func do(p param) {
    fn( // p.iが候補として表示される
}

それ以外の特殊な動作としては、

  • if err != nilスニペット展開
    • errorを返す関数内のみ
  • 公開されるvarのgodoc補完
    • 次のバージョンではconstfunctypeも対象になる

があります。

ジャンプ系

自分の場合は補完よりこちらを多用しています。
ただ、definitiontypeDefinitionは違いがわからないまま使っていました。

textDocument/definition

カーソル位置にあるシンボルの定義元にジャンプする時に使用します。
関数内の変数はその関数内で定義(宣言)された場所にジャンプします。

type myType struct{}

func fn() {
    t := myType{}    // ←
    t.method()       // tが宣言されたのは1行上
}

コマンドラインからはgopls query definitionsで使えます。
※次のバージョンからqueryが不要になる

gopls query definition internal/lsp/definition.go:14:67

textDocument/typeDefinition

カーソル位置にあるシンボルの型定義にジャンプする時に使用します。
関数内の変数は実際の型のある場所にジャンプします。

type myType struct{} // ←

func fn() {
    t := myType{}
    t.method()       // tの型定義はfnの上にあるmyType
}

textDocument/implementation

カーソル位置にあるシンボルの、

  • インタフェースから型
  • 型からインタフェース

にジャンプする時に使用します。

コマンドラインからはgopls implementationで使えます。

gopls implementation internal/lsp/implementation.go:14:10

textDocument/references

カーソル位置にあるシンボルが参照されている(使われている)場所にジャンプする時に使用します。

コマンドラインからはgopls referencesで使えます。

gopls references internal/lsp/references.go:14:18

textDocument/documentSymbol

現在開いているファイルのシンボル一覧を取得します。
結果はジャンプするためや、ファイルのアウトライン表示のためにも使われます。

コマンドラインからはgopls symbolsで使えます。
CLIでうまく動かないhttps://go-review.googlesource.com/c/tools/+/232557

workspace/symbol

プロジェクト(リポジトリ本体と依存パッケージ)からシンボル一覧を検索します。
※LSPの仕様では依存パッケージは含まれないはずですが、その方が便利なのでそういう実装になっています

今のところ結果は100件までに制限されています。
※一度に全部返してしまうとクライアントが高負荷で固まってしまうことがあるため

また、検索方法は補完と同じでinitializationOptions.matcherに従います。

コマンドラインからはgopls workspace_symbolで使えます。

gopls workspace_symbol WorkspaceSymbols

Vimからはtagfunc経由でctagsのように使うこともできます。
設定例は下記参照。 daisuzu.hatenablog.com

その他

Diagnostic以外はほとんど使っていませんでした。。。
しかし、今回調べたことでrenamegorenameの代わりになるくらい完成度が高くなっていたり、SuggestedFixの種類がだんだん増えていることがわかったのは大きな収穫でした。

Diagnostic

go vet(golang.org/x/tools/go/analysis/passes/...)やgofmt -sstaticcheckなどのチェックを行います。
実行するチェッカーは以下のオプションでカスタマイズできます。

  • initializationOptions.analyses
  • initializationOptions.staticcheck

結果にSuggestedFixが含まれている場合、codeAction経由で修正可能です。

SuggestedFixの例:

  • returnの返り値の数があっていない時に追加したり、削除したり
  • 2回目以降の:==に変更したり
  • 未定義変数の<変数名> :=を挿入したり

コマンドラインからはgopls checkで使えます。

gopls check internal/lsp/testdata/lsp/primarymod/analyzer/bad_test.go

textDocument/codeAction

SuggestedFixの実行やimportの追加・削除(go.modへの追加も含む)を行います。

コマンドラインからはgopls fixgopls importsで使えます。

textDocument/codeLens

調べてもイマイチよくわかっていない機能ですが、現状は//go:generateコメントのある行でgo generateが実行できるよ、という情報を返してくれるようです。
また、go.modを開いている場合は依存パッケージのアップデートができるかどうかを教えてくれるようです。

textDocument/hover

カーソル位置のシグネチャや型などの情報を返してくれます。

textDocument/signatureHelp

カーソル位置のシグネチャやヘルプを返してくれます。

コマンドラインからはgopls signatureで使えます。
<position>の指定方法がわからず...は関数呼び出しの()内の位置を指定する必要がある

gopls signature internal/lsp/signature_help.go:21:53

textDocument/documentHighlight

カーソル位置のシンボルが使われている場所をハイライトします。

コマンドラインからはgopls highlightで使えます。

gopls highlight internal/lsp/highlight.go:17:2

現在開いているファイルがimportしているパッケージの https://pkg.go.dev へのリンクや、 <org>/<repo>#<number>形式のコメントからGitHub Issuesへのリンクを返してくれます。

コマンドラインからはgopls linksで使えます。

gopls links internal/lsp/link.go

textDocument/formatting

現在開いているファイルを整形します。

コマンドラインからはgopls formatで使えます。

# -dでdiffを表示する
gopls format -d internal/lsp/testdata/lsp/primarymod/format/bad_format.go.in

textDocument/rename

カーソル位置のシンボルをリネームします。

コマンドラインからはgopls renameで使えます。

# -dでdiffを表示する
gopls rename -d internal/lsp/rename.go:14:18 doRename

リネーム可能かどうかを調べるためにはtextDocument/prepareRenameで確認できます。
コマンドラインからはgopls prepare_renameで使えます。

# rangeが表示されればリネーム可能
gopls prepare_rename internal/lsp/rename.go:14:18

textDocument/foldingRange

コードを折り畳む範囲を返してくれます。

コマンドラインからはgopls folding_rangesで使えます。

gopls folding_ranges internal/lsp/folding_range.go

workspace/executeCommand

codeLensの結果からgo generatego getの実行をしてくれるようです。
また、diagnosticの結果からgo mod tidyの実行をしてくれるようです。

未実装

以下のメソッドはまだ実装されていません。

*1:initializationOptions.usePlaceholders = true

*2:capabilities.textDocument.completion.completionItem.snippetSupport = true

GoでREST APIを呼ぶテストにhttp.FileServerを使う

例えばGET /users/:idのようなAPIの場合、

testdata
└── users
    └── 1

のように、testdata配下のusersディレクトリにidをファイル名としたJSONファイルを配置しておきます。

そうすると、

http.FileServer(http.Dir("testdata"))

で、testdata配下のパスとリクエストのパスが一致するファイルが

  • 存在すれば200 OKでそのファイルの中身
  • 存在しなければ404 Not Found

を返すサーバが作れます。

http.Get(os.Getenv("API_URL") + "/users/" + strconv.FormatInt(id, 10))

のような呼び出し方をしていればhttptestを使って次のようにテストが書けます。

package api

import (
    "net/http"
    "net/http/httptest"
    "os"
    "reflect"
    "testing"
)

func Test_getUser(t *testing.T) {
    ts := httptest.NewServer(http.FileServer(http.Dir("testdata")))
    defer ts.Close()

    if err := os.Setenv("API_URL", ts.URL); err != nil {
        t.Fatal(err)
    }
    defer os.Unsetenv("API_URL")

    type args struct {
        id int64
    }
    tests := []struct {
        name    string
        args    args
        want    *user
        wantErr bool
    }{
        {
            name:    "OK",
            args:    args{id: 1},
            want:    &user{ID: 1, Name: "Alice"},
            wantErr: false,
        },
        {
            name:    "NotFound",
            args:    args{id: 2},
            want:    nil,
            wantErr: true,
        },
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := getUser(tt.args.id)
            if (err != nil) != tt.wantErr {
                t.Errorf("getUser() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if !reflect.DeepEqual(got, tt.want) {
                t.Errorf("getUser() = %v, want %v", got, tt.want)
            }
        })
    }
}

POSTメソッドの場合、

  • エンドポイントがPOST /usersのような形式になるのと、
  • 201 Created500 Internal Server Errorが返せないため、

http.FileServerよりはhttp.HandlerFuncを使った方が良いでしょう。
また、リクエストパスとファイルが1対1にならない時も同様です。

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のネタにするかもしれません。