以下のブログ記事の翻訳です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しかサポートしていないため、この設定を行っています。 ↩
-
もしネットワークが切断されていたり、接続に問題があったりすると、このテストは思いがけず失敗する場合があります。しかし、実用的にはこのようなケースは無視してテストを書くことができます。 ↩