Goでtickerのテストを書くのがちょっと大変だった

2022/03/11に公開

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)

よかった、よかった。

調べたこと

こちらの記事を参考にさせていただきました。とても参考になりました。

今回はもともとモックを使いたいと思っていたので Faketime は使いませんでした。

以下の比較はしてみたのですが dimonomid/clock の変更は概ね benbjohnson/clock に取り込まれていそうだったので、開発が継続している benbjohnson/clock を利用しました。

benbjohnson/clock を使うと Time や Ticker のモックが使えるようになるので便利でした。

疑問

Goの Time や Ticker がインターフェースだったらいろいろ便利そうなんだけどなーと思ったけど、そうなっていないのはなんでだろうな、と思いました。

まとめ

ticker のテストを書くのはちょっと面倒だったけど、いろいろ勉強になって面白かったです。

Enjoy testing!

Discussion