gotest2rdf: 失敗したGoのテストをreviewdogで報告するためのユーティリティを作った

# gotest2rdfの紹介

github.com

![reviewdogがテストの失敗をGitHubの行コメントで報告している様子](https://raw.githubusercontent.com/aereal/gotest2rdf/main/docs/image.png)

gotest2rdfというツールを作りました。go testの出力を受け取り、Reviewdog Diagnostic Formatに変換します。

これをreviewdogに渡すことで失敗したテストやスキップしたテストをPull Requestに行コメントで報告してくれます。

もちろんreviewdogのreporterを指定すればChecksとして報告もできますし、GitHub以外のサービスも対応できます。

ビルドジョブの失敗通知でテストが失敗したことがわかったけど、じゃあどこが落ちとるねんということを知るためにいちいちジョブの出力を見にいくのは地味に手間なので、元々linterなどで活用していたreviewdogを使えないかと考えたことがきっかけです。

# reviewdogとは

GitHub - reviewdog/reviewdog: 🐶 Automated code review tool integrated with any code analysis tools regardless of programming language

> Automated code review tool integrated with any code analysis tools regardless of programming language.

……ということでlinterなどの解析結果を報告してくれるツールです。

おおまかな仕組みとしては、ファイル・行・列・メッセージからなる解析結果を渡すと、コードをホスティングしているサービスのAPIを呼び出していいかんじに見せてくれます。

GitHubのPull RequestやGitLabのMerge Request画面で「この変更ではこうした解析が得られましたよ」という情報を見せることで、開発者にコード改善の示唆を与えてくれます。

典型的には、いわゆるlinterと呼ばれるよりよい書き方へ統一するよう支援してくれるツールの報告に使われることが多いです。

しかし用途はlinterに限られずコードを解析した結果をコードの変更に紐付けて表示するためならなんでも使えます。自動テストもコードの品質を担保する役割も担うことから広義のソフトウェア解析といえるでしょう。

何より既に述べたように、どのテストがどのように失敗したのか、できるだけ画面遷移を減らして知れるとよいですし、不可解な失敗に遭遇した時にレビュアーとのあいだでコミュニケーションする良い機会にもなります。

# Reviewdog Diagnostic Format (RDF) について

ReviewdogはVimのerrorformatなどいくつかサポートしていますが、独自に定義した[Reviewdog Diagnostic Format](https://github.com/reviewdog/reviewdog/tree/master/proto/rdf)という形式もサポートしています。

詳しい仕様やモチベーションはREADMEに譲りますが、機械的に解析しやすく特定の言語やツールに依存しないポータブルかつリーズナブルなフォーマットが世の中に存在しなかったので定義した、ということです。

Protocol BuffersおよびJSON Schemaが公開されているので、準拠も楽です。

# go testの-jsonオプション

go testには `-json` というオプションがあり、これは名前から想像できるようにテスト結果をJSON形式で出力してくれます。
正確にはいわゆるJSON Linesに近いもので、JSON表現を改行区切りにしたものです。

go testのプレーンテキスト出力はパースしやすいとは言えませんし、そもそも仕様が定かなのか・安定しているのかは不明ですし、 `-json` オプションがあるのにわざわざプレーンテキストをパースする意味もありません。

# gotest2rdfの実装について

ここまででgo test -jsonで出力されたJSON LinesをReviewdog Diagnostic Formatに変換してあげれば、失敗したテストをreviewdog経由で報告することができそうだということがわかりました。

しかし単純な変換ができるわけではありません。

go testはテストスイートごとに成功 (pass), 失敗 (fail), スキップ (skip) のいずれかが記録されるのみで、失敗やスキップと理由が直接紐付けられているわけではありません。

よく使う `t.Errorf` などの関数は実は `t.Logf(...); t.Fail()` とほぼ同じです。実際、go testのJSON出力でactionがfailのmessageはテストスイートの名前などが含まれるのみで、 `t.Errorf` に渡した文字列などは含まれません。

渡した文字列はfailより前にoutputというactionのイベントとして記録されます。

`t.Logf()` などの出力はoutputというイベントとして記録されることと `t.Errorf` が `t.Logf(); t.Fail()` と同等ということを踏まえると理解はできるのですが、これが便利かというとそうではありません。

大抵の場合は `t.Errorf` に渡した文字列でアサーションが失敗した理由について詳しく報告しているので、この出力も解析結果として含まれていてほしいはずです。

なのでgotest2rdfではある程度outputイベントをバッファリングしておき、failやskipなどに遭遇したらその時点までに見つかったoutputイベントの出力をfailやskipの補足情報だとみなしてRDFのmessageに含めます。

バッファリングではデフォルトで3行です。これはとても長い出力を無闇にメモリ上に確保することによるパフォーマンスの劣化を気にした……というわけではなく、出力の混同をできるだけ抑えるためです。

以下のようなコードを考えます:

```go
import "testing"

func TestBlah(t *testing.T) {
t.Log("supplemental info")
if 4%2 != 0 {
t.Error("the number theory system maybe corrupted")
}
}
```

5行目のアサーションが失敗した場合、書き手が意図するアサーション失敗の情報はthe number theory system maybe corruptedだけのはずです。

しかし4行目のsupplemental infoも単なるoutputというイベントであり区別は難しいです。

なのでヒューリスティックにバッファリングしておき、それまでに登場した出力をすべて関連あるものとみなすという手段をとっています。

ファイルの内容を読み取って構文解析すればもう少し精度は上げられるかもしれませんが、けっきょく仕様上、failとoutputの関連付けがなされない以上ヒューリスティックに頼るほかありませんし、関係ないかもしれない出力が多少混じっても実用上はそんなに困らないので実装のリーズナブルさをとりました。

# むすび

とても便利なのでぜひご利用ください。