TestScheduler

RxSwiftにはVirtualTimeSchedulerというSchedulerがあって、仮想的な時間でイベントストリームを扱えます。仮想的な時間というとなんだか難しそうですが、要は時間を自分で操作できるSchedulerということです。

RxSwiftのテストライブラリのRxTestsには、TestSchedulerというSchedulerも用意されています。これはVirtualTimeSchedulerにテスト向けの機能をつけたもので、イベントを記録できたりします。記録したイベントはXCTAssertEqual(_:_:)で検証できるので、どの時刻にどの値が来くるかテストできるというわけです。

func test() {
    let scheduler = TestScheduler(initialClock: 0)
    let observer = scheduler.createObserver(String)

    scheduler.scheduleAt(100) { observer.onNext("abc") }
    scheduler.scheduleAt(150) { observer.onNext("def") }
    scheduler.scheduleAt(200) { observer.onNext("ghi") }
    scheduler.start()

    XCTAssertEqual(observer.events, [
        next(100, "abc"),
        next(150, "def"),
        next(200, "ghi"),
    ])
}

今回はこれとAPIKit 2のSessionAdapterTypeを組み合わせたテストの例を紹介します。

SessionAdapterType

APIKitのSessionのバックエンドはデフォルトではNSURLSessionとなっています。SessionAdapterTypeというのは、Sessionとバックエンドをつなぎ込むためのプロトコルで、NSURLSessionの場合はNSURLSessionAdapterというクラスがつなぎ込みを担当しています。

SessionはAdapterさえ用意できればどんなバックエンドにもできるので、当然スタブをバックエンドにすることも可能です。今回はreturnData(_:)が呼ばれると先頭のタスクのハンドラが実行されるという雑なAdapterを用意しました。

class TestSessionAdapter: SessionAdapterType {
    var tasks = [Task]()

    func returnData(data: NSData?, URLResponse: NSURLResponse? = NSHTTPURLResponse(URL: NSURL(), statusCode: 200, HTTPVersion: nil, headerFields: nil), error: NSError? = nil) {
        guard !tasks.isEmpty else {
            return
        }

        let task = tasks.removeFirst()
        task.handler(data, URLResponse, error)
    }

    // MARK: SessionAdapterType
    func resumedTaskWithURLRequest(URLRequest: NSURLRequest, handler: (NSData?, NSURLResponse?, NSError?) -> Void) -> SessionTaskType {
        let task = Task(handler: handler)
        tasks.append(task)
        return task
    }
}

実行キュー

非同期のテストは何かと面倒なんですが、幸い今回はすべて同期にできます。

VirtualTimeSchedulerが持っている時刻というのは単なる数値で、非同期的に実行されるわけではありません。実際には、MainSchedulerで同期的に実行されます。

Sessionにはコールバックのキューの選択肢がenumで用意されており、Sessionの実行キューから同期的にコールバックが実行するCallbackQueue.SessionQueueというcaseもあります。SessionのコールバックキューをCallbackQueue.SessionQueueに設定しつつ、Adapterを同期的に実行されるように実装すると、リクエストを送ってからレスポンスを受け取るまでの処理がすべて同期的に実行されます。

こうして、(仮想上の)任意の時刻にレスポンスを同期的に返せるようになりました。

RxPaginationPaginationViewModel<Request: PaginationRequestType>を例にどんなテストが書けるのか紹介します。PaginationViewModel<Request>はユーザー入力のストリームに応じてリクエストを送り、ローディング状態やレスポンスや次ページの有無などを管理するViewModelです。リクエストは型パラメーターになっていて、デモではGitHub APIのリポジトリ検索のリクエストで特殊化しています。

以下のような条件のテストを書いてみます。

  • 100: 更新のトリガーを送る
  • 150: GET /search/repositoriesのレスポンスを返す
class PaginationViewModelTests: XCTestCase {
    var disposeBag: DisposeBag!
    var scheduler: TestScheduler!

    var sessionAdapter: TestSessionAdapter!
    var session: Session!
    var request: GitHubAPI.SearchRepositoriesRequest!
    var viewModel: PaginationViewModel<GitHubAPI.SearchRepositoriesRequest>!

    override func setUp() {
        disposeBag = DisposeBag()
        scheduler = TestScheduler(initialClock: 0)

        sessionAdapter = TestSessionAdapter()
        session = Session(adapter: sessionAdapter, callbackQueue: .SessionQueue)

        request = GitHubAPI.SearchRepositoriesRequest(query: "Swift")
        viewModel = PaginationViewModel(baseRequest: request, session: session)
    }

    func test() {
        let loading = scheduler.createObserver(Bool)
        viewModel.loading.asDriver()
            .drive(loading)
            .addDisposableTo(disposeBag)

        let elementsCount = scheduler.createObserver(Int)
        viewModel.elements.asDriver()
            .map { $0.count }
            .drive(elementsCount)
            .addDisposableTo(disposeBag)

        scheduler.scheduleAt(100) { self.viewModel.refreshTrigger.onNext() }
        scheduler.scheduleAt(150) { self.sessionAdapter.returnData(Fixture.SearchRepositories.data) }
        scheduler.start()

        XCTAssertEqual(loading.events, [
            next(  0, false), // まだ何もしてない
            next(100, true),  // 更新トリガーが来て読み込み始めた
            next(150, false), // レスポンスが来て読み込み終わった
        ])

        XCTAssertEqual(elementsCount.events, [
            next(  0, 0),  // まだ何もしてない
            next(150, 30), // レスポンスが来て要素数に反映された (FixtureのJSONに30個入ってる)
        ])
    }
}

setUp()がやや雑多な感じになってしまっていますが、テスト自体は普通に待ち合わせるよりも随分と楽になりました。これくらいなら、”ViewModelはテストが簡単”というのもそうかもな〜って気になれるかもしれません。

コードの全体はRxPaginationに置いてあります。