DateFormatterを完全に理解した記事を書いてみました

あるいは`Date`の時差の変換でハマった話しです。
海外対応を実装した方がターゲット層です。  

# 概要

今回はiOSの`Date`と`DateFormatter`の仕様について話して行きたいと思います。
今週これだけで1, 2日ハマってしまいましたので備忘録も込めてまとめます。

## ハマったことについて

例えば次のようなコードがあったとします。
簡単に言えば`Date`のExtensionを作って日付の処理について簡単に扱う為に作った関数です。

```DateConvert.swift

extension Date {
    var japaneseDeviceDateFormatter: DateFormatter {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
        dateFormatter.calendar = Calendar(identifier: .gregorian)
        return dateFormatter
    }
    
    var deviceDateFormatter: DateFormatter {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.timeZone = .current
        dateFormatter.calendar = Calendar(identifier: .gregorian)
        return dateFormatter
    }
    
    func getSampleLocalDate() -> String {
        let localDate = Date()
        let df = localDate.deviceDateFormatter
        df.dateFormat = "yyyyMMddHHmm"
        let testableDateString = df.string(from: localDate)
        return testableDateString
    }
}
```

関数の書き方とか確かにそういうところもツッコミたいかもしれませんが、
そこはグッと抑えてください。  

## テストの仕方

昔のシミュレータはタイムゾーンを変更できた記憶があるのですが、今のシミュレータはできないみたいです。
出来る方はこっそり教えてください。  

実機で海外の時間に変更したい場合は
`設定 > 一般 > 日付と時刻`
の**自動設定**をオフにすると各国の時間帯に変更することができます。  

<img width="375" alt="IMG_6853.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44392/2eb92799-3b83-9905-8ccc-1daf1bf57422.png">

これで各国の時間でデバッグすることができます。  
テストコードを走らせる場合は実機をMacに繋げてXcodeでテストスキームで実機でビルドしないと駄目な気がします。  

それではテストしていきます。

## テストする仕様について

1. 今回は海外在住のユーザーが日本のコンテンツを日本時刻で見たいというニーズに合わせて、海外時刻を日本時刻に変換する処理をテストしたいと思います。
2. 海外時刻を関数に渡す時には`Date`が使えず`"201905180110"`というような形の`文字列`しか引数に渡すことができません。(例の文字列は`2019年05月18日1:10`)

つまり、海外の日付の`文字列`から日本時刻の`文字列`ひいては`Date`を取得する関数が欲しいです

## テスト失敗例その１

こちらは自分でテストを書いておきながらハマってしまったコードの例です。

```DateSampleTests.swift
import XCTest
@testable import DateSample

class DateSampleTests: XCTestCase {
    
    func testBatPractice() {
        let localDate = Date()
        let localDateFormatter = localDate.deviceDateFormatter
        localDateFormatter.dateFormat = "yyyyMMddHHmm"
        guard let currentLocalDate = localDateFormatter.date(from: localDate.getSampleLocalDate()) else { return }
        let currentDateString = localDateFormatter.string(from: currentLocalDate)
        print("currentLocalDate:", currentLocalDate.description, ", currentDateString:", currentDateString)
        
        let jpDateFormatter = localDate.japaneseDeviceDateFormatter
        let jpDateString = jpDateFormatter.string(from: currentLocalDate)
        guard let jpDate = jpDateFormatter.date(from: jpDateString) else { return }
        print("jpDate:", jpDate.description, ", jpDateString:", jpDateString)
        XCTAssertNotEqual(currentDateString, jpDateString)
    }
}
```

これはテストが失敗します。  

## 正解のテストコード

一見うまいこと行くように思われますが

```DateSampleTests.swift
        let jpDateFormatter = localDate.japaneseDeviceDateFormatter
        let jpDateString = jpDateFormatter.string(from: currentLocalDate) // ""
}
```

このステップで落ちてしまいます。  
`jpDateString`が生成されなくて落ちてしまうみたいです。  

```
jpDate: 1999-12-31 15:00:00 +0000 , jpDateString: 
Printing description of jpDateString:
""
```

正しくは次のコードになります。  

```DateSampleTests.swift
import XCTest
@testable import DateSample

class DateSampleTests: XCTestCase {

    override func setUp() {
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }
    
    func testConvertToJapanese() {
        let localDate = Date()
        let localDateFormatter = localDate.deviceDateFormatter
        localDateFormatter.dateFormat = "yyyyMMddHHmm"
        guard let currentLocalDate = localDateFormatter.date(from: localDate.getSampleLocalDate()) else { return }
        let currentDateString = localDateFormatter.string(from: currentLocalDate)
        print("currentDate:", currentLocalDate.description, ", currentDateString:", currentDateString)
        
        let jpDataFormatter = localDate.japaneseDeviceDateFormatter
        jpDataFormatter.dateFormat = "yyyyMMddHHmm" // NEW
        let jsDateString = jpDataFormatter.string(from: currentLocalDate)
        guard let jpDate = jpDataFormatter.date(from: jsDateString) else { return }
        print("jpDate:", jpDate.description, ", jpDateString:", jsDateString)
        XCTAssertNotEqual(currentDateString, jsDateString)
    }
}
```

`// NEW`の部分が修正コードになります。  
dateFormatterを日本用に新しく生成した場合は最初`dateFormat`を指定しないと`Date`から`String`に日本時刻で変換できない仕様だということが分かりました。  
当然と言えば当然なのですが焦っているときにExtensionで作られているクラスのテストでは思いつきません。  

