octocovで実現できる3つのレポートコメントの方法

octocovはコードメトリクスを取得してPull Requestにレポートをコメントしたり、設定したデータストアに記録することができます*1

今回はoctocovで実現できる3つのレポートコメントの方法を紹介します。

Pull Requestへのレポートコメント

Pull Requestへレポートをコメントします。複数回レポートされる場合は過去レポートコメントを隠すようにしています。

octocov init で生成される .octocov.yml にも設定されるデフォルトのレポートコメントです。

設定は以下のような感じです。

comment:
  if: is_pull_request
  # hideFooterLink: false # レポートのフッタのリンクを消す
  # deletePrevious: true # 前回のレポートコメントを隠すのではなくて消す

GitHub Actions Job Summariesへのレポート

GitHub ActionsのJob Summariesにレポートを追加します。

github.blog

設定は以下のような感じです。

summary:
  if: true # 毎回Job Summariesにレポートを追加する

Pull Requestのボディへのレポートの差し込み

Pull Requestのコメントではなくボディに差し込む(随時差し込んだ内容を更新する)ようなレポートも可能です。

GtiHubとSlackを連動させているときにPull RequestコメントによるSlack通知がなくなるのがメリットかもしれません。

body:
  if: is_pull_request

以上、ちょっとしたTipsでした。

*1:設定上はデータストアへの記録を report: と表現しますが今回はコードメトリクスのレポートコメントの方を指します

tagprで実現するPull Request上で進めるOSSのリリースマネジメント

私の趣味は少し実用的で小さなOSSを書くことです。

今までも多くの小さなOSSを書いてきました。そして、エコシステム的にリリースすべきものはリリースしてきました。

ここで言うリリースと言うのはバージョンをつけてパッケージとしてPublishすることです。 PHPであればpackagist.orgに、Rubyであればrubygems.orgに、JavaScriptであればnpmjs.comにPublishするまでのことを指します。 Goであれば、バージョンのタグをつけてGitHubリポジトリにプッシュすればリリース完了です。必要であればアセットをGitHubのリリースページにアップロードします。

(自分にとって)適当で心地よいリリースマネジメント

私は基本的にすぐにリリースします。修正1つでもバージョンタグを打ってすぐにリリースすることが多いです。

これはリリースされることで初めて各言語のエコシステムに綺麗にのって利用できるようになるからであり、Pull RequestやIssueで貢献してくれた人(私を含む)にできるだけその結果をフィードバックしたいからです。

なのでバージョンが上がることには何も感じずどんどん上げていきます。

と、ここまで「ぽい」ことを書いてきましたが、なかなか上げないこともあり、それは自分でも言語化できていません。なんでだろう?

ようは適当なんだと思います。

関わる人にとって適当で心地よいリリースマネジメント

では私だけではなく関わる人にとって良いリリースマネジメントというのはどういうものか考えてみました。

  • リリースフローが自動化できていて
  • リリース権限を持っている人がいつでもリリースできるようになっていて
  • 自由にリリースして良いことが明らかになっている

なぜこのようなことを考えたかというと、tagprという便利なツールが現れて、かつ、私が作成したOSSに積極的にコントリビュートしてくれる方が現れたからです。前から考えていたわけではなく「なるほどそういうリリースマネジメントがあるのかも」と思ったのでした。

songmu.jp

リリースマネジメントビフォーアフター

さっそく環境を整備してみました。対象はGoのパッケージ/ツールのリリースマネジメントです。

Before

  • リリース ... GoReleaser
  • リリースアセット作成 ... GoReleaser
  • Dockerイメージ作成 ... GoReleaser
  • HomebrewのFormulaの更新 ... GoReleaser
  • CHANGELOG更新 ... ghch
  • 次のバージョン決定 ... git-semv
  • リリース環境 ... ローカル

基本は手元でmakeコマンドを叩いてリリースしていました。

After

  • リリース ... tagpr
  • リリースアセット作成 ... GoReleaser
  • Dockerイメージ作成 ... GoReleaser
  • HomebrewのFormulaの更新 ... maltmillを使ってPull型に
  • CHANGELOG更新 ... tagpr
  • 次のバージョン決定 ... tagpr
  • リリース環境はGitHub Actions

具体的なGitHub Actionsのワークフローは以下のようになっています。なんとなく雰囲気はわかるかと思います。

