Goで指定日の0時ジャスト(00:00:00)を取得する処理とテスト

#概要
担当しているプロジェクトで当日の0時ジャストのタイミングを取得したいという要件があり、以下の記事を参考に実行しました。

[golangのtime.Timeの当日00:00:00を取得する方法とベンチマーク](https://qiita.com/ushio_s/items/3e270933641710bbd88e)

が、実装にあたり問題があったので0時ジャストを取得する方法を確認しつつ、時間処理で注意すべきことをまとめます。

# TL;DR

処理速度を求めないのであれば、以下のようにすればできます。処理速度を求めるのであればUnixTimeを使ったやり方がありますが、タイムゾーンの処理で問題が起こる可能性があるのでこの記事を読むことをお勧めします。あと、時間処理はテスト書いた方が良いです。

```go
func (timeUtil *TimeUtilUsingDate) GetJust0AM(t time.Time) time.Time {

	return time.Date(t.Year(),
		t.Month(),
		t.Day(),
		0, 0, 0, 0, t.Location())
}
```

# 動作方法の確認とテスト

関数とテストを作成します。以下で一通り確認できます。
https://play.golang.org/p/wCgi5uJ1b9O

## さまざな実現方法

オリジナルの記事と同様に以下のやり方でやりたことを実現できます。

### fmtを使って文字列整形する

```go
func (timeUtil *TimeUtilUsingFmt) GetJust0AM(t time.Time) time.Time {

	st := fmt.Sprintf("%s 00:00:00 %s", t.Format("2006-01-02"), t.Format("-0700 MST"))
	day, _ := time.Parse("2006-01-02 15:04:05 -0700 MST", st)

	return day
}
```

### time.Formatを使って文字列整形する

```go
func (timeUtil *TimeUtilUsingFormat) GetJust0AM(t time.Time) time.Time {

	st := t.Format("2006-01-02 00:00:00 -0700 MST")
	day, _ := time.Parse("2006-01-02 15:04:05 -0700 MST", st)

	return day
}
```

### 差分を計算してAddを使う

```go
func (timeUtil *TimeUtilUsingAdd) GetJust0AM(t time.Time) time.Time {

	nanosecond := time.Duration(t.Nanosecond())
	second := time.Duration(t.Second())
	minute := time.Duration(t.Minute())
	hour := time.Duration(t.Hour())
	dur := -1 * (nanosecond + second*time.Second + minute*time.Minute + hour*time.Hour)
	day := t.Add(dur)

	return day
}
```

### UnixTimeを使う
（注意）この関数をそのまま使わないでください！理由は後述します。

```go
func (timeUtil *TimeUtilUsingUnixTime) GetJust0AM(t time.Time) time.Time {

	ut := t.Unix()
	_, offset := t.Zone()
	day := time.Unix((ut/86400)*86400-int64(offset), 0).In(t.Location())

	return day
}
```

### Dateで0時0分を再設定する

これはオリジナルの記事になかった方法ですが、これでも動きそうです。

```go
func (timeUtil *TimeUtilUsingDate) GetJust0AM(t time.Time) time.Time {

	return time.Date(t.Year(),
		t.Month(),
		t.Day(),
		0, 0, 0, 0, t.Location())
}
```

# テストの作成と実行

## テストの作成
```go
func TestGetJust0AM(t *testing.T) {

	loc := time.UTC
	check := time.Date(2019, time.October, 30, 15, 1, 2, 3, loc)
	want := time.Date(2019, time.October, 30, 0, 0, 0, 0, loc)

	tests := []struct {
		name  string
		util  TimeUtil
		check time.Time
		want  time.Time
	}{

		{
			name:  "fmtを使って文字列整形する",
			util:  &TimeUtilUsingFmt{},
			check: check,
			want:  want,
		},
		{
			name:  "time.Formatを使って文字列整形する",
			util:  &TimeUtilUsingFormat{},
			check: check,
			want:  want,
		},
		{
			name:  "差分を計算してAddを使う",
			util:  &TimeUtilUsingAdd{},
			check: check,
			want:  want,
		},
		{
			name:  "time.Dateを使う",
			util:  &TimeUtilUsingDate{},
			check: check,
			want:  want,
		},
		{
			name:  "Unixタイムスタンプを利用する",
			util:  &TimeUtilUsingUnixTime{},
			check: check,
			want:  want,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {

			result := tt.util.GetJust0AM(tt.check).In(loc)
			want := tt.want.In(loc)
			if !want.Equal(result) {
				t.Fatalf("return want to be %+v but returned %+v", want, result)
			}
		})
	}

}
```

## テストの実行
正しく動いてそうです。

```
=== RUN   TestGetJust0AM
=== RUN   TestGetJust0AM/fmtを使って文字列整形する
=== RUN   TestGetJust0AM/time.Formatを使って文字列整形する
=== RUN   TestGetJust0AM/差分を計算してAddを使う
=== RUN   TestGetJust0AM/time.Dateを使う
=== RUN   TestGetJust0AM/Unixタイムスタンプを利用する
--- PASS: TestGetJust0AM (0.00s)
    --- PASS: TestGetJust0AM/fmtを使って文字列整形する (0.00s)
    --- PASS: TestGetJust0AM/time.Formatを使って文字列整形する (0.00s)
    --- PASS: TestGetJust0AM/差分を計算してAddを使う (0.00s)
    --- PASS: TestGetJust0AM/time.Dateを使う (0.00s)
    --- PASS: TestGetJust0AM/Unixタイムスタンプを利用する (0.00s)
```

# タイムゾーンを変えてみる
オリジナルの記事では全てUTCで検証をしていましたが、実際の業務で使うのは日本時間なのでタイムゾーンを設定します。

https://play.golang.org/p/sp2x3Lzymri

```go
	//timezoneをJSTにする
	//loc := time.UTC
	loc, _ := time.LoadLocation("Asia/Tokyo")
```

これでも正しく動いていそうです。

```
=== RUN   TestGetJust0AM
=== RUN   TestGetJust0AM/fmtを使って文字列整形する
=== RUN   TestGetJust0AM/time.Formatを使って文字列整形する
=== RUN   TestGetJust0AM/差分を計算してAddを使う
=== RUN   TestGetJust0AM/time.Dateを使う
=== RUN   TestGetJust0AM/Unixタイムスタンプを利用する
--- PASS: TestGetJust0AM (0.00s)
    --- PASS: TestGetJust0AM/fmtを使って文字列整形する (0.00s)
    --- PASS: TestGetJust0AM/time.Formatを使って文字列整形する (0.00s)
    --- PASS: TestGetJust0AM/差分を計算してAddを使う (0.00s)
    --- PASS: TestGetJust0AM/time.Dateを使う (0.00s)
    --- PASS: TestGetJust0AM/Unixタイムスタンプを利用する (0.00s)
```

# 落とし穴

これで大丈夫！と思うと、落とし穴があります。

https://play.golang.org/p/2buP9LtClo6

テストの時間を15時から8時に変えます

```go
	check := time.Date(2019, time.October, 30, 15, 1, 2, 3, loc)
	want := time.Date(2019, time.October, 30, 0, 0, 0, 0, loc)
```

↓

```go
	check := time.Date(2019, time.October, 30, 8, 1, 2, 3, loc)
	want := time.Date(2019, time.October, 30, 0, 0, 0, 0, loc)
```

そうすると、

```
=== RUN   TestGetJust0AM
=== RUN   TestGetJust0AM/fmtを使って文字列整形する
=== RUN   TestGetJust0AM/time.Formatを使って文字列整形する
=== RUN   TestGetJust0AM/差分を計算してAddを使う
=== RUN   TestGetJust0AM/time.Dateを使う
=== RUN   TestGetJust0AM/Unixタイムスタンプを利用する
--- FAIL: TestGetJust0AM (0.00s)
    --- PASS: TestGetJust0AM/fmtを使って文字列整形する (0.00s)
    --- PASS: TestGetJust0AM/time.Formatを使って文字列整形する (0.00s)
    --- PASS: TestGetJust0AM/差分を計算してAddを使う (0.00s)
    --- PASS: TestGetJust0AM/time.Dateを使う (0.00s)
    --- FAIL: TestGetJust0AM/Unixタイムスタンプを利用する (0.00s)
        prog.go:121: return want to be 2019-10-30 00:00:00 +0900 JST but returned 2019-10-29 00:00:00 +0900 JST
FAIL
```

Unixタイムスタンプを利用したものが正しく動いていなさそうです。

# 不具合の修正
## テスト
まずテストを各関数、複数の時間でテストできるように直します。

https://play.golang.org/p/lbL3fwtTGBe

これが通れば行けそうです。

```
    --- FAIL: TestGetJust0AM/Unixタイムスタンプを利用する (0.00s)
        --- PASS: TestGetJust0AM/Unixタイムスタンプを利用する/前日ラスト (0.00s)
        --- FAIL: TestGetJust0AM/Unixタイムスタンプを利用する/本日0時ジャスト (0.00s)
            prog.go:150: return want to be 2019-10-30 00:00:00 +0900 JST but returned 2019-10-29 00:00:00 +0900 JST
        --- FAIL: TestGetJust0AM/Unixタイムスタンプを利用する/タイムゾーン前 (0.00s)
            prog.go:150: return want to be 2019-10-30 00:00:00 +0900 JST but returned 2019-10-29 00:00:00 +0900 JST
        --- PASS: TestGetJust0AM/Unixタイムスタンプを利用する/タイムゾーンジャスト (0.00s)
        --- PASS: TestGetJust0AM/Unixタイムスタンプを利用する/タイムゾーンジャスト後 (0.00s)
        --- PASS: TestGetJust0AM/Unixタイムスタンプを利用する/本日ラスト (0.00s)
        --- FAIL: TestGetJust0AM/Unixタイムスタンプを利用する/翌日0時ジャスト (0.00s)
            prog.go:150: return want to be 2019-10-31 00:00:00 +0900 JST but returned 2019-10-30 00:00:00 +0900 JST
```

## 不具合の修正

以下が修正されたものでテストが通りました！
https://play.golang.org/p/CtCTCFzo57w

###修正前

```go
func (timeUtil *TimeUtilUsingUnixTime) GetJust0AM(t time.Time) time.Time {

	ut := t.Unix()
	_, offset := t.Zone()
	day := time.Unix((ut/86400)*86400-int64(offset), 0).In(t.Location())

	return day
}
```

### 修正後

タイムゾーン（とそれに伴うオフセット）の考慮漏れでした

```go
func (timeUtil *TimeUtilUsingUnixTime) GetJust0AM(t time.Time) time.Time {

	ut := t.Unix()
	_, offset := t.Zone()
	day := time.Unix(((ut+int64(offset))/86400)*86400-int64(offset), 0).In(t.Location())

	return day
}
```

これできっと大丈夫なはず！ですが、1970年以前だともちろん正しく動きませんし、int64の制約を振り切った場合も動かないはずです。時間の処理は奥が深いですね…。

# 大事なこと

なぜUnixTimeを使ったやり方で問題があったかというとタイムゾーンの考慮漏れなのですが、本質的にはUnixTimeはUTCベースの処理となっているため、利用するときは暗黙的にUTCと利用するタイムゾーンとの変換を行わなければならないことになります（具体的にはoffsetの足し引き）。

time.Unixを利用する場合は、タイムゾーンを意識したテストを書いた方が確実です。そこを意識したくなかったり、そこまで高速である必要がなければ他の手段を取るのが良いかと思います。なお自分が担当したプロジェクトは負荷がそこまでシビアではなかったので、Dateで0時0分を指定する方法を使用しています。

```go
func (timeUtil *TimeUtilUsingDate) GetJust0AM(t time.Time) time.Time {

	return time.Date(t.Year(),
		t.Month(),
		t.Day(),
		0, 0, 0, 0, t.Location())
}
```

コードがわかりやすくシンプルであるのと、当日のN時を取りたいと行った時に応用が聞くので負荷を考えなくて良いのであれば、この方法をお勧めします。

# おまけ

ちなみに、タイムゾーンの話やテストを全く見せないで、この関数の中から正しくない関数とその理由を述べよ、とすると大分難しいクイズになるかなと思います。ぜひ誰かに試してみてください！

https://play.golang.org/p/M8p-ItYVor1