LoginSignup
5
2

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-19

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

やりたいこと

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

なぜやりたいのか

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

やったこと

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

1. Loader Protocolを定義

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

実装は以下の通り.

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を定義する場合,以下のようになる.

/// 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を使っているような場合,以下のように定義できる.

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を共通化したい場合は注意が必要(これはそのうち別記事で書く)
スクリーンショット 2019-12-18 17.11.45.png

Loaderによるテストデータの取得(およびテストを実行)例は以下の通り.
(詳細はサンプルコードを参照)

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ファイルは以下の通り.

// (参考)Loader経由で取得するオブジェクトの型
struct Coupon: Decodable {
    var id: Int
    var title: String
    var usedDate: Date?
    var isUsed: Bool {
        return usedDate != nil
    }
}
unusedCoupon.json
{
    "id": 123,
    "title": "Some coupon",
    "usedDate": null
}

APIのモックに利用する

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

例:Mockingjayと組み合わせた場合

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

改善したいこと

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

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

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2