name: tagpr
on:
  push:
    branches:
      - main

jobs:
  tagpr:
    runs-on: ubuntu-latest
    outputs:
      tagpr-tag: ${{ steps.run-tagpr.outputs.tag }}
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - name: Check out source code
        uses: actions/checkout@v3

      - id: run-tagpr
        name: Run tagpr
        uses: Songmu/tagpr@v1

  release:
    needs: tagpr
    if: needs.tagpr.outputs.tagpr-tag != ''
    runs-on: macos-latest
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - name: Setup docker
        uses: docker-practice/actions-setup-docker@master

      - name: Login to ghcr.io
        uses: docker/login-action@v1
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Check out source code
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Setup
        run: |
          brew uninstall go@1.17
          brew install go
          brew install goreleaser
          brew install sqlite3
          brew install messense/macos-cross-toolchains/x86_64-unknown-linux-gnu
      - name: Release
        run: |
          make release

結果

思惑通りになったし、tagpr開発者(であるSongmuさん)の思惑通りでもあったみたいです。

環境を整備してよかったです。私も良い体験でした。

GitHub上で構築するコードメトリクス計測基盤

TL;DR

長いので要約すると

  • octocovは計測したコードメトリクスをGitHub Actions Artifactsに保存すると、単体リポジトリでメトリクスの比較レポートなどもしてくれて便利
  • さらに octocovs-template を使って、各リポジトリのArtifactsに保存されているメトリクスを収集する専用リポジトリを作るとバッジも生成・配布できて便利

となります。


GitHub」というキーワードでお声かけいただき、「TECH STAND #9 GitHub」にて、本エントリと同名タイトルで発表させてもらいました。

standfm.connpass.com

GitHubの中の開発の仕方を知ることができたり、GitHubを使った様々な生産性向上のアプローチを知ることができたり、何よりいろんな方に octocov やペパボでの取り組みを知ってもらえてよかったです。

今回は、個人的にずっと模索していたことがTECH STANDでの発表やアフタートーク、その後フィードバックを通じて気づきを得て解決することができたのでそれを紹介したいと思います。

GitHub "のみで" 構築するコードメトリクス計測基盤

octocovはPull Requestへのコードメトリクスレポート機能やCLIによるカバレッジ確認機能などがあります。

なお、CLIによるカバレッジ機能については次のエントリをぜひ。

k1low.hatenablog.com

バッジ生成機能

その中でも面白い(しかし本人はいたって真面目に作った)機能として、バッジ生成機能があります。

せっかくCIで計測しているコードカバレッジやCode To Test Ratioやテスト実行時間をバッジにしてREADME.mdに貼りたいわけです。

↑こういうのです

そのためにパッケージも書いたりしています。

octocov/pkg/badge at main · k1LoW/octocov · GitHub

ただ、これには1つ個人的なこだわりで課題を持っていました。

CIで計測したコードメトリクスのバッジを生成したとして、そのバッジを誰が配布するのか

CIで計測した結果、その場でバッジの生成はできます。

しかしREADME.mdにそれを更新される状態で貼るには、バッジを常に更新されても固定のURLで公開する必要があるわけです。

その実現方法について満足していませんでした。

CI上で生成したバッジをそのままリポジトリのコミットしてしまう

実現方法の1つは、CI上で生成したバッジをそのままリポジトリのコミットしてしまう方法です。

コミットさえしてしまえばそれをREADME.mdから読むことは可能です。

# octocov

![coverage](docs/coverage.svg) ![ratio](docs/ratio.svg) ![time](docs/time.svg)

最近まで、リポジトリとしてのoctocovもこの方法をとっていました。

https://github.com/k1LoW/octocov/blob/v0.41.0/README.md?plain=1#L5

ただ、これには欠点があって「バッジのコミットが積まれてしまう」というものです。これがうざい。テスト実行時間も取得していることから結構な頻度でコミットが積まれていまいます。

Central modeを使う

もう1つはCentral modeを使う方法です。

octocovにはCentral modeというコードメトリクスを複数のリポジトリから収集するモードがあります。

現在は収集したコードメトリクスと、生成したバッジの一覧を生成する機能があります。Central modeを使えばバッジの配布が可能です。

このCentral modeを使う方法にも(今までは)「GitHubのみで構築するコードメトリクス基盤としては、Central modeのリポジトリ(Central repo)に各リポジトリのメトリクスデータを集約する構成の決定打がない」という課題がありました。

