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にも対応していますので、是非使ってみてください。

椅子と机が最終形態になった

椅子と机が最終形態となりました。

もう、壊れない限りは「他のものが欲しい」とならないと思います。

Mirra 2 Chairs

椅子です。

www.hermanmiller.com

2020年にリモートワークが増えることになったことをきっかけに購入しました。

会社ではとても良い椅子を提供してもらっていたので、それがなくなると身体的に大変なことになるかも?と思っていろいろ調べていた記憶があります。

当時、各所で椅子購入ブームだったので、得られる情報も多くとても迷いました。

決め手はハーマンミラーというブランドへの信頼と、調節要素の多さでした。

というのも椅子は奥さんと共有して使用する予定だったので*1、それぞれの体型や姿勢にあった調整ができたほうが良いだろうと考えました。

さらに、コロナ禍で店舗もあまり空いておらず、椅子ブームで在庫もなく、結局「座って確認」ができませんでした。そう言った意味でもブランドへの信頼や調節要素によるバッファが重要になりました。

結構な賭けでしたが購入を決断し、結果としてはとても満足して座れています。

PREDUCTS DESK - METRO / FlexiSpot E7

机です。

DESK - METRO / FlexiSpot E7preducts.jp

どの経路で知ったのかは覚えていないのですが、「PREDUCTSという会社ができてモジュール式のデスクを販売するよ」というのを知ってから、ずっとウォッチしていました。

note.com

「この机が欲しい」なんてことは今までの人生で思ったことがなかったのですが、これは欲しいと思ってしまい時々眺める日々。

実はそれまでのリモートワークでは奥さんが持ち込んだ机を共用させてもらっていました。

そろそろ「机もちゃんと別にあったほうがいいよね」と思いはじめたとても良いタイミングで昇降デスク版が出てしまい(?)、とても高い買い物なのでものすごく悩んだのですが、購入ボタンを押しました。

TwitterのTLを見ていると若干FORESTのほうが人気みたいなのですが、私はMETROを選択しました。

天板は艶消しブラックのメラミン化粧板らしいのですが、手触りもよく大満足です。

また、PREDUCTS代表のGo Andoさんの真似をしてキャスターをつけています。

私の部屋は広くないので「机の左右に人が立って机を持ち上げる」ということができないため、掃除や模様替えで苦労するかもしれないので念のためというのが理由です。

キャスターは重い昇降デスクを支えることになるので、何も考えずFlexiSpotが推奨しているものにしました。

flexispot.jp

「机にキャスターをつける」これが大成功で「気分によって机の位置を変える」こともできそうなくらい簡単に移動できるようになりました*2

そして今は「何でもかんでも机の下にぶら下げたい病」にかかっています。

電源やスマホ以外にもウェットティッシュボックスとハンディワイパーボックスは成功しました。山崎実業のマグネット付きのボックスがいい感じです。

残念ながらレールには直接マグネットでつけることはできませんでしたが、トレイやデスクの脚にはつけれるので貼り付けています。

ベーススライダーに適当な鉄製の小さめのボードをつけてもいけそうです。

今はゴミ箱をどうにかしていい感じにぶら下げられないかなあと思っています(今はフックスライダーにゴミ袋をかけているだけ)。

これからもDIYなしに拡張ができそうで楽しみです。

本当に良いものをずっと使うということ

私は「良いものをずっと使う」という考えを持っていなくて「リーズナブルで良さそうなものを使う」という感覚でした。

一方で奥さんは「良いものを大切にずっと使う」を地で行く人です。

彼女は時々(私にとっては)高い買い物をしていてびっくりするのですが、その一方で使っているものが実は10年以上(下手したら20年以上)使い続けているものだったりしてもっとびっくりします(そうは見えないくらい綺麗に使っているのですが事実そうなのです)。そういった長寿なものがたくさんあります。というかそういったものばかりな気がするな。。。

大切に長く使えるのであれば本当に良い物にお金を払うのはトータルで満足度が高いんだなあと思いました。

私はなかなか「大切に使う」が下手なのですが、「良いものを大切にずっと使う」を実践している彼女を見ると実践してみたい、見習いたいと思います*3

今回、椅子と机が本当に良い物(そしてお気に入り)になったので、大切に使っていこうと思います。

そういえば

他に最終形態になったものとして、バックパックがあるのですがこちらもずっと満足して使えています。こちらも良い物だった。

