Goの並列テストと現在時刻に依存した実装について

Mercari Advent Calendar 2018 の13日目は株式会社メルペイ認証基盤チームの @pospome がお送りします。


実装パターンの網羅集が書けそうにないことに気づいた & 改めて考えると実装パターン自体が破綻している気がするので、
せっかくなら自分が使ったことのない機能を利用してみようかなと思いました。

そこで今回は"テストの並列実行"を使ってみました。

テストを並列実行してみる

テスト対象は以下です。
userパッケージのGet(),Put(),Delete()をテストします。
それぞれ2秒スリープさせるだけの関数です。

package user

import "time"

func Get() {
  time.Sleep(2 * time.Second)
}

func Put() {
  time.Sleep(2 * time.Second)
}

func Delete() {
  time.Sleep(2 * time.Second)
}



これに対するテストが以下です。

package user_test

import (
  "testing"
  "workspace/parallel_test/user"
)

func TestGet(t *testing.T) {
  user.Get()
}

func TestPut(t *testing.T) {
  user.Put()
}

func TestDelete(t *testing.T) {
  user.Delete()
}



まずは並列化せずにテストしてみました。
2秒スリープするテストが3つあるので6秒かかっています。

$go test ./
ok    workspace/parallel_test/user  6.024s



これを並列実行するわけですが、並列実行する方法は意外と簡単でした。
テスト対象の関数を実行する前にT.Parallel()を実行するだけです。

package user_test

import (
  "testing"
  "workspace/parallel_test/user"
)

func TestGet(t *testing.T) {
  t.Parallel()
  user.Get()
}

func TestPut(t *testing.T) {
  t.Parallel()
  user.Put()
}

func TestDelete(t *testing.T) {
  t.Parallel()
  user.Delete()
}



実行すると2秒になっているので並列実行されているようです。

$go test ./
ok    workspace/parallel_test/user  2.017s



--parallelオプションで並列実行数を1に制限すると6秒になりました。

$go test -parallel 1 ./
ok    workspace/parallel_test/user  6.021s



--parallelオプションで並列実行数を2にすると4秒になりました。

$go test -parallel 2 ./
ok    workspace/parallel_test/user  4.017s

現実的にはテーブル駆動でテストすると思うので、以下に注意しましょう。
https://gist.github.com/posener/92a55c4cd441fc5e5e85f27bca008721

現在時刻に依存するテストは失敗してしまう

並列化は便利ですが、テストによっては並列化できないものもあります。
その代表格が現在時刻に依存する実装ではないでしょうか。

現在時刻をテストするためには以下のような実装を利用すると思います。

package clock

import "time"

var fakeTime time.Time

func Now() time.Time {
  if !fakeTime.IsZero() {
    return fakeTime
  }
  return time.Now()
}

func Set(t time.Time) {
  fakeTime = t
}



上記の実装はtimakinさんのブログの"時刻固定"を参考にしています。


テスト対象の関数のうちGet(),Put()を現在時刻に依存させてみました。

package user

import (
  "time"
  "workspace/parallel_test/clock"
)

func Get() time.Time {
  time.Sleep(2 * time.Second)
  return clock.Now()
}

func Put() time.Time {
  time.Sleep(2 * time.Second)
  return clock.Now()
}

func Delete() {
  time.Sleep(2 * time.Second)
}



テストコードは以下になります。

package user_test

import (
  "testing"
  "time"
  "workspace/parallel_test/clock"
  "workspace/parallel_test/user"
)

func TestGet(t *testing.T) {
  t.Parallel()
  now := time.Date(2018, 1, 1, 0, 0, 0, 0, time.UTC)
  clock.Set(now)
  if result := user.Get(); !result.Equal(now) {
    t.Errorf("got=%v, want=%v", result.Unix(), now.Unix())
  }
}

func TestPut(t *testing.T) {
  t.Parallel()
  now := time.Date(2018, 1, 2, 0, 0, 0, 0, time.UTC)
  clock.Set(now)
  if result := user.Put(); !result.Equal(now) {
    t.Errorf("got=%v, want=%v", result.Unix(), now.Unix())
  }
}

func TestDelete(t *testing.T) {
  t.Parallel()
  user.Delete()
}



並列実行してみると失敗しました。