従来の実現方法としてはCentral repoに対して各リポジトリGitHub Actionsからコミットをするというものでした。

以下のような感じです。

図をみるとわかるようにCentral repoへコミットするためのPermissionが必要になってしまいます。

できれば、リポジトリにある secrets.GITHUB_TOKEN のみでいい感じに他リポジトリにコミットできる仕組みが欲しいところですが、今のところはありません*1。Parsonal Access Tokenが必要になってしまいます。

なんとかできないものかと思っていたのですが、現状は解決を諦めていました。

GitHub Actions Artifacts

ところで、

octocovに最近追加された機能として、「計測したコードメトリクスのデータをArtifactsに保存する」というものがありました。

これによってリポジトリ単体の機能の範囲で過去メトリクスを保存できるようになり、Pull Requestへのレポート時に「前回メトリクスとの比較」もレポートできるようになりました*2

作者である私自身のoctocovに対する機能の理解としてもここまでだったのですが、先日のイベント登壇からの流れでふと

リポジトリのArtifactsにあるコードメトリクスレポートをCentral repoから収集すればいいのでは?

と思い付いたのです。

そして、実際に設定してみると出来たのでした。

既に機能としては実現していたのに作者が気づいていなかったという...*3

実現イメージとしては以下のような感じです。

GitHub のみで コードメトリクスバッジ基盤を構築する方法

さて、具体的な設定方法です。前提として既にoctocovを利用してGitHub Actions Artifactsにコードメトリクスを保存しているPublicなリポジトリを持っている必要があります。

次にCentral repoを作ります。こちらはテンプレートリポジトリを用意しました。

github.com

このリポジトリの "Use this template" ボタンを押して完成です。終わり。

あとは .octocov.yml の central.reports.datastores: にコードメトリクスを収集したいリポジトリを追加していけば良いです。サンプルとして octocov のメトリクスを収集するようになっています。

central:
  reports:
    datastores:
      - artifact://k1LoW/octocov # delete this line
      # - artifact://owner/repo
      # - artifact://owner/other-repo
      # - artifact://owner/another-repo
  badges:
    datastores:
      - local://badges
  push:
    enable: true

実際にバッジを生成している私のリポジトリこちらです。

まとめ

やっと「GitHub上で構築するコードメトリクス計測基盤」を真の意味で実現できる道筋ができました。

Central modeは、現状は最新のコードメトリクスとバッジの一覧の配布のみに対応していますが、将来的にはメトリクスを蓄積して便利そうなダッシュボードなどをGitHubの機能のみでつくれるところまで実現したいとは思っています。今のところキーワードとなっているGitHubの機能は「FlatData」と「ActionsからPagesへの直接デプロイ」です。

ただ、まあ、、、

ぜひ皆さんも使ってみてください。「(個人や会社で)使っているよ」というフィードバックが一番嬉しいです。機能リクエストやPull Requestもお待ちしています。

*1: @kyanny さんにブログエントリ経由でリポジトリ間のアクセスについてまとまっている https://docs.github.com/ja/actions/security-guides/security-hardening-for-github-actions#considering-cross-repository-access を教えてもらいました

*2:データストアを使えば今までもできていましたが、リポジトリ単体で実現できるようになったのがポイントです

*3:なぜ既に機能として実現していたかというと、octocovがサポートするデータストアはREADは全て io/fs.FS インターフェースで揃えていたからです。詳しくは Go Conference 2021 Autumn の発表資料をご覧ください https://speakerdeck.com/k1low/go-conference-2021-autumn-online

経過時間を内訳も含めて計測するためのストップウォッチライブラリ stopw を作った

runn を使ってシナリオテストを順調に積み上げていった結果、順調にテスト実行時間が長くなってきました。

そこでテストのどの部分に時間がかかっているかを計測したくなったのですが、runnにその時間計測機能はなかったのでそれを作ろうと考えました。

ところが、実現したい時間計測機能を満たすちょうど良いライブラリがなかったのでそれを作ることから始めました。

実現したい時間計測機能

時間計測というと Start()Stop() の間の開始時刻と終了時刻と経過時間がわかれば良さそうです。

しかし、runnに組み込むにあたって実現したかった時間計測は、「runnで実行している『どのシナリオ』の『どのステップ』の『どの処理』に時間がかかっているか?」を特定するためのもので、

