$shibayu36->blog;

クラスター株式会社のソフトウェアエンジニアです。エンジニアリングや読書などについて書いています。

Scalaのテスト中にJoda-Timeのnowの時刻を固定する

今回はScalaのテストの話。時刻まわりが関わる実装をテストしたい時、テスト中だけ現在時刻を固定したり、現在時刻を過去にしたりなどといったことをやりたいことはよくある。Joda-TimeのDateTime.nowを使って現在時刻を取得している場合、時刻をfakeして固定することが出来たのでメモ。

固定するにはorg.joda.time.DateTimeUtilsのsetCurrentMillisFixedやsetCurrentMillisProviderを利用する。例えば、以下のように時間を固定することができる。

DateTimeUtils.setCurrentMillisFixed(15147648t00000L) // 2018-01-01に固定
println(DateTime.now) // 2018-01-01T09:00:00.000+09:00
DateTimeUtils.setCurrentMillisSystem() // 元に戻す
println(DateTime.now) // 2018-01-23T05:58:51.118+09:00


これだけでやりたいことは出来たのだけど、テスト用にはユーティリティを作っておくと、さらに便利になる。例えば、

import org.joda.time.{ DateTime, DateTimeUtils }
object TimeFaker {
  /**
   * 指定したtimeMillisに時刻を固定する。ブロックを抜けると元に戻る。
   */
  def fake[T](timeMillis: Long)(block: => T): T = {
    DateTimeUtils.setCurrentMillisFixed(timeMillis)
    try {
      block
    } finally {
      DateTimeUtils.setCurrentMillisSystem()
    }
  }

  /**
   * DateTimeオブジェクトを渡せるバージョン
   */
  def fake[T](t: DateTime)(block: => T): T =
    fake(t.getMillis)(block)

  /**
   * ISODateTimeFormatの形式で渡せるバージョン
   * 例) TimeFaker.fake("2018-03-02T12:34:56") { }
   */
  def fake[T](t: String)(block: => T): T =
    fake(DateTime.parse(t).getMillis)(block)
}

fakeを使うとブロック内だけ時刻が固定され、ブロックを抜けるとシステム時間に戻る。またブロック内で返却したものが結果として返ってくる。

// milliSecondsを渡せる
val result = TimeFaker.fake(1515974400000L) {
  println(DateTime.now) // 2018-01-01T09:00:00.000+09:00
  123
}
println(DateTime.now) // 時刻はもとに戻る
println(result) // 123

// DateTimeのオブジェクトを渡せる
val dt = new DateTime(2018, 2, 13, 14, 59)
TimeFaker.fake(dt) {
  println(DateTime.now) // 2018-02-13T14:59:00.000+09:00
}

// ISODateTimeFormatの形式で渡せる
TimeFaker.fake("2018-03-02T12:34:56") {
  println(DateTime.now) // 2018-03-02T12:34:56.000+09:00
}

便利!

ただしDateTimeUtilsを使った書き換えはスレッド共有の空間を書き換えるので、マルチスレッドでテストする時は動かないことに注意しましょう。

gitのcommit日時順にファイル一覧を表示する

レポジトリ内でドキュメントを探している時に、特定ディレクトリの中のファイルをgitのcommit日の順に並べて新しめのファイルを見つけたいなーと思ったことがあったのでやってみた。

例えば https://github.com/golang/go のdoc/以下にある全てのファイルをcommit日時順に一覧してみるには、以下のコマンド一つで良い。

$ git ls-files doc | xargs -I@ bash -c 'echo "$(git log -1 --format="%aI" -- @)" @' | sort -r
2018-01-11T11:30:49-05:00 doc/go1.10.html
2018-01-11T10:41:03-08:00 doc/go_spec.html
2018-01-09T15:32:22-05:00 doc/diagnostics.html
2018-01-09T15:32:22-05:00 doc/debugging_with_gdb.html
2018-01-09T15:26:21-05:00 doc/install-source.html
2018-01-09T15:26:21-05:00 doc/go_faq.html
...

やっていることは

  • git ls-files doc
    • doc以下のファイル一覧を出す
  • xargs -I@ bash -c 'echo "$(git log -1 --format="%aI" -- @)" @'
    • xargsの-Iオプションを使って、ファイル名一つ一つをコマンドに渡す
    • 例えばbash -c 'echo "$(git log -1 --format="%aI" -- doc/go1.10.html)" doc/go1.10.html'のように展開される
  • git log -1 --format="%aI" -- @
    • あるファイルの最新のcommit日時をISO 8601のフォーマットで表示
  • ISO 8601形式なら文字列ソートでも日時順になるのでsort -rする

という感じ。xargs便利。

golangでAPIなど外部にアクセスするロジックのテストをする

golangで、例えばGithubAPIを叩くような、特定のAPIにアクセスするロジックを書いた時、何も考えずにテストを書くと、テストを実行する際にもそのまま外部のAPIにアクセスしてしまう。この場合、色んなパターンのテストを書きづらい、依存している外部サービスが落ちたらテストも一緒に落ちるなどの問題が起こる。

このような問題から、統合テストではなくユニットテストのときは手元のみで完結して、外部サービスに依存しない状況でテストを書きたくなることがある。そこで今回は外部にアクセスするロジックを、手元で完結させた状態でテストする方法を試したので、その方法について書いてみる。

テストしたいコード