$go test --count 1 ./                                                                                                    
--- FAIL: TestGet (2.01s)
    user_test.go:19: got=1514851200, want=1514764800
FAIL
FAIL  workspace/parallel_test/user  2.020s



並列数を1にすると成功します。

$go test --count 1 --parallel 1  ./                                                                                     
ok    workspace/parallel_test/user  6.026s

現在時刻を取得する仕組みがパッケージ変数なので失敗してしまうようです。

現在時刻に依存するテストだけ並列化しない

現在時刻に依存するテストを並列実行することはできないので、
それらのテストだけ並列実行せずに実行したいところです。

最初は現在時刻を差し替えるところでロックを取って並列実行を防ごうかと思ったのですが、
単にtesting.Parallel()を実行しなければ上手くいきそうです。

以下はTestGet(),TestPut()からtesting.Parallel()を削除したものです。
TestGet(),TestPut()以外が並列実行されていることを確認するためにTestDelete2(),TestDelete3()を追加しています。

package user_test

import (
  "testing"
  "time"
  "workspace/parallel_test/clock"
  "workspace/parallel_test/user"
)

func TestGet(t *testing.T) {
  now := time.Date(2018, 1, 1, 0, 0, 0, 0, time.UTC)
  clock.Set(now)
  if result := user.Get(); !result.Equal(now) {
    t.Errorf("got=%v, want=%v", result.Unix(), now.Unix())
  }
}

func TestPut(t *testing.T) {
  now := time.Date(2018, 1, 2, 0, 0, 0, 0, time.UTC)
  clock.Set(now)
  if result := user.Put(); !result.Equal(now) {
    t.Errorf("got=%v, want=%v", result.Unix(), now.Unix())
  }
}

func TestDelete(t *testing.T) {
  t.Parallel()
  user.Delete()
}

func TestDelete2(t *testing.T) {
  t.Parallel()
  user.Delete()
}

func TestDelete3(t *testing.T) {
  t.Parallel()
  user.Delete()
}



上記のテストを並列実行してみると6秒でした。

go test --count 1  ./
ok    workspace/parallel_test/user  6.033s



2秒スリープするテストが5つあるので並列実行しない場合は10秒かかるはずなので、
現在時刻に依存するテスト以外はうまく並列化されているようです。

ためしに並列数を1に設定してテストを実行してみると10秒かかりました。

go test --count 1 --parallel 1  ./
ok    workspace/parallel_test/user  10.035s

現在時刻を引数に指定するかどうか

さきほどの検証で現在時刻に依存する実装のみ並列化せずにテストできることがわかりましたが、
現在時刻に依存するテストが多ければ多いほどテストに時間がかかってしまうことに変わりありません。

現在時刻に依存するテスト以外は並列化できるので、どの程度時間がかかるかはものによって異なります。
しかし、そもそも関数内でパッケージ変数を参照して現在時刻を取得するのではなく、
現在時刻を関数の引数に指定すれば問題なく並列化できるのです。
そこで現在時刻を引数にするかどうかという点について考えてみましょう。

引数で現在時刻を指定する場合の実装例

まずは引数で現在時刻を指定する場合の実装を考えてみましょう。

チケットの有効期限を確認するTicket.Expires()を現在時刻に依存する実装例とします。

type Ticket struct {
  expiresAt time.Time
}

func (t *Ticket) Expires() bool {
  now := time.Now()
  return t.expiresAt.Equal(now) || t.expiresAt.After(now)
}



引数に現在時刻を指定するように修正したものが以下です。
この修正でTicket.Expires()は現在時刻に依存しなくなりました。

type Ticket struct {
  expiresAt time.Time
}

func (t *Ticket) Expires(now time.Time) bool {
  return t.expiresAt.Equal(now) || t.expiresAt.After(now)
}



しかし、Ticket.Expires()のクライアントコードは現在時刻に依存します。
以下のXxx()は現在時刻に依存している実装例です。

func Xxx(t *Ticket) {
  now := time.Now()
  t.Expires(now)
}



そのため、現在時刻を引数に渡す場合、
main.goなどから現在時刻を生成するファクトリ実装をDIしてあげる必要があります。

ファクトリ実装は以下です。

type NowFactory func() time.Time

