以下のブログ記事の翻訳です1。
Dependency Injection Framework for Swift - Simple Weather App Example with Swinject Part 1/2
前回のブログ記事では、dependency injection (依存性の注入) のコンセプトとSwift用のフレームワークであるSwinjectの基本的な使い方を見てきました。今回の記事では、前回スクリーンショットだけお見せしたシンプルな天気アプリを開発していきます。シンプルですが本質をついた開発手順の中で、dependency injectionパターンとSwinjectを利用し、密結合した依存性を疎結合にする方法を見ていきます。
このブログ記事で使用するソースコードはGitHubのリポジトリからダウンロードできます。
要求事項
- Xcode 7 (beta)
- OpenWeatherMap APIキー
- CocoaPods 0.38以上
まだベータ版ですがXcode 7を使用します。このブログ記事の執筆時点ではbeta 6です。Xcode 7は@testable importをサポートし、ユニットテストのターゲットからinternalなタイプ、関数、プロパティなどにアクセスできます。
また、天気の情報を取得するため、OpenWeatherMapの無料APIを利用します。アカウントを作り、APIキーを用意してください。
Swinjectや他のフレームワークをインストールするため、CocoaPodsを使用します。
プロジェクトの準備
では新しいプロジェクトをXcodeで作成しましょう。File > New > Project...メニューを選択し、iOS > Application > Single View Applicationの項目を選んでください。プロダクト名はSwinjectSimpleExampleにし、言語はSwift、デバイスはiPhoneにしましょう。Include Unit Testsだけチェックし2、プロジェクトをどこかに保存します。
次に、Alamofire、SwiftyJSON、Swinject、Quick、NimbleをCocoaPodsでインストールします。以下のテキストの内容でPodfileを作成し、プロジェクトのルートフォルダに保存してください。その後、pod installコマンドを実行してインストールします。Xcode 7がまだベータ版のため、AlamofireとSwiftyJSONは特定のコミットを指定しています3。
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はビヘイビア駆動開発のフレームワークで、スペックとしてのテストををわかりやすく書くことができます。Nimbleはマッチャのフレームワークで、わかりやすい表現での記述と非同期のテストができます。詳細はそれぞれのプロジェクトのページをご覧ください。
iOS 9上でOpenWeatherMapの無料APIを使用するため、HTTP接続ができるように設定する必要があります。Info.plistを開き、NSAllowsArbitraryLoads要素がtrueにセットされたNSAppTransportSecurity辞書を追加します4。この設定の詳細とその背景はこちらの記事を参照してください。
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" を参照してください。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データが返ってくることをテストしています5。ここではcitiesの詳細はテストしていません。fetchがサーバからデータを取得できることだけ確認しています。
2番目のitでは、登録名"stub"でWeatherFetcherのインスタンスを取得し、JSONデータのパースが正しく動作することをテストしています。StubNetworkに定義したようにスタブは2つの都市のJSONデータを返すので、その2つの都市が定義通りに値セットされるかチェックしています。
それではテストを実行する準備ができました。Command-Uを入力してテストを実行しましょう。今回は、実際の天気によらずテストがパスしましたね。これが、各コンポーネントを疎結合にするdependency injectionパターンの利点です。今回の例では、パーサの部分とネットワークの部分を疎結合にしました。
まとめ
ネットワークサービスとJSONパーサを使用するアプリの開発をシナリオとして用い、ユニットテストを書くにあたって依存性が問題になることを説明しました。さらに、dependency injectionを利用してその問題に対処しました。これら2つの部分を疎結合にすることにより、どのような条件でもユニットテストが再現するようにしました。次のブログ記事では、このアプリのUI部分を実装し、アプリ全体としてどのようにSwinjectを使用するか見ていきます。
-
訳注: 英語版の著者本人による翻訳のため、翻訳に関わる著作権上の問題はありません。 ↩
-
まだXcode 7がベータ版のため、UI testsは除外しています (NDAを少し気にしてます)。Xcode 7が正式リリースされたらUI testsも行うようにブログ記事を更新します。 ↩
-
Xcode 7が正式リリースされたら
Podfileの中身を更新します。 ↩ -
実際には、世の中にリリースするアプリを開発する場合、この設定は好ましくありません。このブログ記事では、無料のAPIがHTTPしかサポートしていないため、この設定を行っています。 ↩
-
もしネットワークが切断されていたり、接続に問題があったりすると、このテストは思いがけず失敗する場合があります。しかし、実用的にはこのようなケースは無視してテストを書くことができます。 ↩