もしかすると既に気づいている人もいるかもしれませんが、海外用の`DateFormatter`と日本用の`DateFormatter`の関数が既にあったので僕は勘違いしてしまいました。  

実はこんな回りくどい処理をする必要がないのです。  
そもそも文字列などから`Date`のインスタンスを生成した場合は`UTC`基準なのです。  

[【Swift】Dateの王道 【日付】](https://qiita.com/rinov/items/bff12e9ea1251e895306)

(この記事によると既に一番上の参考コードはバットプラクティスのオンパレードですがそこはスルーで)

<img width="478" alt="スクリーンショット 2019-05-18 15.05.04.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44392/11f98cdc-80c8-d962-b0d8-4ee9112db4c3.png">

ということはDateを作成した時点で既に`TimeZone`の概念が入っているので`TimeZone`を変更するだけでいいのです。

## 正解のテストコード (その２)

以上をもって上のテストコードをもっとスマートに書くなら例えば次のように変更すればいいのです。

```DateSampleTests.swift
import XCTest
@testable import DateSample

class DateSampleTests: XCTestCase {

   func testConvertToJapaneseBestPractice() {
        let localDate = Date()
        let localDateFormatter = localDate.deviceDateFormatter
        localDateFormatter.dateFormat = "yyyyMMddHHmm"
        guard let currentLocalDate = localDateFormatter.date(from: localDate.getSampleLocalDate()) else { return }
        let currentDateString = localDateFormatter.string(from: currentLocalDate)
        print("currentDate:", currentLocalDate.description, ", currentDateString:", currentDateString)
        
        localDateFormatter.timeZone = TimeZone(identifier: "Asia/Tokyo") // TimeZone を変える
        let jsDateString = localDateFormatter.string(from: currentLocalDate)
        guard let jpDate = localDateFormatter.date(from: jsDateString) else { return }
        print("jpDate:", jpDate.description, ", jpDateString:", jsDateString)
        XCTAssertNotEqual(currentDateString, jsDateString)
    }
}
```

`// TimeZone を変える`のように既に作成した`localDateFormatter`のTimeZoneを日本時刻に変更すればわざわざ日本用の`DateFormatter`のインスタンスを作り直す必要がないのです。  
言われて見れば「確かに！」と思う内容なのですが今まで使いまわしてタイムゾーンを変更する機能を作ったことがありませんでしたので拍子抜けしてしまいました。

そもそも海外の時刻を`文字列`で持ってそれを日本の時刻の`文字列`に変換する意味ってないやん、というツッコミもあるかもしれませんが仮にそういう境遇に陥ってしまった場合は今日の記事が参考になるかと思います。

1週間前にこの記事を読んでいたらこの1週間は徹夜する必要がなかったぐらいです。


# DateFormatter について

元々は`NSDateFormatter`ですがSwiftの登場によって`DateFormatter`に呼び方が変わりました。
Date()クラスを修飾したいに使うクラスになります。

初期宣言

```Sample.swift
let dateFormatter = DateFormatter()
```

だいたいみんなが実装に困る部分の設定は

- calendar
- locale
- timezone

かなと思います。

### calendar

設定は`一般 > 言語と地域 > 暦法`から変更できます。

<img width="375" alt="IMG_6854.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44392/8f9864ff-1075-8ec4-7b2c-969ec0ad2794.png">


| Left align       |       Right align |
|:----------------:|:-----------------:|
| gregorian             |              グレゴリオ歴 |
| japanese           |            和暦 |  
| chinese             |              中国暦 |
| indian               |                インド暦 | 

みたいな感じになります。

[iOS アプリ開発　全アプリ共通でやっておきたいUIまわりのテスト](https://qiita.com/homyu/items/fa39dec7d161bef9de0e)が詳しいです。

和暦は24時間対応の際にiPadとかでバグの温床になるケースが多いので`gregorian`を指定することが多いです。
`japanese`の方が設定は簡単ですが長期的運用を考えた場合はこちらがオススメです。

```Sample.swift
        let dateFormatter = DateFormatter()
        dateFormatter.calendar = Calendar(identifier: .gregorian)
```

### locale

文字列 から 日付 へ変換する際にどのロケールで変換するのかっていう設定で使います。

- ja_JP
- ja
- en_US_POSIX

とかのオプションがあります。

表についてはこちらのページが一覧になります。

https://gist.github.com/jacobbubu/1836273

一般的には`ja_JP`か`en_US_POSIX`で設定することが多いです。

```Sample.swift
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "ja_JP")
```

### timezone

これが今回の記事のメインテーマになったプロパティです。

iPhoneの実機の表示時刻で各国の時間に合わせる時に使うオプションになります。

表については

https://helpspot.readdle.com/calendars/index.php?pg=kb.page&id=588

こちらが参考になります。

海外対応や時差の計算で使うと思います。

```Sample.swift
        let dateFormatter = DateFormatter()
        dateFormatter.timeZone = TimeZone(identifier: "GMT") // 標準時間
        dateFormatter.timeZone = TimeZone(identifier: "Asia/Tokyo") // 日本時間
        dateFormatter.timeZone = TimeZone(identifier: "Asia/Seoul") // ソウル時間
        dateFormatter.timeZone = .current // 実機での設定時間
```

とりあえずここまでの情報や参考ページを読み込めばDateとDateFormatterとの関係性について深く理解できると思います。