XCTest+αでJSON形式のテストデータを使い倒す

テストを拡充させていくにあたり，検証に都合のよいデータを手軽に用意できるようにした作業内容のメモ．
サンプルコード→[gaussbeam/TestDataloader](https://github.com/gaussbeam/TestDataLoader)

## やりたいこと
- あらかじめ用意しておいた`.json`ファイルと型を指定することで，jsonファイル内のデータをもとに必要な形式のデータを取得できるようにする

### なぜやりたいのか
1. テストコード内で`init()`すると，テスト自体とは直接関わりのないコードが増え，テストの内容が把握しづらくなる
1. そもそも`init(from decoder: Decoder)`しか実装していないようなオブジェクトの場合，コードからのテストデータ生成のために別途イニシャライザを定義する必要がある
1. 異なるテストケースで同様のデータを利用する場合，その都度データ生成用のコードを書くのは冗長

## やったこと
以下に示す方法でjsonファイルから任意の型のテストデータを生成できるようにした．

### 1. `Loader` Protocolを定義
このProtocolにより，バンドル内のファイル取得→`Data`までの変換を共通的に行うようにした．
また，変換後の型を任意のものにできるよう，`associatedtype`を指定させるようにしている．

実装は以下の通り．

```Swift
protocol Loader {
    associatedtype DataType

    var bundle: Bundle { get }
    func load(resourceName: String) -> DataType
    func loadData(resourceName: String) -> Data
    func convert(data: Data) -> DataType
}

extension Loader {
    var bundle: Bundle {
        return Bundle(identifier: "jsonファイルを含んだバンドルのID")!
    }

    func load(resourceName: String) -> DataType {
        let data = loadData(resourceName: resourceName)
        return convert(data: data)
    }

    func loadData(resourceName: String) -> Data {
        let path = bundle.path(forResource: resourceName, ofType: "json")!
        let url = URL(fileURLWithPath: path)
        return try! Data(contentsOf: url)
    }
}
```

`Loader`は以下のような流れでjsonからオブジェクトへの変換を行う．

1. 所定のバンドル内から，指定されたjsonファイルを取得
2. 取得したjsonファイルの内容を`Data`型に変換
3. `Data`型から`DataType`型(associatedtypeとして紐付け)に変換
4. 変換された`DataType`型のオブジェクトを返す

上記の処理について，1~4までの全体の流れは`load()`メソッドの，1~2は`loadData()`としてデフォルト実装としてそれぞれ定義している．
3の変換処理については`convert()`が該当するが，この処理(および`DataType`の指定)については，`Loader`の適合先でそれぞれ実装させることで，任意の型への変換ができるようにした．


### 2. 型ごとのLoaderを定義
JSON(=`[String: Any]`)と`Decodable`に適合したオブジェクト用の`Loader`を定義する場合，以下のようになる．

```Swift
/// jsonファイルから取得した内容を`[String: Any]`に変換するLoader
struct JsonLoader: Loader {
    typealias DataType = [String: Any]

    func convert(data: Data) -> DataType {
        return try! JSONSerialization.jsonObject(with: data, options: []) as! DataType
    }
}

/// jsonファイルから取得した内容を`Decodable`に適合した任意の型に変換するLoader
struct DecodableObjectLoader<T: Decodable>: Loader {
    typealias DataType = T

    func convert(data: Data) -> DataType {
        return try! JSONDecoder().decode(DataType.self, from: data)
    }
}
```

上記の例以外にも，`Data`から変換可能な型は`Loader`を定義できる．
例えば，JSONのパースに[ObjectMapper](https://github.com/tristanhimmelman/ObjectMapper)を使っているような場合，以下のように定義できる．

```Swift
struct MappableObjectLoader<T: Mappable>: Loader {
    typealias DataType = T

    func convert(data: Data) -> DataType {
        let jsonString = String(data: data, encoding: .utf8)!
        return DataType(JSONString: jsonString)!
    }
}
```

### 3. `Loader`とテストデータをプロジェクトファイル内に組み込み，テストを記述．
`Loader`とテストデータ，およびテストコードを以下のように配置することで，XCTestで利用可能になる．
※ Frameworkを分けている場合に，テストデータやLoaderを共通化したい場合は注意が必要(これはそのうち別記事で書く)
<img width="225" alt="スクリーンショット 2019-12-18 17.11.45.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/124121/e87757c3-7fec-6da7-65bb-99da1d564eb4.png">

`Loader`によるテストデータの取得(およびテストを実行)例は以下の通り．
(詳細は[サンプルコード](https://github.com/gaussbeam/TestDataLoader)を参照)

```Swift
class CouponTests: XCTestCase {
    func testIsUsed() {
        // ジェネリクスでデコード先の型を指定
        let loader = DecodableObjectLoader<Coupon>()
        
        XCTContext.runActivity(named: "usedDate==nilの場合，isUsedはfalse") { _ in
            // 引数としてテストデータのファイル名を指定
            let c = loader.load(resourceName: "unusedCoupon")
            XCTAssertFalse(c.isUsed)
        }

        XCTContext.runActivity(named: "usedDate!=nilの場合，isUsedはtrue") { _ in
            let c = loader.load(resourceName: "usedCoupon")
            XCTAssertTrue(c.isUsed)
        }
    }
}
```

なお，上記のコードで使用したオブジェクトの定義およびjsonファイルは以下の通り．

```Swift
// (参考)Loader経由で取得するオブジェクトの型
struct Coupon: Decodable {
    var id: Int
    var title: String
    var usedDate: Date?
    var isUsed: Bool {
        return usedDate != nil
    }
}
```



```json:unusedCoupon.json
{
    "id": 123,
    "title": "Some coupon",
    "usedDate": null
}
```

#### APIのモックに利用する
上記の例では`Coupon`型にデコードした値のアサーションを行っているが，別の用法として`[String: Any]`としてデコードした結果をAPIのモックに利用することも可能．

例：[Mockingjay](https://github.com/kylef/Mockingjay)と組み合わせた場合

```Swift
class SomeApiTests: XCTestCase {
    func testSomeApi() {
        …
        let body = JsonLoader().load(resourceName: "usedCoupon")
        stub(http(.get, uri: "https://example.com/api/someApi"), json(body))
        …
        // usedCoupon.jsonの内容で書き換えたAPIレスポンスに対するテスト
    }
}
```

## やってみて
### よかったこと
1. テストデータ生成を１行で行えるようになった
    - 当初の動機であった「テスト自体とは直接関わりのないコードの増加を避ける」ことができた．  
また，所定のBundle内にあるファイルから`Data`型で取得しそれを変換し…というプロセスも`Loader`内に集約したので，テストコード自体の見通しが良くなった．
2. １つのデータをいろいろな形式で使えるようになった
    - 例で挙げたように，`Decodable`に適合したオブジェクトやJSON(=`[String: Any]`)など任意の型で取得できるので，異なる観点・やり方のユニットテストにおいて，１つのjsonファイルを使い回せるようになった．
3. レスポンスの書き換えにも利用できる
    - [Charles](https://www.charlesproxy.com/)と組み合わせることで，APIレスポンスの書き換えにも利用できる．  
当初は想定していなかったことだが，ユニットテストのみならず，デバッグ実行時の振る舞い確認においても任意の状況を作ることができ，より開発がしやすくなった．

### 改善したいこと
1. テストデータの置き場
    - 先にも少し触れたが，特定のテストターゲット内にバンドルしているため，Frameworkを分けている場合にテストデータを共用できない．
      - :pencil: [Kickstarter](https://github.com/kickstarter/ios-oss)では共用Framework(`Library`)の中に`TestHelper`というグループを作り，Frameworkをまたいでテスト用のヘルパを共有している
          - この方法をそのまま使うとテストデータもアプリ本体にバンドルされてしまうのでもうひと工夫必要になりそう :thinking: 
2. Bundleの取得方法
    - Bundle自体もIdentifierをベタ書きしているのでポータビリティが低い(一箇所指定するだけなのでそこまで実害はないが…)．
      - これについては，Extensionのデフォルト実装でやっていることに起因しているので，役割を分けてBundleを取得するクラスを使うのがよさそう．

「もっとうまいやり方あるよ！」という場合にはコメントにてお願いします :pray: