Dependency Injection Framework for Swift - Swinjectを利用したシンプルな天気アプリの例 その1/2

以下のブログ記事の翻訳です[^訳注1]。
[Dependency Injection Framework for Swift - Simple Weather App Example with Swinject Part 1/2](http://yoichitgy.github.io/post/dependency-injection-framework-for-swift-simple-weather-app-example-with-swinject-part-1/)

---

[前回のブログ記事](http://qiita.com/yoichitgy/items/ed8aac4d6f1e6666a4aa)では、dependency injection (依存性の注入) のコンセプトとSwift用のフレームワークである[Swinject](https://github.com/Swinject/Swinject)の基本的な使い方を見てきました。今回の記事では、前回スクリーンショットだけお見せしたシンプルな天気アプリを開発していきます。シンプルですが本質をついた開発手順の中で、dependency injectionパターンとSwinjectを利用し、密結合した依存性を疎結合にする方法を見ていきます。

このブログ記事で使用するソースコードは[GitHubのリポジトリ](https://github.com/Swinject/SwinjectSimpleExample)からダウンロードできます。

![SwinjectSimpleExampleScreenshot.png](https://qiita-image-store.s3.amazonaws.com/0/86677/9a107f82-122f-5e6e-8e89-96d20495d608.png)

## 要求事項

- Xcode 7 (beta)
- OpenWeatherMap APIキー
- CocoaPods 0.38以上

まだベータ版ですがXcode 7を使用します。このブログ記事の執筆時点ではbeta 6です。Xcode 7は`@testable import`をサポートし、ユニットテストのターゲットから`internal`なタイプ、関数、プロパティなどにアクセスできます。

また、天気の情報を取得するため、[OpenWeatherMap](http://openweathermap.org)の無料APIを利用します。[アカウントを作り](http://home.openweathermap.org/users/sign_up)、APIキーを用意してください。

Swinjectや他のフレームワークをインストールするため、[CocoaPods](https://cocoapods.org)を使用します。

## プロジェクトの準備

では新しいプロジェクトをXcodeで作成しましょう。`File > New > Project...`メニューを選択し、`iOS > Application > Single View Application`の項目を選んでください。プロダクト名は`SwinjectSimpleExample`にし、言語はSwift、デバイスはiPhoneにしましょう。`Include Unit Tests`だけチェックし[^1]、プロジェクトをどこかに保存します。

次に、[Alamofire](https://github.com/Alamofire/Alamofire)、[SwiftyJSON](https://github.com/SwiftyJSON/SwiftyJSON)、[Swinject](https://github.com/Swinject/Swinject)、[Quick](https://github.com/Quick/Quick)、[Nimble](https://github.com/Quick/Nimble)を[CocoaPods](https://cocoapods.org)でインストールします。以下のテキストの内容で`Podfile`を作成し、プロジェクトのルートフォルダに保存してください。その後、`pod install`コマンドを実行してインストールします。Xcode 7がまだベータ版のため、AlamofireとSwiftyJSONは特定のコミットを指定しています[^2]。

    source 'https://github.com/CocoaPods/Specs.git'
    platform :ios, '8.0'
    use_frameworks!

    pod 'Alamofire', :git => 'https://github.com/Alamofire/Alamofire.git', :commit => '1b7b1f1aa'
    pod 'SwiftyJSON', :git => 'https://github.com/SwiftyJSON/SwiftyJSON.git', :commit => '45ca854ce'
    pod 'Swinject', '~> 0.2'

    target 'SwinjectSimpleExampleTests' do
        pod 'Quick', '~> 0.5.0'
        pod 'Nimble', '2.0.0-rc.2'
    end

Alamofireはリクエストと非同期なレスポンスをシンプルに書くことができるネットワークライブラリです。SwiftyJSONはシンプルな記法でJSONの要素にアクセスするためのライブラリです。Quickは[ビヘイビア駆動開発](https://ja.wikipedia.org/wiki/ビヘイビア駆動開発)のフレームワークで、スペックとしてのテストををわかりやすく書くことができます。Nimbleはマッチャのフレームワークで、わかりやすい表現での記述と非同期のテストができます。詳細はそれぞれのプロジェクトのページをご覧ください。

iOS 9上でOpenWeatherMapの無料APIを使用するため、HTTP接続ができるように設定する必要があります。`Info.plist`を開き、`NSAllowsArbitraryLoads`要素が`true`にセットされた`NSAppTransportSecurity`辞書を追加します[^3]。この設定の詳細とその背景は[こちらの記事](http://objc-lovers.com/archives/224)を参照してください。

## Dependency Injectionなしの場合

まずdependency injectionなしで、ネットワークサービスから取得した天気の情報を扱うモデルを実装します。依存性が密結合になった状態を気にせずにいると、どのような問題が発生するか見ることになります。

プロジェクトの`SwinjectSimpleExample`グループに`City.swift`を追加してください。天気の情報を持った都市を表すエンティティとして`City`を定義します。

**City.swift**

    struct City {
        let id: Int
        let name: String
        let weather: String
    }

OpenWeatherMap APIの設定情報を記述するため、`OpenWeatherMap.swift`を追加してください。`apiKey`はあなた自身が取得したAPIキーを設定するようにしてください。

**OpenWeatherMap.swift**

    struct OpenWeatherMap {
        private static let apiKey = "YOUR API KEY HERE"

        private static let cityIds = [
            6077243, 524901, 5368361, 1835848, 3128760, 4180439,
            2147714, 264371, 1816670, 2643743, 3451190, 1850147
        ]

        static let url = "http://api.openweathermap.org/data/2.5/group"

        static var parameters: [String: String] {
            return [
                "APPID": apiKey,
                "id": cityIds.map { String($0) }.joinWithSeparator(",")
            ]
        }
    }

`WeatherFetcher.swift`を追加し、`WeatherFetcher`を実装します。`fetch`関数はコールバックを引数として受け取り、その中でOpenWeatherMapから受け取った`City`の配列 (オプショナル) を処理します。

**WeatherFetcher.swift**

    import Foundation
    import Alamofire
    import SwiftyJSON

    struct WeatherFetcher {
        static func fetch(response: [City]? -> ()) {
            Alamofire.request(.GET, OpenWeatherMap.url, parameters: OpenWeatherMap.parameters)
                .response { _, _, data, _ in
                    let cities = data.map { decode($0) }
                    response(cities)
                }
        }

        private static func decode(data: NSData) -> [City] {
            let json = JSON(data: data)
            var cities = [City]()
            for (_, j) in json["list"] {
                if let id = j["id"].int {
                    let city = City(
                        id: id,
                        name: j["name"].string ?? "",
                        weather: j["weather"][0]["main"].string ?? "")
                    cities.append(city)
                }
            }
            return cities
        }
    }

`fetch`関数の中では、Alamofireを使用してリクエストをサーバに送り、非同期にJSONデータのレスポンスを受け取っています。APIの呼び出しとレスポンスの仕様については、[OpenWeatherMapの "Call for several city IDs"](http://openweathermap.org/current#severalid) を参照してください。Alamofireからの`response`を処理するクロージャの変数`data`は、レスポンスがエラーだと`nil`になります。この例題ではエラーの詳細は扱わず、単に`fetch`のコールバックに`nil`を渡すだけですが、実際の製品のアプリではきちんとエラーハンドリングを行いましょう。

`decode`関数は、サーバから返ってきたJSONデータをパースします。`fetch`の中で`data.map { decode($0) }`という形で呼び出され、`map`は`data`が`nil`でない場合に引数のクロージャを実行して結果を返し、`data`が`nil`であれば`nil`を返します。`decode`関数ではJSONデータを`City`エンティティの配列にマップするためにSwiftyJSONを使用しています。

それでは、プロジェクトの`SwinjectSimpleExampleTests`グループにユニットテストを追加しましょう。ファイル名は`WeatherFetcherSpec.swift`とし、ファイル作成時に`SwinjectSimpleExampleTests`がターゲットになるよう設定しましょう。テストでは、天気のデータが正しく取得され、さらに正しくパースされることを確認します。

**WeatherFetcherSpec.swift**

    import Quick
    import Nimble
    @testable import SwinjectSimpleExample

    class WeatherFetcherSpec: QuickSpec {
        override func spec() {
            it("returns cities.") {
                var cities: [City]?
                WeatherFetcher.fetch { cities = $0 }

                expect(cities).toEventuallyNot(beNil())
                expect(cities?.count).toEventually(equal(12))
                expect(cities?[0].id).toEventually(equal(6077243))
                expect(cities?[0].name).toEventually(equal("Montreal"))
                expect(cities?[0].weather).toEventually(equal("Clouds"))
            }
        }
    }

QuickとNimbleを使用すると、1つのテストを`it`クロージャの中に書き、値の確認は同期の場合に`expect(something).to(condition)`や`expect(something).toNot(condition)`、非同期の場合には`expect(something).toEventually(condition)`や`expect(something).toEventuallyNot(condition)`のように書きます。`WeatherFetcher.fetch`は`cities`を天気データ取得時に非同期にセットするため、後者の確認法でテストを記述します。

1番目に、`fetch`によってコールバックが呼ばれたタイミングで、`nil`で初期化された`cities`に配列がセットされることをチェックしています。2番目に、APIのリクエストで12個のcity IDを送っているので、`cities`の要素数が12になることをチェックしています。3番目から5番目では、簡略化のため最初の要素だけチェックし、`id`、`name`、`weather`がそれぞれ`6077243`、`"Montreal"`、`"Clouds"`になることをチェックしています。

それではユニットテストを実行する準備ができました。`Command-U`を入力してテストを実行しましょう。テストはパスしましたか？パスした人もいるかもしれませんが、パスしなかった人も多かったと思います。なぜそんなことになったのでしょう？それは、現実世界の現在の "Montreal" で天気が "Clouds" でないとテストがパスしないからです。では、現在の天気に関わらずパスするテストを書くにはどうすればいいでしょうか？実際のところ、JSONデータをパースする部分がサーバからデータを取得する部分に依存していると、そのようなテストを書くことは難しいです。

## Dependency Injectionありの場合

前のセクションでは、パーサがネットワーク (つまりAlamofire) に強く依存していると、テストを書くことが困難になることを見てきました。このセクションでは、それらを疎結合にしてdependency injectionを行い、より良いテストを書いていきたいと思います。

まず、以下のプロトコルを定義した`Networking.swift`を追加します。そのプロトコルは`request`メソッドを持ち、ネットワークからのレスポンスデータを受け渡すコールバックを引数としてとります。

**Networking.swift**

    import Foundation

    protocol Networking {
        func request(response: NSData? -> ())
    }

`Network.swift`を追加し、`Networking`プロトコルに準拠した`Network`を実装します。これによってAlamofireをカプセル化します。

**Network.swift**

    import Foundation
    import Alamofire

    struct Network : Networking {
        func request(response: NSData? -> ()) {
            Alamofire.request(.GET, OpenWeatherMap.url, parameters: OpenWeatherMap.parameters)
                .response { _, _, data, _ in
                    response(data)
                }
        }
    }

`WeatherFetcher`を修正し、そのインスタンス生成時に`Networking`が注入されるようにし、サーバに天気情報をリクエストする際にそれを使用します。前のセクションでは`fetch`と`decode`関数は`static`でしたが、`networking`プロパティを使用するためここではインスタンスメソッドに変更されていることに注意してください。`networking`をとるデフォルトイニシャライザはSwiftによって自動的に生成されます。`WeatherFetcher`からAlamofireへの依存性がなくなったことが確認できますね。

**WeatherFetcher.swift**

    struct WeatherFetcher {
        let networking: Networking

        func fetch(response: [City]? -> ()) {
            networking.request { data in
                let cities = data.map { self.decode($0) }
                response(cities)
            }
        }

        private func decode(data: NSData) -> [City] {
            let json = JSON(data: data)
            var cities = [City]()
            for (_, j) in json["list"] {
                if let id = j["id"].int {
                    let city = City(
                        id: id,
                        name: j["name"].string ?? "",
                        weather: j["weather"][0]["main"].string ?? "")
                    cities.append(city)
                }
            }
            return cities
        }
    }

ではここで`WeatherFetcherSpec`を修正し、疎結合になったネットワークとJSONパーサのテストを書いてみましょう。

**WeatherFetcherSpec.swift**

    import Quick
    import Nimble
    import Swinject
    @testable import SwinjectSimpleExample

    class WeatherFetcherSpec: QuickSpec {
        struct StubNetwork: Networking {
            private static let json =
            "{" +
                "\"list\": [" +
                    "{" +
                        "\"id\": 2643743," +
                        "\"name\": \"London\"," +
                        "\"weather\": [" +
                            "{" +
                                "\"main\": \"Rain\"" +
                            "}" +
                        "]" +
                    "}," +
                    "{" +
                        "\"id\": 3451190," +
                        "\"name\": \"Rio de Janeiro\"," +
                        "\"weather\": [" +
                            "{" +
                                "\"main\": \"Clear\"" +
                            "}" +
                        "]" +
                    "}" +
                "]" +
            "}"

            func request(response: NSData? -> ()) {
                let data = StubNetwork.json.dataUsingEncoding(
                    NSUTF8StringEncoding, allowLossyConversion: false)
                response(data)
            }
        }

        override func spec() {
            var container: Container!
            beforeEach {
                container = Container()

                // Registrations for the network using Alamofire.
                container.register(Networking.self) { _ in Network() }
                container.register(WeatherFetcher.self) { r in
                    WeatherFetcher(networking: r.resolve(Networking.self)!)
                }

                // Registration for the stub network.
                container.register(Networking.self, name: "stub") { _ in
                    StubNetwork()
                }
                container.register(WeatherFetcher.self, name: "stub") { r in
                    WeatherFetcher(
                        networking: r.resolve(Networking.self, name: "stub")!)
                }
            }

            it("returns cities.") {
                var cities: [City]?
                let fetcher = container.resolve(WeatherFetcher.self)!
                fetcher.fetch { cities = $0 }

                expect(cities).toEventuallyNot(beNil())
                expect(cities?.count).toEventually(beGreaterThan(0))
            }
            it("fills weather data.") {
                var cities: [City]?
                let fetcher = container.resolve(WeatherFetcher.self, name: "stub")!
                fetcher.fetch { cities = $0 }

                expect(cities?[0].id).toEventually(equal(2643743))
                expect(cities?[0].name).toEventually(equal("London"))
                expect(cities?[0].weather).toEventually(equal("Rain"))
                expect(cities?[1].id).toEventually(equal(3451190))
                expect(cities?[1].name).toEventually(equal("Rio de Janeiro"))
                expect(cities?[1].weather).toEventually(equal("Clear"))
            }
        }
    }

`StubNetwork`は`Networking`に準拠するスタブです。サーバから返ってくるデータと同じ構造を持ったJSONデータを定義しています。`StubNetwork`の`request`メソッドは、現実世界の現在の天気に関わらず、いつでも同じデータを返します。`spec`の最初の部分で`container`を設定し、後で2つの`it`の中でその`container`を使用しています。登録名なしの方では`Network`を使用するように`container`を設定し、登録名が`"stub"`の方では`StubNetwork`を使用するように設定しています。

1番目の`it`では、登録名なしで`container`から`WeatherFetcher`のインスタンスを取得し、実際のネットワーク (Alamofire) から何らかのJSONデータが返ってくることをテストしています[^4]。ここでは`cities`の詳細はテストしていません。`fetch`がサーバからデータを取得できることだけ確認しています。

2番目の`it`では、登録名`"stub"`で`WeatherFetcher`のインスタンスを取得し、JSONデータのパースが正しく動作することをテストしています。`StubNetwork`に定義したようにスタブは2つの都市のJSONデータを返すので、その2つの都市が定義通りに値セットされるかチェックしています。

それではテストを実行する準備ができました。`Command-U`を入力してテストを実行しましょう。今回は、実際の天気によらずテストがパスしましたね。これが、各コンポーネントを疎結合にするdependency injectionパターンの利点です。今回の例では、パーサの部分とネットワークの部分を疎結合にしました。

## まとめ

ネットワークサービスとJSONパーサを使用するアプリの開発をシナリオとして用い、ユニットテストを書くにあたって依存性が問題になることを説明しました。さらに、dependency injectionを利用してその問題に対処しました。これら2つの部分を疎結合にすることにより、どのような条件でもユニットテストが再現するようにしました。次のブログ記事では、このアプリのUI部分を実装し、アプリ全体としてどのようにSwinjectを使用するか見ていきます。

[^1]: まだXcode 7がベータ版のため、UI testsは除外しています (NDAを少し気にしてます)。Xcode 7が正式リリースされたらUI testsも行うようにブログ記事を更新します。
[^2]: Xcode 7が正式リリースされたら`Podfile`の中身を更新します。
[^3]: 実際には、世の中にリリースするアプリを開発する場合、この設定は好ましくありません。このブログ記事では、無料のAPIがHTTPしかサポートしていないため、この設定を行っています。
[^4]: もしネットワークが切断されていたり、接続に問題があったりすると、このテストは思いがけず失敗する場合があります。しかし、実用的にはこのようなケースは無視してテストを書くことができます。
[^訳注1]: 訳注: 英語版の著者本人による翻訳のため、翻訳に関わる著作権上の問題はありません。