上記の図でいうと横軸が時系列だとして、実現したい時間計測機能は

  • 濃いめの青のライン全ての開始時刻と終了時刻と経過時間を
  • 入れ子状態を維持した(表現した)状態で
  • 1つの結果として

時間計測したいというものです。

stopw

というわけで作成したのが stopw です。

github.com

stopwは Start()Stop() したあと、計測結果を Result() で取得します。

上記だけだとただの時刻計測です。

stopwは入れ子を表現するためにキーを複数渡すことができます。

キーを渡して時刻計測することで、次のように入れ子の計測結果を得ることができます。

package main

import (
    "encoding/json"
    "fmt"

    "github.com/k1LoW/stopw"
)

func main() {
    stopw.Start()
    stopw.Start("sub span A")
    // do something for `sub span A`
    stopw.Start("sub span B")
    // do something for `sub span A` or `sub span B`
    stopw.Start("sub span A", "sub sub span a")
    // do something for `sub span A` or `sub span B` or `sub sub span a`
    stopw.Stop("sub span A", "sub sub span a")
    // do something for `sub span A` or `sub span B`
    stopw.Stop("sub span span A")
    // do something for `sub span B`
    stopw.Stop()

    r := stopw.Result()
    b, _ := json.MarshalIndent(r, "", "  ")
    fmt.Println(string(b))

    // Output:
    // {
    //   "id": "cbt1386v9mc80ofooblg",
    //   "started_at": "2009-11-10T23:00:00.436022+09:00",
    //   "stopped_at": "2009-11-10T23:00:00.436883+09:00",
    //   "elapsed": 860375,
    //   "breakdown": [
    //     {
    //       "id": "sub span A",
    //       "started_at": "2009-11-10T23:00:00.436153+09:00",
    //       "stopped_at": "2009-11-10T23:00:00.436594+09:00",
    //       "elapsed": 441292,
    //       "breakdown": [
    //         {
    //           "id": "sub sub span a",
    //           "started_at": "2009-11-10T23:00:00.436449+09:00",
    //           "stopped_at": "2009-11-10T23:00:00.436594+09:00",
    //           "elapsed": 145500
    //         }
    //       ]
    //     },
    //     {
    //       "id": "sub span B",
    //       "started_at": "2009-11-10T23:00:00.436303+09:00",
    //       "stopped_at": "2009-11-10T23:00:00.436883+09:00",
    //       "elapsed": 580083
    //     }
    //   ]
    // }
}

上記のコードをみたらわかるように、渡したキーの数で入れ子の深さを表現するという単純なものです。

ただ、いくつか使いやすくするためのしくみを入れています。

いきなり stopw.Start() ができる

これの何が便利かというと、stopw.Start() のスコープと stopw.Stop() のスコープが同じである必要がないということです。

例えば、ある関数スコープ内で stopw.Start() して、別の関数スコープ内で stopw.Start("keyA") して、さらに別の関数スコープ内で stopw.Start("keyA", "keyB") して、さらに別の関数スコープ内で stopw.Stop しても、1つにまとまった結果を stopw.Result() から取得できます。

これは組み込みやすさにつながります。

math/rand などでも使われているテクニックですね。

https://cs.opensource.google/go/go/+/refs/tags/go1.19:src/math/rand/rand.go;l=293

当然、別々の時刻計測をするためにインスタンスを分けることも可能です。

a := stopw.New()
b := stopw.New()

a.Start()
// do something for a
a.Stop()

b.Start()
// do something for b
b.Stop()

ra := a.Result()
rb := b.Result()

defer stopw.Start().Stop() で、関数ブロックの時間計測ができる

これも組み込みやすくするためのしくみです。1行で時間計測ができるのは便利です。

func () {
    defer stopw.Start().Stop()
    // do something
}()
r := stopw.Result()
[...]

このテクニックもどこかのライブラリで見た気がするのですが忘れてしまいました。

stopw.Disable() で、時間計測処理をスキップできる

一括してOFFにできるようにしていて、stopwを組み込んだ結果発生するオーバヘッドをできるだけ小さくできるようにしています。

キーに合わせてできる限り時刻を含め入れ子を保つように動く

例えば stopw.Start() を実行しなくても stopw.Start("key A") を実行すれば stopw.Start() で記録されるべき開始時刻が記録されますし、stopw.Stop("key B") を実行しなくても stopw.Stop() を実行すれば stopw.Stop("key B") で記録されるべき終了時刻が記録されます。