例えば以下のようなコード。Githubhttps://github.com/shibayu36/shibayu36 の最新のリリースタグを取得し、そのリリースタグ名を出力する。これはGithubReleases APIにアクセスしている。

package main

import (
package main

import (
	"context"
	"fmt"

	"github.com/google/go-github/github"
)

func main() {
	ctx := context.Background()
	client := github.NewClient(nil)
	release, _, _ := client.Repositories.GetLatestRelease(ctx, "shibayu36", "shibayu36")
	fmt.Println(release.GetTagName())
}

テストを書くための作戦

上記のような実装をそのままテストしようとすると、

  • テスト中にGithubAPIにアクセスしてしまう
  • Github上に様々なデータを作らないと、色んなパターンのテストが出来ない

という問題がある。

そこで以下のような作戦でテストを書けるようにする。

  • 実装はstructのメソッドとして定義し、APIのベースのURLを切り替え可能にする
  • テスト中はhttp.NewServeMux() + httptestを利用し、テスト用のサーバを立てる

ではこの二点をそれぞれ対応してみる。

実装はstructのメソッドとして定義し、APIのベースのURLを切り替え可能にする

まず先程のコードをテストしやすいように、「GithubAPIにアクセスし最新のリリースタグ名を取得する」という部分をメソッド化する。そのメソッドをmain関数で呼んで、リリースタグ名を出力する。

package main

import (
	"context"
	"fmt"
	"net/url"

	"github.com/google/go-github/github"
)

type myClient struct {
	apiGithubURL string
}

// ownerとrepoから最新のリリースタグ名を取得するメソッド
func (c *myClient) GetLatestReleaseTagName(owner, repo string) (string, error) {
	ctx := context.Background()
	client := github.NewClient(nil)

	// Github APIのベースURLを切り替えられるようにしておく
	client.BaseURL = c.getAPIGithubURL()

	release, _, err := client.Repositories.GetLatestRelease(ctx, owner, repo)
	if err != nil {
		return "", err
	}
	return release.GetTagName(), nil
}

// go-githubのクライアントのClient.BaseURLに渡せるURLオブジェクト作成
// myClientインスタンス作成時にapiGithubURLを明示的に指定したら、
// それを利用するように。そうでなければデフォルトのURLを使う。
func (c *myClient) getAPIGithubURL() *url.URL {
	u := "https://api.github.com"
	if c.apiGithubURL != "" {
		u = c.apiGithubURL
	}
	apiURL, _ := url.Parse(u + "/")
	return apiURL
}

func main() {
	tagName, err := (&myClient{}).GetLatestReleaseTagName("shibayu36", "shibayu36")
	fmt.Println(tagName, err)
}

この部分でのポイントは

  • github.com/google/go-github/github で作ったclientはclient.BaseURLにURLオブジェクトを与えることでAPIのベースURLを切り替えることができる
  • githubAPIを叩く時に、myClientのapiGithubURLフィールドが指定されていたらそちらに、そうでなければ普通にapi.github.comを利用するようにclient.BaseURLを指定する
    • client.BaseURLにそのまま渡せるオブジェクトを作るgetAPIGithubURLメソッドを作っておくと便利

これで、&myClient{}でオブジェクトを作ったら普通にGithubにアクセスし、&myClient{apiGithubURL: "http://api.example.com"}でオブジェクトを作っておくと、http://api.example.com にアクセスするような実装になった。

http.NewServeMux() + httptestを利用し、テスト用のサーバを立てる

実装でアクセス先を切り替えられるようになった。あとはテスト中にダミーのサーバを立てて、アクセス先をそちらに切り替えてテストすれば良い。以下のとおり。これで外部にアクセスせずに、APIなどを使うテストをかくことが出来た。

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestGetLatestReleaseTagName(t *testing.T) {
	// NewServeMuxを使えば、特定のPATHでレスポンスを返すような
	// アプリケーションを簡単に書けるので、
	// shibayu36/sample-repoの最新リリースAPIを模倣する。
	mux := http.NewServeMux()
	mux.HandleFunc("/repos/shibayu36/sample-repo/releases/latest", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, `{"tag_name": "0.0.1"}`)
	})

	// テスト用に作ったアプリケーションでテストサーバを立てる
	apiGithubServer := httptest.NewServer(mux)
	defer apiGithubServer.Close()

	// APIアクセスを立てたテストサーバに向けてmyClientオブジェクトを作成
	c := &myClient{
		apiGithubURL: apiGithubServer.URL,
	}

	{
		// 成功パターンのテスト
		tagName, err := c.GetLatestReleaseTagName("shibayu36", "sample-repo")
		assert.NoError(t, err)
		assert.Equal(t, "0.0.1", tagName, "shibayu36/sample-repoのタグが取れている")
	}

	{
		// 失敗パターン(レポジトリがない)のテスト
		_, err := c.GetLatestReleaseTagName("shibayu36", "wrong-repo")
		assert.Error(t, err, "存在しないrepoなのでエラー")
	}
}

まとめ

今回はgolangで外部にアクセスするロジックを手元で完結させた状態でテストする方法について書いてみた。

テスト用のダミーサーバを立てるには、http.NewServeMux() + httptestが非常に便利だった。golangの場合、テスト時にgithub.Client.Repositories.GetLatestReleaseを置き換えるみたいなことが出来ないため、実装部分でうまく置き換えられるようにしておくのが少し難しかった。

ちなみにmkrのプラグインインストーラも今回の作戦でテストをしているので参考にどうぞ。