k1low.hatenablog.com

追記

「写真がない」というコメントをいただいたので貼っておきます*4

*1:今は別々になりました

*2:まだその機会は訪れていません

*3:PREDUCTS DESKの購入に悩んでいる時も「ずっと使うんでしょ?だったら月1万円で15ヶ月と考えたら余裕じゃないの?」と言われて購入に踏み切った気がします。よく考えたら5年も10年もずっと使う物だった。

*4:写真下手で椅子と机に申し訳ない...

PHPerKaigi 2022でGitHub Actionsについて発表した #phperkaigi

参加してきましたPHPerKaigi 2022

phperkaigi.jp

感想

一言で言うと「オフライン参加の人たちが羨ましい!!!」でした。

オンライン開催でも本当に参加してよかったと思えたのですが、時折垣間見えるオフライン参加の人たちのワイワイ感が楽しそうでした。

オンライン参加しなかったのは完全に自分の選択であって誰のせいでもないのです。ただ、「オンラインでも楽しいけど、やっぱりオフライン楽しいんだな」という感じです( ep 67 @fortkle @o0h_と、私の尊敬する若手スペシャル by Yokohama North AM でも楽しそうに語っていて羨ましかったです)。

逆にオンラインの場を設けてもらって、地方勢や家庭イベント勢には本当にありがたいと思いました*1

GitHub Actions Deep Dive using PHP

今回はGitHub Actionsについてちょっとだけ濃い話を発表してきました。

私は、自分のOSSのCI/CD環境だけでなく所属会社であるGMOペパボでもGitHub Actionsにどっぷりなので「多分普通よりもGitHub Actionsを使っているだろう」ということで発表題材にしました。

Upload Artifact

発表の中では余談として話した「Upload ArtifactはAPIドキュメントにない」というお話ですが、なぜこんなマニアックな挙動を調べたかというと、「Artifactを使った実装を自作Actionで完結させて提供したかったから」です。

具体的には何に使っているかと言うと octocov のコードメトリクスレポートの保存先です。Artifactを使うことでリポジトリ内で完結した状態で、外部サーバやストレージなどに頼らずにPRごとにカバレッジの差分表示などを出すことができるので便利なのです。

言語はGoですが、Upload Artifactの実装はパッケージに切り出していますので、「Artifactを使った実装を自作Actionで完結させて提供したい」という奇特な方はご利用ください。

github.com

New Action

発表のインパクトになればいいなーと、今回の発表のためだけに2つActionを作成しました。

github.com

github.com

とはいえサンプルなどではなく、一応ちゃんと作ったつもりですので使うタイミングがあったらぜひ使ってみてください(マーケットプレイスにちゃんと登録しないとだ)。

私がGitHub Actionsに抱いているイメージ

これは賛同する人は少ないかもなのですが、私がGitHub Actionsに抱いているイメージは「CI/CD」というよりも「Serverless」です。

なんというかそれ自体の使い勝手が良いのと、コンピューティングリソースの運用をしなくていいので*2、なんでもかんでもGitHub Actionsで完結させたくなります。

「ハンマーを持つと全てが釘に見える」というアレですね。

これからも「いち実行環境」として便利に使っていくんだろうなと思います。

PHPerKaigi 2022 お疲れさまでした!!

運営の皆さん、発表者の皆さん、参加者の皆さん、お疲れさまでしたー!

楽しかったです!

*1:ただ、ただ、モジュラモノリス発表x2の合同アフタートークがあったりしたらしく、それは聞きたかったなーと

*2:正確には他の誰かに丸投げしている

PHPerKaigiが好きな理由 #phperkaigi

PHPの名を冠していて、それでいていろいろな技術に対して門戸を開いているところが好きです。

(ほぼ)元PHPerであるからなのですが、なんとなくPHPに関われている気がして嬉しいのです。

他のコミュニティや勉強会やカンファレンスも好きなところはたくさんありますし、他にも幅広い技術の話題を取り上げてくれるカンファレンスはあるので(PHPカンファレンス福岡とか)、「PHPerKaigiだけ」というわけではないのですが、少なくとも私がPHPerKaigiの好きなところの1つです。

あと、程よい距離感も好きです。

forteeの充実した機能も好きです。

運営の人の動きや頑張りが(たまたまですが)私のTwitter TLで見ることができるのも好きです。

今年はオンライン/オフラインのハイブリッド開催です。私はオンラインでの参加になりますが、今回も楽しみです。