stopwはキーを元に入れ子をできるだけ保とうとします。

最後に

runnにはどう組み込まれているかというと、

runn.Profile(true) をオプションで渡して、最後に DumpProfile() を実行すると、計測結果をJSON形式で取得できるようになっています。

opts := []runn.Option{
    runn.T(t),
    runn.Book("testdata/books/login.yml"),
    runn.Profile(true)
}
o, err := runn.New(opts...)
if err != nil {
    t.Fatal(err)
}
if err := o.Run(ctx); err != nil {
    t.Fatal(err)
}
f, err := os.Open("profile.json")
if err != nil {
    t.Fatal(err)
}
if err := o.DumpProfile(f); err != nil {
    t.Fatal(err)
}

というわけで、時間計測ライブラリ stopw の紹介でした。

良かったら使ってみてください。

シナリオテスティングツール/パッケージであるrunnをgRPCに対応させた

なんとかgRPC対応ができました。

「そもそもrunnって何?」については会社のテックブログにエントリを書きましたのでそちらをご覧ください。

tech.pepabo.com

なお、runnを使ったgRPCのシナリオテストの実戦投入は私もまだなので、もし触ってくださる方がいましたら是非フィードバックをお待ちしています。

gRPC Runner

runnでシナリオの各ステップを実行するコンポーネントをRunnerと呼んでいるのですが、今回gRPC Runnerを追加しました。

runners: セクションで grpc:// というschemeでgRPC Runnerを作成できるようにしています。

次の例だと greq という名前をつけたgRPC Runnerを作成しています。

runners:
  greq: grpc://grpc.example.com:80

443ポートにするとTLSを使用するようになります。細かく設定する場合は以下のように書けます。

runners:
  greq:
    addr: grpc.example.com:8080
    tls: true
    cacert: path/to/cacert.pem
    cert: path/to/cert.pem
    key: path/to/key.pem
    # skipVerify: false

私が主に想定しているユースケースが「開発しているgRPCサーバのシナリオテスト」なので、大抵は次のようなコードで runners: セクションの設定を上書きします(次の例だと greq を runn.Runner() で上書きしています)。

