Goにおけるテスタブルな時刻の取り回し

Mitsuo Sugaya
The Finatext Tech Blog
8 min readFeb 25, 2021

--

Photo by steve_jon on Unsplash

はじめに

はじめまして。Finatext で保険サービスの開発(主にサーバーサイド)をしているすがやです。

いきなりですがみなさん時をかけてますか? 私はかけてます。

…時刻を扱う実装というのは、得てして問題を生みやすいものです。

この記事では時刻の取り回しで生じる問題と、弊社のGoでのサーバー実装におけるそれらの問題との向き合い方を説明します。

時間に起因する課題

  1. unit testしづらい問題

語り尽くされた話題ではありますが、現時刻に依存した実装はunit testしづらいという問題があります。

たとえば単純な例として呼び出し時点が休日であるかを判定するコードがある場合を考えます。

// IsHoliday 休日か
func IsHoliday() bool {
dayOfWeek := time.Now().Weekday()
return dayOfWeek == 0 || dayOfWeek == 6
}

これだけのコードであっても素直にテストを記述しようと思うと、休日・平日いつでも成功するように書くことは難しくなります。

タイミングで落ちる/遅い

本質的には前掲のものと同じ話ですが、少し問題の見え方が異なる例を挙げます。
時刻ベースでデータを分類したり、簡易的な識別子を生成したりするような処理、というものが考えられます。

このような処理について「繰り返し実行したときに同様に分類されること」をテストしたいことがあります。

func TestCategorize(t *testing.T) {
res1 := getCategory()
res2 := getCategory()
if res1 != res2 {
t.Error(“fail”)
}
}

このテストは多くの場合に成功しますが、運悪く1回目の処理と2回目の処理の間にしきい値を超えた場合には失敗してしまいます。

この例はとても単純なものですが、for文で大量のアサーションを回したりしていて、「たまに落ちるテスト」に心当たりは無いでしょうか。

また、これとは逆に「しきい値を超えた場合には違う結果が返ること」をテストしたい場合にも困ってしまいます。各呼び出しの間にSleepを挟んだりして経過を待つのは辛いですよね。

未来の動作確認ができない問題

同様の問題はunit testに限らず、APIや画面の挙動をリクエストして確認したい場合にも発生します。

休日限定のキャンペーン機能を実装したり、あるいは管理画面から投入した期間設定に従って動作が変わる機能を実装することはよくあるのではないでしょうか。

このような処理の動作確認も実際にその時を待たねばならないとすると歯がゆいですね。

実装パターン

前置きが長くなりましたが、具体的にどのように対処しているかを説明します。

1. 時間の注入

unit testにおける問題は、関数の内部で呼び出される時間 = time.Now() が勝手に変動する事によって生じています。

そこで、最も素朴には time.Now() を関数内で直接呼び出さないようにさえできれば回避できるわけです。

// 現時刻を受け取る
func IsHoliday(now time.Time) bool {
dayOfWeek := now.Weekday()
return dayOfWeek == 0 || dayOfWeek == 6
}

これだけでも、Test側でよしなに生成した現時刻を引数に渡してあげることで意図したテストをできますね。

しかしこの実装には問題があります。

それは「関数内で時間が進行しない」ということです。

すべての関数においてその内部での処理を一定の時刻で処理をしてよいとは限りません。

処理の各部に置いて厳密な現時刻を取り扱いたいという要望にこの実装は耐えられません。

そこでこれらの問題に対処するために Clock というinterfaceを定義します。

Clock は最低限、現時刻のようなものを返してくれれば何でもよいので Now() time.Time だけがあればよいでしょう。

type Clock interface {
Now() time.Time
}

利用側としては、以下のように Clock を手渡します。

// 現時刻を取得できるInterfaceを備えたものを受け取る
func IsHoliday(c clock.Clock) bool {
today := c.Now().Weekday()
return today == 0 || today == 6
}

プロダクションコードでは Clock の中身として実際の time.Now() へのアクセスを提供する実装を、テストコードでは都合の良い時刻を返却する実装を渡してあげることで、テスト容易性と厳密な時刻へのアクセスを両立させることができます。

ちなみに、テストのためにより高機能なClockが必要な場合には個別に実装しても良いと思いますし、 https://github.com/Songmu/flextime のようなpackageを利用させていただくのもアリだと思います。

余談: 用途に応じた時間の取り回し

ところで、上記の説明では「プロダクションコードで厳密な現時刻にアクセスできないこと」を問題として挙げました。

一方で時間に関連する課題として、逆に「厳密な現時刻にアクセスすべきでないこと」もあります。厳密にはこれはtimeの問題ではなく、要件・仕様とちゃんと向き合おうという話ですが…。

たとえば「リクエスト時点の同日のデータを返す」処理で、複数種類のデータを取る必要がある場合に、都度 Now() を呼び出してしまうと、処理中に日付をまたいだ場合に意図せぬデータが返る恐れがあります。
現時刻を取るための Clock と、 処理の文脈で定まった time.Time は、似て非なるものだということを意識して引数を設計する必要があります。

もっとも、リファクタリングの中で無意識的に var now time.Time と time.Now() が自然に使い分けられていることも多いと思います。

リクエストの文脈による時間の注入

ここまでは実装レベルで時計を切り替える手法を説明してきました。

しかしながら、先に述べた「未来の動作確認」のような場合には、実装レベルで切り替えるというアプローチはできません。

サーバー自体の時計をずらして立ち上げ直して試す、という方法も考えられないわけでは有りませんがなかなか煩雑ですよね。

そこで弊社のいくつかのプロジェクトの検証環境では独自のリクエストヘッダ X-TRAVELED-TIME という仕組みを提供しています。

このヘッダーにRFC3339形式の時刻を指定したリクエストを受けたサーバーは、処理の冒頭でこれをパースした時刻を現時刻として取り扱う Clock を生成します。

var myClock clock.Clock
myClock = clock.NewRealClock()
if traveledTime, err := time.Parse(time.RFC3339, c.Request().Header.Get(“X-TRAVELED-TIME”)); err == nil {
myClock = clock.NewTraveledClock(time.FixedZone(“Asia/Tokyo”, 9*60*60), &traveledTime)
}

先述の各処理に Clock を渡す実装を前提として、この Clock を引き渡すことで「リクエストごとに指定された時刻」での動作を検証することができます。
なお、この Clock は指定時刻でカッチリ固定するのではなく、 Now() の呼び出しごとにミリ秒単位で時刻を進めたりできるとなお良いでしょう。

検討

ところで、Clock の引き回し方には色々な手法がありそうですよね。
個人的にはかなりベタに、func ごとに明示的に Clock や現在時刻を引数で渡してあげるのが好きです。

それによって、当該処理が現時刻に依存しているのか、ある時点に依存しているのか、あるいは依存していないのかがシグネチャから明らかとなります。

しかしながら、引数を増やさずに広い範囲で気軽に扱えるようにしておきたいという視点ではリクエストコンテキストにこの役割を負わせたり、package変数にしたりというアプローチも有りえるかな、と思います。

最後に

現在 Finatext では Goでのサーバー実装に一緒に取り組んでいただけるエンジニアを募集しています!弊社の開発体制などについて、もっと詳しく聞いてみたい方のために、2/25の20:00よりオンラインでの会社説明会を行います。気になる方はぜひ参加お待ちしています!

採用サイトはこちら

--

--