Goでtickerのテストを書くのがちょっと大変だった
tickerを使った定期的な処理がありました。処理を書くのは難しくないけど、テストを書くのはちょっと大変だったし、調べてもなかなか情報が見つからなかったのでまとめてみます。
調べながら書いたものなので、もっとよい方法があったら教えてください。
この記事に記載しているコードは以下で公開しています。
素直に書く
素直な実装
これが処理の実装で、tickerで1秒ごとになにかを処理して、キャンセルされたら終了する。
package testingticker
import (
"context"
"fmt"
"time"
)
func run(ctx context.Context) int {
ticker := time.NewTicker(1 * time.Second)
i := 0
for {
select {
case <-ticker.C:
i++
fmt.Println(i)
case <-ctx.Done():
return i
}
}
}
素直なテスト
素直にテストを書くとこんな感じになりました。context.WithTimeoutを使って、10秒後にキャンセルします。
package testingticker
import (
"context"
"testing"
"time"
)
func Test_run(t *testing.T) {
type args struct {
ctx context.Context
}
tests := []struct {
name string
args args
want int
}{
{
name: "OK",
args: args{context.Background()},
want: 10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(tt.args.ctx, 10*time.Second)
defer cancel()
if got := run(ctx); got != tt.want {
t.Errorf("run() = %v, want %v", got, tt.want)
}
})
}
}
これを実行します。
=== RUN Test_run
=== RUN Test_run/OK
1
2
3
4
5
6
7
8
9
10
--- PASS: Test_run (10.00s)
--- PASS: Test_run/OK (10.00s)
PASS
ok testingticker 10.114s
素直なテストは素直に10秒かかります。
tickerのintervalが短いからいいけど、1分単位の処理を書くのに1分待たないといけなくなってしまうので、これはとてもつらいですね。
モックを使って書く
こちらのライブラリを使いました。
モックを使った実装
引数に c clock.Clock
を渡して time.NewTicker ではなく clock.Clock.Ticker を使います。
package testingticker
import (
"context"
"fmt"
"time"
"github.com/benbjohnson/clock"
)
func runWithClock(ctx context.Context, c clock.Clock) int {
ticker := c.Ticker(1 * time.Second)
i := 0
for {
select {
case <-ticker.C:
i++
fmt.Println(i)
case <-ctx.Done():
return i
}
}
}
モックを使ったテスト
試行錯誤した結果、こんな感じになりました。
package testingticker
import (
"context"
"sync"
"testing"
"time"
"github.com/benbjohnson/clock"
)
func Test_runWithClock(t *testing.T) {
type args struct {
ctx context.Context
}
tests := []struct {
name string
args args
want int
}{
{
name: "OK",
args: args{
context.Background(),
},
want: 10,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithCancel(tt.args.ctx)
defer cancel()
c := clock.NewMock()
var got int
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
got = runWithClock(ctx, c)
}()
time.Sleep(100 * time.Millisecond)
c.Add(10 * time.Second)
cancel()
wg.Wait()
if got != tt.want {
t.Errorf("runWithClock() = %v, want %v", got, tt.want)
}
})
}
}
モックを作る
c := clock.NewMock()
でモックを作ります。c.Add(10 * time.Second)
とすることで、さっと10秒にワープできます。
ところで、clock.NewMock()
はargsに入れていません。clock.Clock
はインターフェースでこんな感じです。
type Clock interface {
After(d time.Duration) <-chan time.Time
AfterFunc(d time.Duration, f func()) *Timer
Now() time.Time
Since(t time.Time) time.Duration
Until(t time.Time) time.Duration
Sleep(d time.Duration)
Tick(d time.Duration) <-chan time.Time
Ticker(d time.Duration) *Ticker
Timer(d time.Duration) *Timer
WithDeadline(parent context.Context, d time.Time) (context.Context, context.CancelFunc)
WithTimeout(parent context.Context, t time.Duration) (context.Context, context.CancelFunc)
}
Addがありません。func NewMock() *Mock
となっていて、Addは*Mockに定義されているためです。MockはClockの実装となっています。Addを呼びたいのでargsに入れませんでした。
goroutine で runWithClock を実行する
以下のようにすると、Addが実行されません。
got = runWithClock(ctx, c)
c.Add(10 * time.Second)
以下のようにすると、10秒後にワープしてからrunWithClockが動いて、その後時間が進みません。
c.Add(10 * time.Second)
got = runWithClock(ctx, c)
仕方ないので runWithClock を非同期で呼んで、その後Addを呼ぶようにします。こんな感じになりました。
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
got = runWithClock(ctx, c)
}()
time.Sleep(100 * time.Millisecond)
c.Add(10 * time.Second)
cancel()
wg.Wait()
こんな感じの処理をしています。
- 待ち合わせしたいので WaitGroup を作ります
- goroutine で runWithClock を呼びます。
- すぐに Add すると runWithClock の中で ticker が作られる前に Add してしまうことがあるので、ちょっと Sleep します
- Addします
- tickerがぐるぐるしたはずなので、処理をキャンセルします
- 待ち合わせした後に、gotを評価します
実行すると、さくっと終わります。
=== RUN Test_runWithClock
=== RUN Test_runWithClock/OK
1
2
3
4
5
6
7
8
9
10
--- PASS: Test_runWithClock (0.11s)
--- PASS: Test_runWithClock/OK (0.11s)
よかった、よかった。
調べたこと
こちらの記事を参考にさせていただきました。とても参考になりました。
- Goで時刻をモックする · hnakamur's blog
- Mocking time and testing event loops in Go [Dmitry Frank]
- 【Go言語】Fake timeつかったら時間のかかるコードのテストが一瞬で終わった - Qiita
今回はもともとモックを使いたいと思っていたので Faketime は使いませんでした。
以下の比較はしてみたのですが dimonomid/clock の変更は概ね benbjohnson/clock に取り込まれていそうだったので、開発が継続している benbjohnson/clock を利用しました。
- benbjohnson/clock: Clock is a small library for mocking time in Go.
- dimonomid/clock: Clock is a small library for mocking time in Go.
benbjohnson/clock を使うと Time や Ticker のモックが使えるようになるので便利でした。
疑問
Goの Time や Ticker がインターフェースだったらいろいろ便利そうなんだけどなーと思ったけど、そうなっていないのはなんでだろうな、と思いました。
まとめ
ticker のテストを書くのはちょっと面倒だったけど、いろいろ勉強になって面白かったです。
Enjoy testing!
Discussion