func TestServer(t *testing.T) {
    addr := "127.0.0.1:8080"
    l, err := net.Listen("tcp", addr)
    if err != nil {
        t.Fatal(err)
    }
    ts := grpc.NewServer()
    myapppb.RegisterMyappServiceServer(s, NewMyappServer())
    reflection.Register(s)
    go func() {
        s.Serve(l)
    }()
    t.Cleanup(func() {
        ts.GracefulStop()
    })
    opts := []runn.Option{
        runn.T(t),
        runn.Runner("greq", fmt.Sprintf("grpc://%s", addr),
    }
    o, err := runn.Load("testdata/books/**/*.yml", opts...)
    if err != nil {
        t.Fatal(err)
    }
    if err := o.RunN(ctx); err != nil {
        t.Fatal(err)
    }
}

gRPCリクエストの書き方

4種類の通信方式はそれぞれ次のようにかけます。

なお、protoファイルは https://github.com/k1LoW/runn/blob/main/testdata/grpctest.proto を想定しています。

Unary RPC

headers: と message:` でリクエストを投げることができます。レスポンスはHTTP Runnerと同様に自動で記録され使用できます。

steps:
  hello_unary:
    desc: Request using Unary RPC
    greq:
      grpctest.GrpcTestService/Hello:
        headers:
          authentication: tokenhello
        message:
          name: alice
          num: 3
          request_time: 2022-06-25T05:24:43.861872Z
    test: |
      steps.hello_unary.res.status == 0 && steps.hello_unary.res.message.num == 3

Server streaming RPC

Server streaming RPCもUnary RPCと同様に headers:message: でリクエストを投げることができます。

レスポンスは複数のメッセージが返ってくることがあるため steps.<step_id>.res.messages に配列でレスポンスメッセージが記録されます。

steps:
  hello_server_streaming:
    desc: Request using Server streaming RPC
    greq:
      grpctest.GrpcTestService/ListHello:
        headers:
          authentication: tokenlisthello
        message:
          name: bob
          num: 4
          request_time: 2022-06-25T05:24:43.861872Z
    test: |
      steps.hello_server_streaming.res.status == 0 && len(steps.hello_server_streaming.res.messages) == 2 && steps.hello_server_streaming.res.messages[1].num == 34

Client streaming RPC

Client streaming RPCは複数のメッセージを送るので message: の代わりに messages: セクションで複数のリクエストを投げることができます。

steps:
  hello_client_streaming:
    desc: Request using Client streaming RPC
    greq:
      grpctest.GrpcTestService/MultiHello:
        headers:
          authentication: tokenmultihello
        messages:
          -
            name: alice
            num: 5
            request_time: 2022-06-25T05:24:43.861872Z
          -
            name: bob
            num: 6
            request_time: 2022-06-25T05:24:43.861872Z
    test: |
      steps.hello_client_streaming.res.status == 0 && steps.hello_client_streaming.res.message.num == 35

Bidirectional streaming RPC

同期的にではありますがBidirectional streaming RPCもサポートしています。 messages: セクションで receive キーワードでサーバからのメッセージを1つ受信し、close キーワードで通信を閉じます。

  hello_bidi_streaming:
    desc: Request using Bidirectional streaming RPC
    greq:
      grpctest.GrpcTestService/HelloChat:
        headers:
          authentication: tokenhellochat
        messages:
          -
            name: alice
            num: 7
            request_time: 2022-06-25T05:24:43.861872Z
          - recieve # recieve server message
          -
            name: bob
            num: 8
            request_time: 2022-06-25T05:24:43.861872Z
          -
            name: charlie
            num: 9
            request_time: 2022-06-25T05:24:43.861872Z
          - close # close connection
    test: |
      steps.hello_bidi_streaming.res.status == 0 && len(steps.hello_bidi_streaming.res.messages) == 1

gRPCサポートを終えて

まだ実戦投入していないのでアレですが、これでrunnで最低限欲しいと思っていた機能がひと通り揃いました。

あとはテックブログに書いたような機能を必要性やモチベーションに応じて追加したり、細かいところをちまちまと整備したりしようかと思います*1

そう言えば、gRPCのテストを書くために grpcstub を作ったりしたのですが、それはまたどこかで紹介したいです。

なお、runnを使ったgRPCのシナリオテストの実戦投入は私もまだなので、もし触ってくださる方がいましたら是非フィードバックをお待ちしています(大事なことなので2回(ry)。

*1:そういえば Flaky なテストが残っていて(TestShardという名前のテストでYAMLのパースが時々失敗しているように見える)、それも原因を特定したい。。

gomockで「呼ばれるべき」と指定した関数の非同期な実行を待ちたいのでテストヘルパーを書いてみた

gomockでデータベース操作などをモックすることはよくあると思います。

そのテスト関数内で「呼ばれるべき(EXPECT())」と指定した関数に非同期に実行される挙動がある場合、テスト関数の書き方によっては実行終了までにその関数が実行されないことがあり、結果としてテストがエラーになってしまうことがあります。 そして、エラーになったりエラーにならなかったりしてくると、Flakyなテストの出来上がりです*1

(「そもそもテストしやすいように〜すべきだ」というテスト容易性については一旦傍に置いておきます)

ものすごい雑コードですが、例えば以下のような感じのテストです*2

func TestAsyncExec(t *testing.T) {
    ctrl := gomock.NewController(t)
    t.Cleanup(func() {
        ctrl.Finish()
    })
    m := NewMockQuerier(ctrl)
    m.
        EXPECT().
        Exec("INSERT INTO users (name) VALUES ('alice');").
        Return(nil)

    go func() {
        time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)
        m.Exec("INSERT INTO users (name) VALUES ('alice');")
    }()
}

実際に動くコードはこちらです。

実行すると以下のようなエラーが出ます。goroutine内の処理が実行しきる前にテスト関数の実行が完了するからですね。

=== RUN   TestAsyncExec
    controller.go:269: missing call(s) to *main.MockQuerier.Exec(is equal to INSERT INTO users (name) VALUES ('alice'); (string)) /tmp/sandbox509983514/prog.go:24
    controller.go:269: aborting test due to missing call(s)
--- FAIL: TestAsyncExec (0.00s)
FAIL

Program exited.

「テスト関数の終了をちょっとだけ待ってほしい」だけなんですが、なんとなくの待ち時間を決めて time.Sleep() を使うのもちょっとなあと思ってしまいます。

なので、gomockで「呼ばれるべき」と指定した関数が全て実行されるのを一定間隔で確認するテストヘルパーを書いてみました。

func waitMockCall(t *testing.T, ctrl *gomock.Controller, timeout time.Duration) {
    t.Helper()
    called := false
    done := make(chan interface{})

    go func() {
        time.Sleep(timeout)
        close(done)
    }()

    for {
        select {
        case <-done:
            break
        default:
        }

        var c reflect.Value = reflect.ValueOf(ctrl).Elem()
        var cs reflect.Value = c.FieldByName("expectedCalls").Elem()
        i := cs.FieldByName("expected").MapRange()
        called = true
        for i.Next() {
            calls := i.Value()
            if calls.Len() != 0 {
                called = false
            }
        }
        if called {
            break
        }
        time.Sleep(10 * time.Millisecond)
    }

    if !called {
        t.Log("missed call in time")
    }
}

waitMockCall(t *testing.T, ctrl *gomock.Controller, timeout time.Duration) を、テスト関数の末尾で呼び出しておけば、EXPECT()した関数が全て呼ばれるまで一定期間待つようになります。

実際に動くコードはこちらです。

実行すると以下のように成功するはずです。

=== RUN   TestAsyncExec
--- PASS: TestAsyncExec (0.09s)
PASS

Program exited.

feat: Add AllExpectedCallsSatisfied to Controller by FlorianLoch · Pull Request #627 · golang/mock · GitHub らへんのPull Requestが取り込まれていればよかったのですが、まだだったので reflect パッケージを使って使えそうな値を引っ張り出しています。

で、結局どうするのが正解?

みなさんはどうしていますか?是非知りたいです。

*1:「フレイキーテスト」。最近覚えた言葉です。https://www.publickey1.jp/blog/22/itjenkinsdevops_days_tokyo_2022.html

*2:「なんで突然の goroutine なんだよ!」というのは、簡単にするためにテスト関数内に書いているだけで本当は何か別の関数内で実行されるものとしてください

octocovはコードカバレッジを確認するCLIツールとしても使える

octocovは私が開発しているコードメトリクス計測のためのツールキットです。

主となる用途であるCI(GitHub Actions)に組み込む使い方は会社のテックブログで紹介させてもらいましたのでそちらをご覧ください。

tech.pepabo.com

私自身もドックフーディングをしていて、自分の主だったリポジトリはすでに octocov に移行済みです。

Fix handling for bind runner by k1LoW · Pull Request #47 · k1LoW/runn · GitHub

実は octocov にはもうひとつ便利な使い方があるので、そちらを紹介したいと思います。

コードカバレッジを確認する

octocov は実は単体のCLIツールとしても使うことができます。.octocov.yml といった設定ファイルも一切必要ありません。ただ、インストールするだけでOKです。

macOSだとHomebrew経由でインストール可能です。

$ brew install k1LoW/tap/octocov

あとは octocov が対応しているフォーマットカバレッジレポートを出力しているリポジトリでレポートファイルを指定して octocov コマンドを実行するだけです。

例えば、Goのプロジェクトだと以下のような感じです。

$ go test ./... -coverprofile=coverage.out -covermode=count
?       github.com/k1LoW/octocov        [no test files]
ok      github.com/k1LoW/octocov/central        3.682s  coverage: 73.1% of statements
?       github.com/k1LoW/octocov/cmd    [no test files]
ok      github.com/k1LoW/octocov/config 1.264s  coverage: 64.6% of statements
ok      github.com/k1LoW/octocov/datastore      1.690s  coverage: 43.0% of statements
?       github.com/k1LoW/octocov/datastore/artifact     [no test files]
?       github.com/k1LoW/octocov/datastore/bq   [no test files]
?       github.com/k1LoW/octocov/datastore/gcs  [no test files]
?       github.com/k1LoW/octocov/datastore/github       [no test files]
ok      github.com/k1LoW/octocov/datastore/local        0.796s  coverage: 80.0% of statements
?       github.com/k1LoW/octocov/datastore/s3   [no test files]
ok      github.com/k1LoW/octocov/gh     1.848s  coverage: 16.1% of statements
ok      github.com/k1LoW/octocov/internal       0.712s  coverage: 84.8% of statements
ok      github.com/k1LoW/octocov/pkg/badge      1.929s  coverage: 72.1% of statements
ok      github.com/k1LoW/octocov/pkg/coverage   2.606s  coverage: 88.4% of statements
ok      github.com/k1LoW/octocov/pkg/pplang     0.747s  coverage: 77.3% of statements
ok      github.com/k1LoW/octocov/pkg/ratio      1.062s  coverage: 83.2% of statements
ok      github.com/k1LoW/octocov/report 1.193s  coverage: 68.7% of statements
?       github.com/k1LoW/octocov/version        [no test files]
$ octocov -r coverage.out

                      main (9244fb7)
--------------------------------------
  Coverage                     66.8%

うまく Coverage が取得できていることが確認できたら octocov ls-files でファイルごとのカバレッジを見てみましょう。

$ octocov -r coverage.out ls-files
 73.1% [ 98/134] central/central.go
  0.0% [   0/29] config/build.go
 56.7% [ 89/157] config/config.go
 90.9% [  10/11] config/generate.go
 84.3% [ 91/108] config/ready.go
 77.8% [  42/54] config/yaml.go
 45.4% [ 49/108] datastore/datastore.go
  0.0% [    0/6] datastore/hint.go
 80.0% [  16/20] datastore/local/local.go
 16.1% [ 54/335] gh/gh.go
 83.9% [  52/62] internal/path.go
100.0% [    4/4] internal/value.go
 72.1% [  31/43] pkg/badge/badge.go
 91.5% [  43/47] pkg/coverage/clover.go
 91.7% [  44/48] pkg/coverage/cobertura.go
 84.7% [144/170] pkg/coverage/coverage.go
 96.7% [  29/30] pkg/coverage/diff.go
 90.9% [  40/44] pkg/coverage/gocover.go
 90.6% [  48/53] pkg/coverage/jacoco.go
 84.1% [  58/69] pkg/coverage/lcov.go
 90.6% [  29/32] pkg/coverage/merge.go
 89.9% [  62/69] pkg/coverage/printer.go
 87.8% [  65/74] pkg/coverage/simplecov.go
 77.3% [  34/44] pkg/pplang/pplang.go
 60.4% [  32/53] pkg/ratio/copy_from_gocloc.go
 92.9% [  26/28] pkg/ratio/merge.go
 94.2% [  81/86] pkg/ratio/ratio.go
 66.7% [132/198] report/diff_report.go
 70.2% [193/275] report/report.go

さらに octocov view でファイルを指定してテストがカバーしている行も確認できます。

$ octocov -r coverage.out view pkg/coverage/simplecov.go
[...]
112   3371              lcs := fcov.Blocks.ToLineCoverages()
113   3371              fcov.Total = lcs.Total()
114   3371              fcov.Covered = lcs.Covered()
115   3371              cov.Total += fcov.Total
116   3371              cov.Covered += fcov.Covered
117             }
118
119     11      return cov, rp, nil
120        }
121
122     16 func (s *Simplecov) detectReportPath(path string) (string, error) {
123     16      p, err := os.Stat(path)
124     16      if err != nil {
125                     return "", err
126             }
127     18      if p.IsDir() {
128      2              // path/to/coverage/.resultset.json
129      2              np := filepath.Join(path, SimplecovDefaultPath[0], SimplecovDefaultPath[1])
130      4              if _, err := os.Stat(np); err != nil {
131      2                      // path/to/.resultset.json
132      2                      np = filepath.Join(path, SimplecovDefaultPath[1])
133      2                      if _, err := os.Stat(np); err != nil {
134                                     return "", err
135                             }
136                     }
137      2              path = np
[...]

1列目が行数、2列目がテストにおいて通った回数、3列目が実際のコード行になります。

ターミナル上でカバレッジを確認できるのは意外に便利

私は今までファイル単位のコードカバレッジを見るためには、カバレッジレポートをHTMLで出力してブラウザで見たり、SaaSが提供している画面で見たりする方法しか持っていませんでした。

ターミナルでコードカバレッジをみるというのはターミナル内で開発をする自分のスタイルにもあっています。cat でファイルの中身をみるレベルでカバレッジもみることができるようになって便利です。

octocovは、Goのカバレッジフォーマットだけではなく、Rubyでよく使われるSimpleCovや、PHPUnitが出力可能なClover XMLや、Java/Kotlinでよく使われているらしいJaCoCoや、その他にもLCOVやCoverturaにも対応していますので、是非使ってみてください。