func NewNowFactory() NowFactory {
  return NowFactory(func() time.Time {
    return time.Now()
  })
}



Ticket.Expires()のクライアントコードであるXxx()はファクトリ実装であるNowFactoryを引数に取り、
現在時刻を取得します。

func Xxx(t *Ticket, nf NowFactory) {
  now := nf()
  t.Expires(now)
}



もしくは以下のようにTicket.Expires()の引数にNowFactoryを直接指定してもよいです。
time.Timeを直接渡すよりもNowFactoryを渡す方が"何を渡せば良いのか"が明確になるでしょう。

type Ticket struct {
  expiresAt time.Time
}

func (t *Ticket) Expires(nf NowFactory) bool {
  return t.expiresAt.Equal(nf()) || t.expiresAt.After(nf())
}



今回紹介したNowFactoryは関数ですが、
こちらのようにメソッドとして実装する方法もあります。


いずれにせよ現在時刻を引数に指定する場合は、
このような現在時刻をDIする仕組みも一緒に用意してあげましょう。


ちなみに、Ticket.Expires()の引数に独自型を指定することでも
"何を渡せば良いのか"を明確にすることができます。

//独自型で定義するバージョン
type Now time.Time

//埋め込みを利用して独自型で定義するバージョン
type Now struct {
  time.Time
}

しかし、これらの独自型はtime.Timeではなくなってしまうので、以下のデメリットがあります。
使わない方が良いとは言いませんが、デメリットを認識した上で利用しましょう。
https://www.pospome.work/entry/2018/01/05/193425

time.Timeが必要な場合は以下のようにtime.Timeを生成するメソッドを持たせることになるでしょう。

type Now time.Time

func (n Now) NewTime() time.Time {
  //省略
}

現在時刻という概念を排除する

Ticket.Expires()は"現在時刻を指定してチケットが有効期限切れかどうかを確認する"ためのメソッドです。
そのため引数には現在時刻を指定する必要があります。
人によっては"クライアントコードが現在時刻を指定することを知っていなければならない点"が気になるのではないでしょうか。

しかし、少し発想を変えると"引数で指定した時刻に対して有効期限切れかどうかを確認する"ためのメソッドという考え方もできます。
プロダクトコードではたまたま現在時刻を引数に指定しているだけです。

メソッド名と変数名をそれを意識した命名にしましょう。
メソッド名をTicket.ExpiresOn()に変更し、引数名もnowからtにすることでそれっぽくなります。

type Ticket struct {
  expiresAt time.Time
}

func (t *Ticket) ExpiresOn(t time.Time) bool {
  return t.expiresAt.Equal(t) || t.expiresAt.After(t)
}



引数のtime.Timeを"現在時刻"ではなく、"対象となる日付"とみなすことによって現在時刻という概念を排除しています。
これによってクライアントコードが自然と現在時刻を指定できるようになるかもしれません。

現在時刻を排除するのは難しい

考え方を変えることで引数にtime.Timeを指定することに対する抵抗感が薄くなった方もいるのではないでしょうか。
しかし、常に現在時刻を排除することができるわけではありません。

Ticket.ExpiresOn()に関しては現実的に現在時刻しか指定されないのであれば、
"引数で指定した時刻に対して有効期限切れかどうかを確認する"ためのメソッドという考え方は不自然でしょうし、
プロダクトコードをテストを考慮した実装に合わせることに違和感を感じるかもしれません。

"発想を変えて現在時刻という概念を排除する"というのは結局その人の考え方に依存します。
そのため、複数人で開発し、人の入れ替わりもあるチーム開発で採用するのはなかなか難しいかもしれません。
現在時刻以外を指定するユースケースが存在しない限り、誰にとっても明確に正当性のある実装にはならないのです。

ここらへんはチームとして方針を固めた方が良いでしょう。
ちなみに最近のpospomeは引数に現在時刻を指定する方針に振り切ってやってみたいと思っています。
やってみないとメリットとデメリットが分からないですからね。

まとめ

今回はテストを並列化する話と現在時刻を引数に指定するかどうかという話をしました。
現在時刻を引数に指定する件については皆さんも一度考えてみてはどうでしょうか。

明日14日目の執筆担当は @syucream です。引き続きお楽しみください。