phperkaigi.jp

今回もタイムテーブルを見る限り、多少のPHPの軸足がありつつも発表内容の幅は広そうです。

forteeの機能によってプロポーザルに星をつけたセッションには、そのままタイムテーブルに☆マークがついているので便利です。

あと、オンラインの人たちの野良懇親会とかもあれば参加したいなあと思っています。

まだ若干チケットはあるみたいなので、参加してみんなで楽しみましょう。

データベースにクエリを投げて結果をプリントするライブラリqpを作った

データベースを伴うテストを書いていて、何故かテスト結果が安定しない事象に出くわして「なんでだ?????」と混乱した結果、データベースの状況をprintデバッグをしたくなって作りました*1

github.com

使い方は

package main

import (
    "database/sql"
    "log"

    "github.com/k1LoW/qp"
    _ "github.com/mattn/go-sqlite3"
)

func main() {
    db, err := sql.Open("sqlite3", "path/to/db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    qp.Print(db, "SELECT * FROM users WHERE username = 'alice'")
}

みたいにqp.Print()*sql.DB と実行したいクエリを渡すと、

$ go run main.go
+----+----------+----------+-------------------+---------------------+---------+
| id | username | password |       email       |       created       | updated |
+----+----------+----------+-------------------+---------------------+---------+
|  1 | alice    | passw0rd | alice@example.com | 2017-12-05 00:00:00 | <nil>   |
+----+----------+----------+-------------------+---------------------+---------+
(1 row)

のように結果がでるシンプルなものです。SELECT だけ結果をだしますが、他のクエリでもデータベースに投げるようにしています。

私のように混乱しないとなかなか必要になることはないと思いますが、「Goのコードからデータベースにクエリをカジュアルに投げてデバッグしたい」というときがあれば使ってみてください。

*1:原因は、単純にgo test ./... がパッケージ単位で並列に実行されるのに対して別のパッケージで同じテストデータベースにクエリを投げていたからでした...

ファイルの一部の文字列を差し替えするためのコマンド/パッケージ repin を作った

継続的ドキュメンテーション関連です。

README.mdなどのドキュメントを運用していると、そのドキュメントの一部を(CIなどで)自動で差し替えたいことがあります。

例えば xxx help コマンドの出力をもって機能一覧にしているようなREADMEだと、機能が追加されるたびに xxx help の実行結果でREADMEを差し替える必要があります。

ちょっと前に作った dirmap も出力結果をREADMEに貼っておきたい系のツールです。

k1low.hatenablog.com

そういう「文字列の差し替え」というのはsedでもある程度可能ですが、エスケープが難しかったり複数行の置換もなかなか覚えられないので専用ツールを書きました。

github.com

repin

例えば

# Hello

```console
```

みたいなMarkdownファイルがあったとして、$ echo hello world! という文字列を入れたいとき、GNU sedだと

$ cat README.md | sed -z 's/```console.*```/```console\n$ echo hello world!\n```/'

と書けば

# Hello

```console
$ echo hello world!
```

と出力されます。

これを repin で書くと

$ repin README.md -k '```console' -k '```' -r '$ echo hello world!'

となります。多少直感的になります。

また、置換文字列を標準入力で受け取れるので、

$ repin --help | repin README.md -k '```console' -k '```'

とすれば

# Hello

```console
repin is a tool to replace strings between keyword pair.

Usage:
  repin [FILE] [flags]

Flags:
  -h, --help              help for repin
  -i, --in-place          edit file in place
  -k, --keyword strings   keywords to use as a delimiter. If 1 keyword is specified, it will be used as the start and end delimiters; if 2 keywords are specified, they will be used as th\
e start and end delimiters, respectively.
  -N, --no-newline        disable appending newlines
      --raw-keywords      do not convert \n or \t of the entered keywords
  -r, --replace string    replace file path or string
  -v, --version           version for repin

```

と、複数行の help コマンドの出力を差し込んだMarkdownが得られます(これをGNU sedですることが私には難しかった)。

パッケージとしての利用

repinはパッケージとしての利用もできるようにしています*1

pkg.go.dev

というわけで

小さいコマンド/パッケージの紹介でした

*1:これは、 https://github.com/k1LoW/ndiag に組み込んでドキュメンテーション生成の仕組みをもう少しシンプルにしたいという思惑があるのですがまだ取りかかれていません