19
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

swift初心者がSmartNews風ニュースアプリを作ってみる過程を晒す(9) - DIとMockを使用したテストを書く

Last updated at Posted at 2016-07-18

はじめに

swiftはほとんど未経験ですが、SmartNews風ニュースアプリを作ってみて、その過程をさらしています。

スクリーンショット 2016-07-03 11.39.55.png

前回は、こんな記事を書きました。

swift初心者がSmartNews風ニュースアプリを作ってみる過程を晒す(8) - RxSwiftを用いてMVVMを実装する - Qiita

今回は、ここまで実装したアプリケーションのテストを書いてみます。

心構え

テストコードを書くにあたって、以下の点に留意しました。

実際のネットワークリクエストが発生する部分にはMockを使用する

  • 毎回ネットワークリクエストが発生するようなテストは遅くなり、遅いテストは頻繁に実行されなくなるため、そのような書き方は良くないとされてます。
  • とはいえ、APIの仕様が変更された時などはすぐに気づける方が嬉しいので、EntryStoreTests.swiftの中では例外的に、実際にネットワークリクエストを発生させてAPIにアクセスしています。
  • Mockを使うことで異常系のテストを書きやすくなります(Mockを使わずにエラーや例外を発生させることは困難だったり面倒くさい場合があります)。

Mockはprotocolに準拠する

DI(依存性注入)を用いる

DIとは、オブジェクトが自身のインスタンス変数を作成せずに、どこか別の所から渡してもらうことを意味します。DIをテスト時に用いることで、テストする対象のみにフォーカスしやすくなります(このアプリの例で言えば、ニュース記事を利用する部分が、ニュース記事を取得する部分に依存しない構造になっていれば、ユニットテストを書くのが簡単になります)。

例として、Serviceレイヤのコードを確認します。EntryAPIServiceはAPIからデータを取得する役割を担っています。ところが、この実装では、entryStoreを自身で生成してしまっているため、テストを実行するたびにネットワークアクセスが発生してしまいます。

これを回避するため、テスト時には外部からentryStoreを渡してモック化してやりたい訳です。

EntryAPIService.swift

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をモック化することが簡単になります。

EntryAPIService.swift

class EntryAPIService {
    enum APIError: ErrorType {
        case CannotParse
    }    
    let entryStore: EntryStore // EntryStoreプロトコルに準拠していればよくて、entryStoreのクラスが何であるかは知らない
    
    init(entryStore: EntryStore) { //依存関係を明確にするために、コンストラクタで依存性を注入する
        self.entryStore = entryStore
    }
        // ...
    
}
EntryStore.swift
import RxCocoa

protocol EntryStore {
    func fetchEntries(q q: String) -> Driver<[Entry]>
}

プロダクションコードでは、実際にAPIにアクセスする必要があるため、EntryStoreImplを使用します。

EntryStoreImpl.swift
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を使用します。

EntryStoreMock.swift
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())
EntryAPIServiceTests.swift
    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を使用することで、各コンポーネントを疎結合にし、簡単にユニットテストを書くことができました。
  • プロダクションの環境を壊したくない場合や、異常系の処理をテストするにはモックを使用しないと困難な場合もあるでしょうから、覚えておいて損のないテクニックだと思います。

おわりに

ソースコードはこちらです。

参考

19
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
19
20

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?