前回Part 2の翻訳から間が空いてしまいましたが、ReactiveCocoa v4.0 RC1がリリースされてAPIが安定し、英語記事の更新も落ち着いたので翻訳を再開します。Part 3は以下のブログ記事の翻訳です1。
Dependency Injection in MVVM Architecture with ReactiveCocoa Part 3: Designing the Model
前回のブログ記事では、Model、View、ViewModelの各フレームワークからなるアプリを開発するために、Xcodeのプロジェクトを設定しました。今回のブログ記事では、MVVMアーキテクチャのModel部分を開発します。エンティティとサービスで構成されるModelを設計し、dependency injectionを行う方法について学びます。MVVMアーキテクチャにより、システムを疎結合にすることが特徴です。ReactiveCocoaを使用して、MVVMアーキテクチャのModel、View、ViewModelを疎結合にするために不可欠なイベントハンドリングを行います。また、JSONからSwiftの型へマッピングを行うHimotokiの使い方について学びます。
ソースコードはGitHubのリポジトリからダウンロードできます。
- SwinjectMVVMExample: 発展形を含むプロジェクト
- SwinjectMVVMExample_ForBlog: (Xcodeや外部フレームワークの更新を除き) ブログ記事に沿った説明のための簡略化したプロジェクト
Himotoki
HimotokiはSwiftで書かれたタイプセーフなJSONデコード専用ライブラリです。JSONからSwiftの型へのマッピングをDecodableプロトコルとして簡潔に記述できます。特に、イミュータブルな (let) プロパティへのマッピングをサポートしているところが特徴です。
使い方は簡単です。ここでは、{ "some_name": "Himotoki", "some_value": 1 }のようなJSONをSomeValue型にマッピングするとします。マッピングの記述のため、SomeValue型をDecodableプロトコルに準拠させます。
struct SomeValue {
let name: String
let value: Int
}
extension SomeValue: Decodable {
static func decode(e: Extractor) throws -> Group {
return try SomeValue(
name: e <| "some_name",
value: e <| "some_value"
)
}
}
decode関数の中で、SomeValueのイニシャライザの引数としてマッピングを表現しています。ここでは、some_nameをSomeValueのnameプロパティへ、some_valueをvalueプロパティへマッピングしています。
JSONデータからマッピングしたインスタンスを取得するためには、AlamofireやNSJSONSerializationから返される
[String: AnyObject]型のJSONデータを引数としてdecode関数を呼びます。
func testSomeValue() {
// JSON data returned from Alamofire or NSJSONSerialization.
let json: [String: AnyObject] = ["some_name": "Himotoki", "some_value": 1]
let v: SomeValue? = try? decode(json)
XCTAssert(v != nil)
XCTAssert(v?.name == "Himotoki")
XCTAssert(v?.value == 1)
}
Himotokiを利用することにより、JSONデータを扱うためのコードを減らすことができます2。Himotokiはネストされたデータやオプショナルパラメータもサポートしています。詳しくはプロジェクトのページを参照してください。
Pixabay APIの仕様
Pixabay APIのドキュメントによると、Pixabayのサーバから返ってくるJSONデータは以下ようなフォーマットになっています。画像情報の配列をhits、見つかった画像の総数をtotal、APIで利用可能な画像の総数をtotalHitsとして取り出すことができます。
{
"total": 12274,
"totalHits": 240,
"hits": [
{
"id": 11574,
"pageURL": "https://pixabay.com/en/sonnenblumen-sonnenblumenfeld-flora-11574/",
"type": "photo",
"tags": "sunflower, sunflower field, flora",
"previewURL": "https://pixabay.com/static/uploads/photo/2012/01/07/21/56/sunflower-11574_150.jpg",
"previewWidth": 150,
"previewHeight": 92,
"webformatURL": "https://pixabay.com/get/3b4f5d71752e6ce9cbcf/1356479243/aca42219d23fd9fe0cc6f1cc_640.jpg",
"webformatWidth": 640,
"webformatHeight": 396,
"imageWidth": 1280,
"imageHeight": 792,
"views": 10928,
"downloads": 1649,
"likes": 70,
"user": "WikiImages"
},
{
"id": "256",
"pageURL": "https://pixabay.com/en/example-image-256/",
"type": "photo",
// ... etc.
},
//... 18 more hits for page number 1
]
}
Modelの設計の概要
今回の設計では、Modelをエンティティとサービスで構成します。簡単に言うと、エンティティは対象となるシステムに存在する概念あるいはオブジェクトです3。サービスは状態のないオペレーションで、エンティティには収まらないものです。
ViewModelとModel、そしてModelと外部システムを疎結合にするため、それらのインターフェイスをプロトコルとして定義します。下の図でImageSearchingとNetworkingがプロトコルです。ImageSearchとNetworkはそれらの実装です。ViewModelはImageSearchingプロトコルを通してModelにアクセスし、そのプロトコルの実装であるImageSearchはNetworkingプロトコルを通して外部のシステムにアクセスします。外部システムがJSONデータを持つイベントを発生させ、そのイベントがViewModelへと伝播していく時にImageSearchによってデータがResponseEntityやImageEntityに変換されます。
エンティティ
この節では、Pixabayから返される画像とレスポンスを表すエンティティを定義していきます。ExampleModelターゲットに以下の内容のImageEntity.swiftを追加してください。Project Navigator上のExampleModelグループ (フォルダアイコン) を右クリックしてNew File...を選択し、Swift Fileを選択すれば、ExampleModelターゲットにファイルを追加できます。Xcodeがどのターゲットにファイルを追加するか聞いてきた時にはExampleModelのみチェックしておいてください。
ImageEntity.swift
import Himotoki
public struct ImageEntity {
public let id: UInt64
public let pageURL: String
public let pageImageWidth: Int
public let pageImageHeight: Int
public let previewURL: String
public let previewWidth: Int
public let previewHeight: Int
public let imageURL: String
public let imageWidth: Int
public let imageHeight: Int
public let viewCount: Int64
public let downloadCount: Int64
public let likeCount: Int64
public let tags: [String]
public let username: String
}
// MARK: Decodable
extension ImageEntity: Decodable {
public static func decode(e: Extractor) throws -> ImageEntity {
let splitCSV: String -> [String] = { csv in
csv.characters
.split { $0 == "," }
.map {
String($0).stringByTrimmingCharactersInSet(
NSCharacterSet.whitespaceCharacterSet())
}
}
return try ImageEntity(
id: e <| "id",
pageURL: e <| "pageURL",
pageImageWidth: e <| "imageWidth",
pageImageHeight: e <| "imageHeight",
previewURL: e <| "previewURL",
previewWidth: e <| "previewWidth",
previewHeight: e <| "previewHeight",
imageURL: e <| "webformatURL",
imageWidth: e <| "webformatWidth",
imageHeight: e <| "webformatHeight",
viewCount: e <| "views",
downloadCount: e <| "downloads",
likeCount: e <| "likes",
tags: (try? e <| "tags").map(splitCSV) ?? [],
username: e <| "user"
)
}
}
ImageEntityがイミュータブルなプロパティを持つstructとして定義してあることに注意してください。イミュータブルであることにより、エンティティを使用する際の安全性を確保できます4。ExampleViewModelターゲットから参照できるよう、エンティティの型はpublicで定義してあります。
ImageEntityには、Swiftの命名慣習とアプリの構成に合わせ、JSONの要素と異なる名前にしたプロパティもあります。id、viewCount、downloadCount、likeCountは、32-bitシステムでも大きな値を扱えるよう、UInt64やInt64で定義してあります。JSONの要素tagsはCSV形式の文字列となっていますが、ImageEntity では(try? e <| "tags").map(splitCSV)により分割した文字列の配列になっています。?? []をmapの返り値に適用することにより、JSONデータにtagsがなくてnilが返ってきた場合は代わりに空配列が使用されます。
ExampleModelターゲットに以下の内容のResponseEntity.swift を追加してください。ここでは、配列をマッピングするために<||演算子を使用しています。JSONのtotal要素は無視しています。もし後で必要になったら追加すればよいでしょう。
ResponseEntity.swift
import Himotoki
public struct ResponseEntity {
public let totalCount: Int64
public let images: [ImageEntity]
}
// MARK: Decodable
extension ResponseEntity: Decodable {
public static func decode(e: Extractor) throws -> ResponseEntity {
return try ResponseEntity(
totalCount: e <| "totalHits",
images: e <|| "hits"
)
}
}
ImageEntityをテストするため、ExampleModelTestsターゲットに以下の内容のDummy.swiftとImageEntitySpec.swiftを追加してください。
Dummy.swift
let imageJSON: [String: AnyObject] = [
"id": 12345,
"pageURL": "https://somewhere.com/page/",
"imageWidth": 2000,
"imageHeight": 1000,
"previewURL": "https://somewhere.com/preview.jpg",
"previewWidth": 200,
"previewHeight": 100,
"webformatURL": "https://somewhere.com/image.jpg",
"webformatWidth": 600,
"webformatHeight": 300,
"views": 54321,
"downloads": 4321,
"likes": 321,
"tags": "a, b c, d ",
"user": "Swinject"
]
ImageEntitySpec.swift
import Quick
import Nimble
import Himotoki
@testable import ExampleModel
class ImageEntitySpec: QuickSpec {
override func spec() {
it("parses JSON data to create a new instance.") {
let image: ImageEntity? = try? decode(imageJSON)
expect(image).notTo(beNil())
expect(image?.id) == 12345
expect(image?.pageURL) == "https://somewhere.com/page/"
expect(image?.pageImageWidth) == 2000
expect(image?.pageImageHeight) == 1000
expect(image?.previewURL) == "https://somewhere.com/preview.jpg"
expect(image?.previewWidth) == 200
expect(image?.previewHeight) == 100
expect(image?.imageURL) == "https://somewhere.com/image.jpg"
expect(image?.imageWidth) == 600
expect(image?.imageHeight) == 300
expect(image?.viewCount) == 54321
expect(image?.downloadCount) == 4321
expect(image?.likeCount) == 321
expect(image?.tags) == ["a", "b c", "d"]
expect(image?.username) == "Swinject"
}
it("gets an empty array if tags element is nil.") {
var missingJSON = imageJSON
missingJSON["tags"] = nil
let image: ImageEntity? = try? decode(missingJSON)
expect(image).notTo(beNil())
expect(image?.tags.isEmpty).to(beTrue())
}
it("throws an error if any of JSON elements except tags is missing.") {
for key in imageJSON.keys where key != "tags" {
var missingJSON = imageJSON
missingJSON[key] = nil
let image: ImageEntity? = try? decode(missingJSON)
expect(image).to(beNil())
}
}
it("ignores an extra JSON element.") {
var extraJSON = imageJSON
extraJSON["extraKey"] = "extra element"
let image: ImageEntity? = try? decode(extraJSON)
expect(image).notTo(beNil())
}
}
}
Dummy.swiftの中では、ダミーのJSONデータのインスタンスをimageJSONとして定義しています。AlamofireやNSJSONSerializationから返ってくるような[String: AnyObject]型のJSONデータをHimotokiは扱うため、imageJSONはStringではなく[String: AnyObject]として定義しています。
it("parses JSON data to create a new instance.")では、ImageEntityのすべてのプロパティがJSONからマッピングされていることを確認しています。
it("gets an empty array if tags element is nil.")では、JSONデータにtags要素が含まれない場合に、tagsプロパティに空配列がセットされることを確認しています。
it("throws an error if any of JSON elements except tags is missing.")では、tags以外のJSON要素が欠けている場合にdecode関数がエラーを投げることを確認しています。不正な、あるいは壊れたJSONデータはtry?でnilが返ってくるか見ることにより確認できます。
it("ignores an extra JSON element.")では、JSONデータに余分な要素があってもdecode関数はImageEntityのインスタンスを返すことを確認しています。余分な要素を無視することにより、将来のPixabay APIの変更に対してJSONデータのマッピングが安全になります。
それでは次に、ExampleModelTestsターゲットに以下の内容のResponseEntitySpec.swiftを追加してください。テストの内容は見たとおり簡単です。
ResponseEntitySpec.swift
import Quick
import Nimble
import Himotoki
@testable import ExampleModel
class ResponseEntitySpec: QuickSpec {
override func spec() {
let json: [String: AnyObject] = [
"totalHits": 123,
"hits": [imageJSON, imageJSON]
]
it("parses JSON data to create a new instance.") {
let response: ResponseEntity? = try? decode(json)
expect(response).notTo(beNil())
expect(response?.totalCount) == 123
expect(response?.images.count) == 2
}
}
}
Command-Uを入力してユニットテストを実行しましょう。パスしましたね。ここでは問題なくテストがパスしたと思いますが、実際の開発ではテストがパスするまでエンティティやテストの修正を繰り返すことになります。
ネットワークサービス
シンプルな天気アプリの例の時と同様に、Networkingに適合するNetwork型にAlamofireをカプセル化します。今回は、ReactiveCocoaを使ってネットワークのイベントをハンドリングします。ExampleModelターゲットに以下の内容のNetworking.swiftとNetwork.swiftを追加してください。
Networking.swift
import ReactiveCocoa
public protocol Networking {
func requestJSON(url: String, parameters: [String : AnyObject]?)
-> SignalProducer<AnyObject, NetworkError>
}
Network.swift
import ReactiveCocoa
import Alamofire
public final class Network: Networking {
private let queue = dispatch_queue_create(
"SwinjectMMVMExample.ExampleModel.Network.Queue",
DISPATCH_QUEUE_SERIAL)
public init() { }
public func requestJSON(url: String, parameters: [String : AnyObject]?)
-> SignalProducer<AnyObject, NetworkError>
{
return SignalProducer { observer, disposable in
let serializer = Alamofire.Request.JSONResponseSerializer()
Alamofire.request(.GET, url, parameters: parameters)
.response(queue: self.queue, responseSerializer: serializer) {
response in
switch response.result {
case .Success(let value):
observer.sendNext(value)
observer.sendCompleted()
case .Failure(let error):
observer.sendFailed(NetworkError(error: error))
}
}
}
}
}
天気アプリの例では、ネットワークサービスはコールバックを用いてレスポンスを受け渡していました。今回は、requestJSONメソッドがSignalProducer<AnyObject, NetworkError>を返し、それによりオブザーバにイベントを伝えるようにしています。Alamofire (あるいはNSJSONSerialization) がJSONデータをAnyObject型 (実体は配列または辞書) として返すため、SignalProducerはジェネリック型としてAnyObjectをとっています。
requestJSONメソッドは、トレイリングクロージャを使ってSignalProducerのインスタンスを生成して返しています。そのクロージャの中では、sendNext、sendCompleted、sendFailedを呼び出して、AlamofireのレスポンスのイベントからReactiveCocoaのイベントに変換しています。SignalProducerインスタンスのstartメソッドが呼ばれるまでは、インスタンス生成時に渡されたクロージャは実際には実行されないことに注意してください。
デフォルトではAlamofireはレスポンスをメインスレッド (メインキュー) で返すため、ディスパッチキューをAlamofireに渡してバックグラウンドでレスポンスを返すようにしています。
sendFailedメソッドはNetworkErrorのインスタンスを引数にとります。ExampleModelターゲットに以下の内容のNetworkError.swiftを追加してください。Alamofireから送られてくるNSErrorをアプリ固有のエラー型に変換しています5。
NetworkError.swift
import Foundation
public enum NetworkError: ErrorType {
/// Unknown or not supported error.
case Unknown
/// Not connected to the internet.
case NotConnectedToInternet
/// International data roaming turned off.
case InternationalRoamingOff
/// Cannot reach the server.
case NotReachedServer
/// Connection is lost.
case ConnectionLost
/// Incorrect data returned from the server.
case IncorrectDataReturned
internal init(error: NSError) {
if error.domain == NSURLErrorDomain {
switch error.code {
case NSURLErrorUnknown:
self = .Unknown
case NSURLErrorCancelled:
self = .Unknown // Cancellation is not used in this project.
case NSURLErrorBadURL:
self = .IncorrectDataReturned // Because it is caused by a bad URL returned in a JSON response from the server.
case NSURLErrorTimedOut:
self = .NotReachedServer
case NSURLErrorUnsupportedURL:
self = .IncorrectDataReturned
case NSURLErrorCannotFindHost, NSURLErrorCannotConnectToHost:
self = .NotReachedServer
case NSURLErrorDataLengthExceedsMaximum:
self = .IncorrectDataReturned
case NSURLErrorNetworkConnectionLost:
self = .ConnectionLost
case NSURLErrorDNSLookupFailed:
self = .NotReachedServer
case NSURLErrorHTTPTooManyRedirects:
self = .Unknown
case NSURLErrorResourceUnavailable:
self = .IncorrectDataReturned
case NSURLErrorNotConnectedToInternet:
self = .NotConnectedToInternet
case NSURLErrorRedirectToNonExistentLocation, NSURLErrorBadServerResponse:
self = .IncorrectDataReturned
case NSURLErrorUserCancelledAuthentication, NSURLErrorUserAuthenticationRequired:
self = .Unknown
case NSURLErrorZeroByteResource, NSURLErrorCannotDecodeRawData, NSURLErrorCannotDecodeContentData:
self = .IncorrectDataReturned
case NSURLErrorCannotParseResponse:
self = .IncorrectDataReturned
case NSURLErrorInternationalRoamingOff:
self = .InternationalRoamingOff
case NSURLErrorCallIsActive, NSURLErrorDataNotAllowed, NSURLErrorRequestBodyStreamExhausted:
self = .Unknown
case NSURLErrorFileDoesNotExist, NSURLErrorFileIsDirectory:
self = .IncorrectDataReturned
case
NSURLErrorNoPermissionsToReadFile,
NSURLErrorSecureConnectionFailed,
NSURLErrorServerCertificateHasBadDate,
NSURLErrorServerCertificateUntrusted,
NSURLErrorServerCertificateHasUnknownRoot,
NSURLErrorServerCertificateNotYetValid,
NSURLErrorClientCertificateRejected,
NSURLErrorClientCertificateRequired,
NSURLErrorCannotLoadFromNetwork,
NSURLErrorCannotCreateFile,
NSURLErrorCannotOpenFile,
NSURLErrorCannotCloseFile,
NSURLErrorCannotWriteToFile,
NSURLErrorCannotRemoveFile,
NSURLErrorCannotMoveFile,
NSURLErrorDownloadDecodingFailedMidStream,
NSURLErrorDownloadDecodingFailedToComplete:
self = .Unknown
default:
self = .Unknown
}
}
else {
self = .Unknown
}
}
}
それではNetwork型のユニットテストを追加しましょう。ExampleModelTestsターゲットに以下の内容のNetworkSpec.swift を追加してください。
NetworkSpec.swift
import Quick
import Nimble
@testable import ExampleModel
class NetworkSpec: QuickSpec {
override func spec() {
var network: Network!
beforeEach {
network = Network()
}
describe("JSON") {
it("eventually gets JSON data as specified with parameters.") {
var json: [String: AnyObject]? = nil
let url = "https://httpbin.org/get"
network.requestJSON(url, parameters: ["a": "b", "x": "y"])
.on(next: { json = $0 as? [String: AnyObject] })
.start()
expect(json).toEventuallyNot(beNil(), timeout: 5)
expect((json?["args"] as? [String: AnyObject])?["a"] as? String)
.toEventually(equal("b"), timeout: 5)
expect((json?["args"] as? [String: AnyObject])?["x"] as? String)
.toEventually(equal("y"), timeout: 5)
}
it("eventually gets an error if the network has a problem.") {
var error: NetworkError? = nil
let url = "https://not.existing.server.comm/get"
network.requestJSON(url, parameters: ["a": "b", "x": "y"])
.on(failed: { error = $0 })
.start()
expect(error)
.toEventually(equal(NetworkError.NotReachedServer), timeout: 5)
}
}
}
}
ここでは、ネットワークをテストするための簡単で安定したサーバとしてhttpbin.orgを使用しています。そのサーバはAlamofireのユニットテストでも使用されています。試しにブラウザで https://httpbin.org/get?a=b&x=y にアクセスしてみると、どのようにレスポンスが返ってくるかわかります。
最初のテストでは、リクエストで指定したようにレスポンスのJSONに"a"および"x"要素があり、それぞれ値が"b"および"y"となっていることを確認しています。json変数を使用して、サーバからのレスポンスを非同期に受け取っています。requestJSONから返されるSignalProducerにオブザーバ (つまりイベントハンドラ) を追加するため、json変数にレスポンスをセットするクロージャを引数としてonメソッドを呼んでいます。その後、startメソッドによりSignalProducerのシグナルを開始しています。レスポンスはtoEventuallyメソッドにより非同期でチェックされています。
2番目のテストでは、エラーの場合にNetworkErrorが発生することを確認しています。擬似的にエラーを発生させるため、存在しないURLをrequestJSONに渡しています。
ユニットテストを実行し、次の節に移りましょう。
画像検索サービス
この節では、Pixabay APIを利用して画像を検索するサービスを定義していきます。ここがExampleModelの中心です。
最初に、ExampleModelターゲットに以下の内容のPixabay.swiftを追加してください。APIのURLとパラメータを定義しています。apiKeyにはPixabayから取得したご自身のAPIキーを指定してください。
Pixabay.swift
internal struct Pixabay {
internal static let apiURL = "https://pixabay.com/api/"
internal static var requestParameters: [String: AnyObject] {
return [
"key": Config.apiKey,
"image_type": "photo",
"safesearch": true,
"per_page": 50,
]
}
}
extension Pixabay {
private struct Config {
private static let apiKey = "" // Fill with your own API key.
}
}
次に、ExampleModelターゲットに以下の内容のImageSearching.swiftを追加してください。ResponseEntityのSignalProducerを返すsearchImagesメソッドを持つプロトコルを定義しています。
ImageSearching.swift
import ReactiveCocoa
public protocol ImageSearching {
func searchImages() -> SignalProducer<ResponseEntity, NetworkError>
}
最後に、ExampleModelターゲットに以下の内容のImageSearch.swiftを追加してください。
ImageSearch.swift
import ReactiveCocoa
import Result
import Himotoki
public final class ImageSearch: ImageSearching {
private let network: Networking
public init(network: Networking) {
self.network = network
}
public func searchImages() -> SignalProducer<ResponseEntity, NetworkError> {
let url = Pixabay.apiURL
let parameters = Pixabay.requestParameters
return network.requestJSON(url, parameters: parameters)
.attemptMap { json in
if let response = (try? decode(json)) as ResponseEntity? {
return Result(value: response)
}
else {
return Result(error: .IncorrectDataReturned)
}
}
}
}
ImageSearchはNetworkingに依存しており、その依存性はイニシャライザで注入されています (Initializer Injectionパターン)。
searchImagesメソッドは、network.requestJSONから返されるSignalProducer<AnyObject, NetworkError>をSignalProducer<ResponseEntity, NetworkError>に変換しています。SignalProducerの変換でattemptMapを使っています。attemptMapに渡したクロージャの中では、decodeを呼んでJSONデータをResponseEntityインスタンスにマッピングしています。マッピングが成功した場合、ResponseEntityインスタンスをResult(value: response)で包んで返しています。失敗した場合6、Result(error: .IncorrectDataReturned)としてエラーを返しています。もしある値をエラーでなく別の値に変換するだけであれば、SignalProducerのmapメソッドが利用できます。
(try? decode(json)) as ResponseEntity?のキャストは見慣れないかもしれませんが、decode関数がResponseEntity型を扱うようSwiftコンパイラの型推論を補助しているものです。もしキャストが(try? decode(json)) as? ResponseEntityであれば、ソースコードをコンパイルすることができません。
それではユニットテストを書きましょう。ExampleModelTestsターゲットに以下の内容のImageSearchSpec.swiftを追加してください。
ImageSearchSpec.swift
import Quick
import Nimble
import ReactiveCocoa
@testable import ExampleModel
class ImageSearchSpec: QuickSpec {
// MARK: Stub
class GoodStubNetwork: Networking {
func requestJSON(url: String, parameters: [String : AnyObject]?)
-> SignalProducer<AnyObject, NetworkError>
{
var imageJSON0 = imageJSON
imageJSON0["id"] = 0
var imageJSON1 = imageJSON
imageJSON1["id"] = 1
let json: [String: AnyObject] = [
"totalHits": 123,
"hits": [imageJSON0, imageJSON1]
]
return SignalProducer { observer, disposable in
observer.sendNext(json)
observer.sendCompleted()
}.observeOn(QueueScheduler())
}
}
class BadStubNetwork: Networking {
func requestJSON(url: String, parameters: [String : AnyObject]?)
-> SignalProducer<AnyObject, NetworkError>
{
let json = [String: AnyObject]()
return SignalProducer { observer, disposable in
observer.sendNext(json)
observer.sendCompleted()
}.observeOn(QueueScheduler())
}
}
class ErrorStubNetwork: Networking {
func requestJSON(url: String, parameters: [String : AnyObject]?)
-> SignalProducer<AnyObject, NetworkError>
{
return SignalProducer { observer, disposable in
observer.sendFailed(.NotConnectedToInternet)
}.observeOn(QueueScheduler())
}
}
// MARK: - Spec
override func spec() {
it("returns images if the network works correctly.") {
var response: ResponseEntity? = nil
let search = ImageSearch(network: GoodStubNetwork())
search.searchImages()
.on(next: { response = $0 })
.start()
expect(response).toEventuallyNot(beNil())
expect(response?.totalCount).toEventually(equal(123))
expect(response?.images.count).toEventually(equal(2))
expect(response?.images[0].id).toEventually(equal(0))
expect(response?.images[1].id).toEventually(equal(1))
}
it("sends an error if the network returns incorrect data.") {
var error: NetworkError? = nil
let search = ImageSearch(network: BadStubNetwork())
search.searchImages()
.on(failed: { error = $0 })
.start()
expect(error).toEventually(equal(NetworkError.IncorrectDataReturned))
}
it("passes the error sent by the network.") {
var error: NetworkError? = nil
let search = ImageSearch(network: ErrorStubNetwork())
search.searchImages()
.on(failed: { error = $0 })
.start()
expect(error).toEventually(equal(NetworkError.NotConnectedToInternet))
}
}
}
先頭で3つのスタブを定義しています。GoodStubNetworkは正しいJSONデータを出力するSignalProducerを返します。BadStubNetworkは、空の辞書の形で不正なJSONデータを出力するSignalProducerを返します。ErrorStubNetworkは、JSONデータの代わりにエラーを出力するSignalProducerを返します。.observeOn(QueueScheduler())を使うことにより、すべてのSignalProducerにおいてイベントをバックグラウンドで出力し、非同期なネットワークのレスポンスを模擬しています。
spec()の中では、3つのユニットテスト (あるいはスペック) を定義しています。1つ目は、attemptMapが正しくJSONデータをResponseEntityに変換していることをチェックしています。2つ目は、attemptMapがJSONデータをエラーに変換するケースをチェックしています。3つ目は、ErrorStubNetworkから出力されるエラーがImageSearchを通して渡ってくることをチェックしています。
Command-Uを入力してテストを実行しましょう。パスしましたね!これで、MVVMアーキテクチャのModelの中心的な部分を実装し終えました。
まとめ
例題アプリのModel部分をMVVMアーキテクチャで開発することを通して、ModelをViewModelおよび外部システムから疎結合にする設計方法を学びました。プロトコルを使用することにより、実装に対する直接的な依存を排除できました。疎結合にしたコンポーネント間のイベントハンドリングのためにRectiveCocoaを利用したました。また、Himotokiを使うと簡単にJSONデータをエンティティにマッピングできることを見ました。次回のブログ記事では、ViewとViewModel部分を設計して実装していきます。
もし質問、提案、問題などがあれば気軽にコメントをどうぞ。
-
訳注: 英語版の著者本人による翻訳のため、翻訳に関わる著作権上の問題はありません。 ↩
-
関数型プログラミングに慣れていたら、Argoも便利なJSONパーサかもしれません。 ↩
-
DDD (ドメイン駆動設計)では、エンティティはドメインモデルの概念であり、アイデンティティにより定義されます。このブログ記事では、アイデンティティは関係なく、単に概念やオブジェクトを指す言葉としてエンティティを用います。 ↩
-
ErrorTypeについてより深く学ぶために、"How to Implement the ErrorType Protocol"を読むことをお薦めします。 ↩ -
try?は単にブログ記事を簡略化するために使用しています。do-try-catchのほうがエラー情報に基づいたハンドリングに適しています。 ↩

