この記事はGROOVE X Advent Calendar 2024の19日目の記事です。
こんにちは、QAチームに異動してQA自動化に取り組んでいる Junya です。
最近、 Qase というテスト管理ツールをお試しで利用しているのですが、 go test の実行結果をテストケースに紐づけて Qase へアップロードするのに苦労したので、そのお話をします。
Qase についての詳細は割愛するので、詳細が知りたい方は公式ドキュメントなどを参照してください。
実現したいこと
- テスト実行時のログをキャプチャしたい
t.Runの結果をテストケースのステップに紐づけて保存したい(またの機会に)- パラメタライズドテストの結果をQaseのパラメータに紐づけたい(またの機会に)
Qase 連携の2つのアプローチ
go test の結果を Qase 連携する方法として、2つのアプローチがあります。
go testやgotestsumを実行してから、CLI を使って Qase にアップロードするgo test実行中のログ出力をキャプチャして、Qase にアップロードする
前者は手軽ですが、go test にきめ細やかなアノテーションの仕組みなどがない関係上、Qase のステップやパラメータなどの概念を活かし切ることが出来ません。
Qase の機能をフル活用するために、今回は後者のアプローチでトライすることにしました。
前者の方法に興味のある方は、こちらのドキュメントを参照してください。
テスト実行時のログをキャプチャする
結論から書いてしまいますね。以下のような構造体を用意して、標準出力の内容を buf へ横取りし、 TestMain と各テストの t.Cleanup で呼び出すことで解決しました。
type StdoutCapturer struct { r, w *os.File stdout *os.File buf *bytes.Buffer lastIndex int } // NewStdoutCapturer 標準出力をキャプチャするための構造体を生成する func NewStdoutCapturer() *StdoutCapturer { r, w, err := os.Pipe() if err != nil { log.Fatal(err) } stdout := os.Stdout os.Stdout = w var buf bytes.Buffer // buf と stdout の両方に書き込む tee := io.TeeReader(r, stdout) go func() { _, _ = buf.ReadFrom(tee) }() return &StdoutCapturer{r: r, w: w, stdout: stdout, buf: &buf} } func (c *StdoutCapturer) Close() { _ = c.w.Close() _ = c.r.Close() os.Stdout = c.stdout } // Flush 現時点までに出力された内容を返す func (c *StdoutCapturer) Flush() string { full := c.buf.String() content := full[c.lastIndex:] c.lastIndex = len(full) return content }
TestMain の実装は以下のとおり。
var capturer testutil.Capturer func TestMain(m *testing.M) { // テストが開始されて `testing.T` が生成される前に標準出力を Capturer で置き換える capturer = testutil.NewStdoutCapturer() m.Run() capturer.Close() }
capturer をパッケージのグローバル変数で定義しておき、以下のようにテスト内の t.Cleanup で標準出力の内容を取得します。
t.Cleanup(func() { captured := capturer.Flush() UploadResult(t, captured) // テスト管理ツールに結果をアップロード })
ハマりポイント
いくつかハマりポイントがあったので紹介します。
テストの setup, teardown ではフックできない
テストパッケージのグローバル変数に capturer がいるのはイケてないので、以下のように各テストの setup, teardown で標準出力を横取りしたくなります。
func TestExample(t *testing.T) { capturer := testutil.NewStdoutCapturer() t.Cleanup(func() { captured := capturer.Flush() })
しかし、これは機能しませんでした。testing のこちらの箇所 で、そもそもテストに入る前に、 T オブジェクトのライターとして os.Stdout を紐づけているためです。
t := &T{
common: common{
signal: make(chan bool, 1),
barrier: make(chan bool),
w: os.Stdout,
ctx: ctx,
cancelCtx: cancelCtx,
},
tstate: tstate,
}
仕方がないので、 TestMain でテスト実行前に os.Stdout を書き換えるようにしました。
t.Cleanup では取得できないログがある(未解決)
go test -v を実行した際の、個別のテストに対する出力は以下のようになります。
1 === RUN TestExample
2 run_test.go:49: TestExample が呼ばれたよ
3 === NAME TestExample_Title
4 run_test.go:46: {"status":true,"result":{"case_id":1115,"hash":"xxx"}}
5 --- PASS: TestExample_Title (0.45s)
6 --- PASS: TestExample_Title/TestExample_Title (0.00s)
この中の4行目までは t.Cleanup でキャプチャできるのですが、5行目・6行目は、テスト実行後に go test がログ出力するので、 t.Cleanup では取りこぼしてしまいますが、いったんここは諦めることにしました。
取りこぼした分は、次のテストの実行結果に含まれてしまうので、以下のように Capturer を書き換えて、余計な文字列を除去しています。
// Flush 現時点までに出力された内容を返す func (c *StdoutCapturer) Flush() string { full := c.buf.String() content := full[c.lastIndex:] c.lastIndex = len(full) // Note: PASS, FAIL の判定結果は含まれず、次の Flush に入ってしまうので、一行ずつ読んで "=== RUN" までを無視する // TODO: PASS, FAIL の判定結果も Flush に含まれるようにする for { index := strings.Index(content, "\n") if index == -1 { break } line := content[:index] if strings.HasPrefix(line, "=== RUN") { break } content = content[index+1:] } return content }
t.Log をラップする
以下のようにログ出力をラップする仕組みも試しました。
package wrapper type Wrapper struct { t *testing.T } func T (t *testing.T) *Wrapper { return &Wrapper{t: t} } func (w *Wrapper) Log(args ...interface{}) { t.T.Helper() // ここに保存処理を書く t.T.Log(args...) }
以下のように使うイメージです。(testify.suite にも似た仕組みがあります)
wrapper.T(t).Log("...")
この場合、テストの中で明示的に t.Log を呼んでいる箇所は置き換えられるのですが、testing モジュールが内部的に出力するログはキャプチャできませんでした。
ただ、このアーキテクチャは有用なので、Qase のステップにログを紐づけたい場合に活用しています。
テスト並行実行時のふるまい(未検証)
os.Stdout を書き換え、各テストの終了時点でフックして標準出力をキャプチャしているので、テストが並行実行されると、キャプチャに複数のテストの内容が混在してしまう可能性があります。
いったん -p 1 オプション(並列数: 1)をつけて go test を実行することで、この問題を回避する予定です。
まとめ
os.Stdout を上書きする強引なアプローチによって、テスト時のログ出力をなんとか Qase (テスト管理ツール)にアップロードできました。
一方で、一部保存できないログがあったり、複数テスト同時実行時のふるまいに不安があったりするので、今後も改善していきたいと思います。
以下のような改善案があるかな、と思ってます。
- テスト実行時に標準出力を見張る、単一のサービスを動かし、出力をパースして管理する
- gotestsum の
go test -jsonによる json 出力などを活用する(デフォルトのログ出力より解析しやすい) gotestsumやqasectlの開発に貢献して、テスト実行結果 + CLI でも、よりきめ細やかなデータを連携できるようにする
パラメタライズドテストの連携や、Qase のテストケースのステップとの連携部分など、他にも話したいことが沢山あるのですが、それはまたの機会に。
一緒に働く仲間を募集中!
弊社では、そんな開発チームで働く仲間を募集中です。 ぜひご応募ください!