はじめに
swiftはほとんど未経験ですが、SmartNews風ニュースアプリを作ってみて、その過程をさらしています。
前回は、こんな記事を書きました。
swift初心者がSmartNews風ニュースアプリを作ってみる過程を晒す(8) - RxSwiftを用いてMVVMを実装する - Qiita
今回は、ここまで実装したアプリケーションのテストを書いてみます。
心構え
テストコードを書くにあたって、以下の点に留意しました。
実際のネットワークリクエストが発生する部分にはMockを使用する
- 毎回ネットワークリクエストが発生するようなテストは遅くなり、遅いテストは頻繁に実行されなくなるため、そのような書き方は良くないとされてます。
- とはいえ、APIの仕様が変更された時などはすぐに気づける方が嬉しいので、
EntryStoreTests.swift
の中では例外的に、実際にネットワークリクエストを発生させてAPIにアクセスしています。 - Mockを使うことで異常系のテストを書きやすくなります(Mockを使わずにエラーや例外を発生させることは困難だったり面倒くさい場合があります)。
Mockはprotocolに準拠する
- Protocolを使用することで、Structでもクラスでも一貫した方法でモック化できたりするメリットがあります。Protocolを使用するメリットは、下記の資料に詳しく記載されています。
- Swift では Protocol を積極的に使おう - Qiita
- Swiftにおける現実的なモック
DI(依存性注入)を用いる
DIとは、オブジェクトが自身のインスタンス変数を作成せずに、どこか別の所から渡してもらうことを意味します。DIをテスト時に用いることで、テストする対象のみにフォーカスしやすくなります(このアプリの例で言えば、ニュース記事を利用する部分が、ニュース記事を取得する部分に依存しない構造になっていれば、ユニットテストを書くのが簡単になります)。
例として、Serviceレイヤのコードを確認します。EntryAPIServiceはAPIからデータを取得する役割を担っています。ところが、この実装では、entryStoreを自身で生成してしまっているため、テストを実行するたびにネットワークアクセスが発生してしまいます。
これを回避するため、テスト時には外部からentryStoreを渡してモック化してやりたい訳です。
import Foundation
import RxSwift
import RxCocoa
import RxAlamofire
class EntryAPIService {
enum APIError: ErrorType {
case CannotParse
}
let entryStore = EntryStoreImpl()
func fetchEntries(q q: String) -> Driver<[Entry]> {
return entryStore.fetchEntries(q: q)
}
}
次のようにDIを用いた実装にすれば、外部からentryStoreを渡してやることができ、entryStoreをモック化することが簡単になります。
class EntryAPIService {
enum APIError: ErrorType {
case CannotParse
}
let entryStore: EntryStore // EntryStoreプロトコルに準拠していればよくて、entryStoreのクラスが何であるかは知らない
init(entryStore: EntryStore) { //依存関係を明確にするために、コンストラクタで依存性を注入する
self.entryStore = entryStore
}
// ...
}
import RxCocoa
protocol EntryStore {
func fetchEntries(q q: String) -> Driver<[Entry]>
}
プロダクションコードでは、実際にAPIにアクセスする必要があるため、EntryStoreImplを使用します。
import RxCocoa
import RxAlamofire
class EntryStoreImpl: EntryStore {
func fetchEntries(q q: String) -> Driver<[Entry]> {
let url = "https://ajax.googleapis.com/ajax/services/feed/load?v=1.0&q=http://menthas.com/\(q)/rss"
return JSON(.GET, url)
.asDriver(onErrorJustReturn: []) //Builder just needs info about what to return in case of error.
.map({ json -> [Entry] in
guard let responseData = json["responseData"] as? Dictionary<String, AnyObject> else {return []}
guard let feed = responseData["feed"] as? Dictionary<String, AnyObject> else {return []}
guard let entries = feed["entries"] as? [AnyObject] else {return []}
return entries.map { Entry(value: $0) }
})
}
}
テストコードでは、極力ネットワークアクセスを発生させないため、EntryStoreMockを使用します。
import RxCocoa
import RxAlamofire
class EntryStoreMock: EntryStore {
func fetchEntries(q q: String) -> Driver<[Entry]> {
let entry = Entry()
entry.title = "title1"
entry.link = "https://example.com/articles/1"
entry.contentSnippet = "今日はとてもいい天気..."
return Driver.just([Entry](arrayLiteral: entry))
}
}
テスト時には、次のようにしてEntryAPIServiceを初期化することで、ネットワークアクセス部分をモック化してテストを実行できます.
let e = EntryAPIService(entryStore: EntryStoreMock())
func testFetchEntries() {
let expectation = expectationWithDescription("subscribeNext called")
let e = EntryAPIService(entryStore: EntryStoreMock())
let expectedArticle = Entry()
expectedArticle.setValuesForKeysWithDictionary(
["title": "title1", "link": "https://example.com/articles/1", "contentSnippet": "今日はとてもいい天気..."]
)
e.fetchEntries(q: "ios").driveNext({ (articles) in
XCTAssertNotNil(articles)
XCTAssertFalse(articles.isEmpty)
XCTAssertEqual(articles.first, expectedArticle)
expectation.fulfill()
}).addDisposableTo(bag)
waitForExpectationsWithTimeout(5, handler: { error in
if let error = error {
XCTFail("waitForExpectationsWithTimeout errored: \(error)")
}
})
}
感想
- DIとMockを使用することで、各コンポーネントを疎結合にし、簡単にユニットテストを書くことができました。
- プロダクションの環境を壊したくない場合や、異常系の処理をテストするにはモックを使用しないと困難な場合もあるでしょうから、覚えておいて損のないテクニックだと思います。
おわりに
ソースコードはこちらです。