Help us understand the problem. What is going on with this article?

RxTest、RxBlockingによるテストパターン

More than 1 year has passed since last update.

RxTestとRxBlocking、プロジェクトにRxSwift導入している人はみんな使っていますよね:question:

私の関わっているプロジェクトでもRxSwiftを使用しており、もちろんテストコードを書く際にはこの2つのライブラリを使っています。
RxTestとRxBlockingを使ったテストコードの書き方にはいくつか基本的なパターンがあり、それらを覚えておけば、Rxなコードのテストは簡単にかけるはずです。

本記事では、RxTestとRxBlockingを使ったテストコードの基本パターンと、実際のプロジェクトで出てくるようなプロダクションコードに対してそれらのパターンをどう適用するかという具体例を説明します。

なお、本記事で紹介するコードはRxSwift/RxCocoa/RxTest/RxBlockingのいずれもバージョン4.1.2で動作確認しています。
また、テストコードの記述にはQuick、Nimbleを使用しています。

掲載しているコードは以下のリポジトリに全て含まれています。
実際にテストを動かして動作確認してみてください。

https://github.com/takehilo/RxSwiftTestPatterns

そもそもRxとは?RxSwiftとは?という方は、まずこれらの記事から読んでみると良いのではないでしょうか。

RxTest、RxBlockingとは

RxSwiftを利用していると、入力や戻り値の型にObservable型をとる関数が出てきます。
通常の関数のテストであれば、何か入力を与えて関数を実行し、その結果を検証するということができますが、戻り値の型がObservable型である関数は通常の関数のようにテストすることは出来ません。
こうしたRxSwift特有のコードをテストするための便利な機能を提供してくれるのが、RxTestおよびRxBlockingです。

RxTest

RxTestが提供するTestSchedulerは、指定した時刻にObservableにイベントを発行することができるスケジューラです。
TestSchedulerを利用することで、どの時刻にどんなイベントがオブザーバに届いたのかを検証することができるようになります。

また、RxTestを使う上でおさえておきたいものとして、HotObservableColdObservableがあります。

HotObservableは、オブザーバがいるいないにかかわらず、指定された時刻に正確にイベントを発行するObservableです。
つまり、オブザーバがサブスクライブするタイミングによってはイベントが受け取れない可能性があるということです。

ColdObservableは、新しくオブザーバがサブスクライブすると、イベントを最初からリプレイします。
こちらはHotObservableとは違い、オブザーバはサブスクライブするタイミングに関わらず最初のイベントから受け取ることが出来ます。

HotObservableColdObservableについては、公式ドキュメントにも記載があります。
https://github.com/ReactiveX/RxSwift/blob/master/Documentation/HotAndColdObservables.md

また、Qiitaでも素晴らしい記事があります。
RxのHotとColdについて

RxBlocking

RxBlockingは、通常のObservableをBlockingObservableに変換します。
BlockingObservablecompletedイベントが発行されるかタイムアウトするまで、カレントスレッドをブロックします。
これにより、イベントが非同期に発行される場合でも簡単にテストをすることができます。

RxTest、RxBlockingを使ったテストコードの基本パターン

ここからは、RxTestとRxBlockingを使ったテストコードの基本的な書き方のパターンを見ていきます。

なお、テストの初期化コードを以下のように書いておきます。

class BasicPatterns: QuickSpec {
    override func spec() {
        var scheduler: TestScheduler!
        let disposeBag = DisposeBag()

        beforeEach {
            scheduler = TestScheduler(initialClock: 0)
        }
    }
}

[2019/04/23追記]
バージョン4.4.0から以下の記述が不要になりました
https://github.com/ReactiveX/RxSwift/releases/tag/4.4.0

また、以下のエクステンションを書いておくことで、Nimbleのexpectでイベントの検証が簡単にできます。

extension Recorded: Equatable where Value: Equatable {}
extension Event: Equatable where Element: Equatable {}

このエクステンションについてはこちらも参照:
https://github.com/Quick/Nimble/issues/523

:fire: HotObservable + start パターン

it("HotObservable + start") {
    let xs = scheduler.createHotObservable([
        Recorded.next(110, 10),
        Recorded.next(210, 20),
        Recorded.next(310, 30)
    ])

    let res = scheduler.start(created: 100, subscribed: 200, disposed: 1000) {
        xs.map { $0 * 2 }
    }

    expect(res.events).to(equal([
        Recorded.next(210, 40),
        Recorded.next(310, 60)
    ]))
}

最初のパターンなので、各文の意味を解説しておきます。

scheduler.createHotObservable()メソッドは、HotObservableを生成します。
ここでは、110、210、310という時刻にnextイベントを発行するよう指定しています。
10、20、30というのは、nextイベントとともにオブザーバに通知される要素(Element)です。

scheduler.start()メソッドは、テスト対象となるObservableを作成し、作成されたObservableにオブザーバをサブスクライブさせ、最後に破棄するという一連の流れを実行します。
scheduler.start()メソッドには、Observable作成を行うクロージャと、Observableの作成、サブスクライブ、破棄それぞれを実行する時刻を指定することが出来ます。

最後に、オブザーバに通知されたイベントの検証を行います。
res.eventsには、オブザーバが受信したイベントの時刻および要素が記録されていますので、想定通りのイベントが通知されているかをここで検証することが出来ます。

今回のテスト対象はmapオペレーターです。
Observableの作成コードをxs.map { $0 * 2 }としているので、オブザーバが受け取る要素はオリジナルの要素の2倍の数値となっているはずです。

検証コードを見ると、時刻210と310のイベントしかありません。
これは、HotObservableを使用しているためです。
HotObservableはイベントをリプレイしません。
オブザーバーは時刻200にサブスクライブしているため、その前の時刻110に発行されたイベントは受け取れないのです。

以上がHotObservable + startパターンです。

scheduler.start()メソッド

このメソッドのシグネチャは以下のとおりです。

start(created: TestTime, subscribed: TestTime, disposed: TestTime, create: @escaping () -> Observable) -> TestableObserver

エイリアスとして別に2つのメソッドが定義されています。

start(disposed: TestTime, create: @escaping () -> Observable) -> TestableObserver

start(_ create: @escaping () -> Observable) -> TestableObserver

createdsubscribeddisposedにはそれぞれデフォルト時刻として1002001000が設定されているため、先程のコードは以下のように書くことも可能です。

let res = scheduler.start {
    xs.map { $0 * 2 }
}

:shaved_ice: ColdObservable + start パターン

it("ColdObservable + start") {
    let xs = scheduler.createColdObservable([
        Recorded.next(110, 10),
        Recorded.next(210, 20),
        Recorded.next(310, 30)
    ])

    let res = scheduler.start(created: 100, subscribed: 200, disposed: 1000) {
        xs.map { $0 * 2 }
    }

    expect(res.events).to(equal([
        Recorded.next(310, 20),
        Recorded.next(410, 40),
        Recorded.next(510, 60)
    ]))
}

HotObservable + startパターンとの違いは、scheduler.createColdObservable()を使っているところです。
こちらの場合、テスト対象となるObservableはColdObservableとなります。
ColdObservableなので、オブザーバがサブスクライブするタイミングに関わらず、オブザーバはすべてのイベントを受信することができます。
検証コードを見てみると、想定通りすべてのイベントが受信できていることがわかります。

一方で、イベントを受信した時刻はHotObservableのときとは少し違います。
ColdObservableでは、オブザーバがサブスクライブしてからイベントを発行するまでの遅延時間を指定するようです。
今回、オブザーバは時刻200にサブスクライブしており、最初のイベントを発行するまでの遅延時間は110になっているので、オブザーバは時刻310に最初のイベントを受信しています。

以上がColdObservable + startパターンです。

:fire: HotObservable + scheduleAt + start パターン

it("HotObservable + scheduleAt + start") {
    let xs = scheduler.createHotObservable([
        Recorded.next(110, 10),
        Recorded.next(210, 20),
        Recorded.next(310, 30)
    ])

    let observer = scheduler.createObserver(Int.self)

    scheduler.scheduleAt(200) {
        xs.map { $0 * 2 }.subscribe(observer).disposed(by: disposeBag)
    }

    scheduler.start()

    expect(observer.events).to(equal([
        Recorded.next(210, 40),
        Recorded.next(310, 60)
    ]))
}

やっていることは最初のHotObservable + startパターンと同じですが、いくつか新しい関数が出てきました。

scheduler.createObserver()は、オブザーバを作成します。
このオブザーバはRxTestが提供するTestableObserverというクラスのインスタンスで、Observableが発行したイベントの時刻と要素を記録することができます。
これまでに出てきた変数resは、実はTestableObserverであり、scheduler.start()関数が内部的に作成していたものです。

scheduler.scheduleAt()は、指定した時刻に引数として渡されたクロージャを実行します。
ここでは、時刻200にオブザーバをサブスクライブさせています。

scheduler.start()は、今までのそれとは違うもので、TestSchedulerが管理している仮想時間を開始する関数です。
これを実行することで、指定時刻にObservableがイベントを発行したり、scheduler.scheduleAt()で指定されたクロージャが実行されたりします。

以上がHotObservable + scheduleAt + startパターンです。

:shaved_ice: ColdObservable + scheduleAt + start パターン

it("ColdObservable + scheduleAt + start") {
    let xs = scheduler.createColdObservable([
        Recorded.next(110, 10),
        Recorded.next(210, 20),
        Recorded.next(310, 30)
    ])

    let observer = scheduler.createObserver(Int.self)

    scheduler.scheduleAt(200) {
        xs.map { $0 * 2 }.subscribe(observer).disposed(by: disposeBag)
    }

    scheduler.start()

    expect(observer.events).to(equal([
        Recorded.next(310, 20),
        Recorded.next(410, 40),
        Recorded.next(510, 60)
    ]))
}

HotObservable + scheduleAt + startパターンのColdObservable版です。
やっていることはColdObservable + startパターンと同じです。

:no_entry_sign: Blocking パターン

it("Blocking") {
    // 非同期にイベントが発行されるObservable
    let observable = Observable.of(10, 20, 30)
        .map { $0 * 2 }
        .observeOn(ConcurrentDispatchQueueScheduler(qos: .background))

    let blocking = observable.toBlocking()

    expect(try! blocking.first()).to(equal(20))
    expect(try! blocking.last()).to(equal(60))
    expect(try! blocking.toArray()).to(equal([20, 40, 60]))
    expect { try blocking.single() }.to(throwError(RxError.moreThanOneElement))

    let materialized = blocking.materialize()
    if case let .completed(elements) = materialized {
        expect(elements).to(equal([20, 40, 60]))
    } else {
        fail("expected completed but got \(materialized)")
    }
}

RxBlockingが提供するtoBlocking()を活用したパターンです。
APIクライアントの関数など、イベントが非同期に発行されるObservableを返す関数をテストするときに有効です。

toBlocking()は通常のObservableをBlockingObservableに変換します。
BlockingObservableは、completedイベントが発行されるまでカレントスレッドつまりここではメインスレッドをブロックします。
これにより、非同期に発行されるイベントであっても簡単に要素の検証ができます。

BlockingObservableは、要素の検証行うために5つのメソッドを用意しています。

first()last()は、Observableが発行した最初と最後の要素を返します。
toArray()は、Observableが発行したすべてのイベントの要素を返します。
single()は、Observableが1つのイベントだけを発行した場合に、その要素を返します。
イベントが2つ以上発行された場合は例外をスローします。
materialize()は、MaterializedSequenceResultというEnumを返します。
Observableがcompletedしたのかfailedしたのかを検証することができます。

なお、toBlocking()を使う場合、scheduler.createHotObservable()scheduler.createColdObservable()で生成したObservable(TestableObservable)は使用できません。
TestableObservableに対してtoBlocking()を呼ぶと、いつまでたってもテストが終了しません。

it("Don't do this") {
    let xs = scheduler.createHotObservable([
        Recorded.next(110, 10),
        Recorded.next(210, 20),
        Recorded.next(310, 30),
        Recorded.completed(40)
    ])

    // ブロックされたままになりテストが終了しない
    expect(try! xs.toBlocking().toArray()).to(equal([10, 20, 30]))
}

以上がBlockingパターンです。

RxTest、RxBlockingを使ったテストコードの実践パターン

これまでは、RxTest、RxBlockingが提供する機能の使い方の説明がメインであったため、テスト対象はmapオペレーターを使っただけのシンプルなものを使用してきました。

実際のプロジェクトでは、テスト対象のコードはもう少し複雑になります。
しかし、基本的にはこれまで説明してきた基本パターンを適用していくだけです。

ここからは、実際のプロジェクトで出てくるようなテスト対象のコードに対して、どのようにテストを書いていけばよいのかを説明していきます。

APIクライアント

HTTP通信を行うAPIクライアントクラスのメソッドの戻り値の型はSingleを使うことが多いですが、これはBlockingパターンを使用できる典型的な例です。
自分は使ったことがないですが、CompletableMaybeを返す関数もBlockingパターンが使えます。

これらのObservableは必ずcompletederrorイベントをemitします。
なのでRxBlockingによってこれらのイベントがemitされるまでブロックし、最後に結果を検証する事ができます。

テスト対象コード
class UserService {
    func fetchUser(by userId: Int) -> Single<User> {
        return Single.create { single in
            Alamofire.request("https://api.example.com/users/\(userId)").responseData { response in
                switch response.result {
                case let .success(data):
                    do {
                        let user = try JSONDecoder().decode(User.self, from: data)
                        single(.success(user))
                    } catch {
                        single(.error(error))
                    }
                case let .failure(error):
                    single(.error(error))
                }
            }

            return Disposables.create()
        }
    }
}
テストコード
var userService: UserService!

beforeEach {
    userService = UserService()

    let userJson = "{\"id\": 1, \"name\": \"test-user\"}"
    self.stub(uri("https://api.example.com/users/1"), jsonData(userJson.data(using: .utf8)!))
}

it("should fetch an user") {
    let expectedUser = User(id: 1, name: "test-user")
    expect(try! userService.fetchUser(by: 1).toBlocking().single()).to(equal(expectedUser))
}

HTTP通信をスタブ化するために、ここではMockingjayというライブラリを使用しています。

self.stub(uri("https://api.example.com/users/1"), jsonData(userJson.data(using: .utf8)!))

ここでは、HTTPリクエストのURLがhttps://api.example.com/users/1だった場合にuserJsonのJSON文字列をレスポンスとして返すという宣言をしています。
こうすることで、実際にこのURLにはアクセスせずにすぐにレスポンスが帰ってくるようになります。

さて、肝心のBlockingパターンによるテストコードですが、ものすごくシンプルになっています。

expect(try! userService.fetchUser(by: 1).toBlocking().single()).to(equal(expectedUser))

ここでテストしているのは、emitされる要素が1つであることと、その要素の値がexpectedUserと一致していることです。
また、Mockingjayによってリクエストの宛先URLがhttps://api.example.com/users/1であることの検証もしていることになります。

Subject/Relay

私はViewModelで状態を保持するプロパティの型としてBehaviorRelayをよく使います。
UITextField等のUIをバインドし、何らかのロジックを実行してUIに結果を反映させるという使い方です。

ここではHotObservable + scheduleAt + startパターンを使用した例をご紹介します。

なお、ここで紹介するテストコードの書き方は、同じRelayのPublishRelayや、PublishSubjectBehaviorSubjectなどのSubjectでも使えます。

テスト対象コード
class LoginViewModel {
    let email = BehaviorRelay<String>(value: "")

    var isValidEmail: Driver<Bool> {
        return email.asDriver().map { !$0.isEmpty }
    }

    var isValidPassword: Driver<Bool> {
        return password.asDriver().map { !$0.isEmpty }
    }

    var isValidForm: Driver<Bool> {
        return Driver.combineLatest(isValidEmail, isValidPassword).map { $0 && $1 }
    }
}
テストコード
var scheduler: TestScheduler!
var loginViewModel: LoginViewModel!

let disposeBag = DisposeBag()

beforeEach {
    scheduler = TestScheduler(initialClock: 0)
    loginViewModel = LoginViewModel()
}

describe("isValidEmail") {
    it("should be true when email is not empty") {
        let xs = scheduler.createHotObservable([
            Recorded.next(10, ""),
            Recorded.next(20, "a@example.com"),
            Recorded.next(30, "")
        ])

        xs.bind(to: loginViewModel.email).disposed(by: disposeBag)

        let observer = scheduler.createObserver(Bool.self)
        loginViewModel.isValidEmail.drive(observer).disposed(by: disposeBag)

        scheduler.start()

        expect(observer.events).to(equal([
            Recorded.next(0, false),
            Recorded.next(10, false),
            Recorded.next(20, true),
            Recorded.next(30, false)
        ]))
    }
}

describe("isValidForm") {
    it("should be true when both email and password are valid") {
        let xs1 = scheduler.createHotObservable([
            Recorded.next(10, ""),
            Recorded.next(30, "a@example.com"),
            Recorded.next(50, "")
        ])

        let xs2 = scheduler.createHotObservable([
            Recorded.next(20, ""),
            Recorded.next(40, "passw0rd"),
            Recorded.next(60, "")
        ])

        xs1.bind(to: loginViewModel.email).disposed(by: disposeBag)
        xs2.bind(to: loginViewModel.password).disposed(by: disposeBag)

        let observer = scheduler.createObserver(Bool.self)
        loginViewModel.isValidForm.drive(observer).disposed(by: disposeBag)

        scheduler.start()

        expect(observer.events).to(equal([
            Recorded.next(0, false),
            Recorded.next(10, false),
            Recorded.next(20, false),
            Recorded.next(30, false),
            Recorded.next(40, true),
            Recorded.next(50, false),
            Recorded.next(60, false)
        ]))
    }
}

isValidEmailのテスト

LoginViewModelのemailプロパティは、ビューのUITextFieldにバインドされることを想定しています。
そして、ビュー側ではisValidEmailをサブスクライブし、文字が入力されるたびにそのフィールドが有効かどうかをユーザにフィードバックするというイメージです。

テストコードを見てみましょう。
ここではemailに対して["", a@example.com, ""]をemitするHotObservableをバインドし、isValidEmailは[false, false, true, false]をオブザーバに通知することをテストしようとしています。

ここで、時刻0でfalseとなっているのは、BehaviorRelayはオブザーバがサブスクライブしたときに最後の要素をemitする(リプレイする)という性質があるためです。

isValidFormのテスト

isValidFormは、emailpassword両方が有効な場合のみtrueを返します。
isValidFormはビューのUIButton.rx.isEnabledにバインドし、ユーザがログインフォームに文字を入力するたびにフォームが評価され、ボタンがリアクティブに有効になったり無効になったりするというような使い方を想定しています。

テストコードを見てみましょう。
はじめに、2つのHotObservableを作成し、それぞれLoginViewModelのemailpasswordにバインドします。
次に、オブザーバを作成し、LoginViewModelのisValidFormにサブスクライブさせます。
これで準備完了です。

ここで期待しているのは、emailpassword両方のフィールドが有効なときのみisValidFormtrueになることです。
今回の場合、時刻40のときにどちらのフィールドにも文字が1文字以上入力されている状態になるので、時刻40のときにisValidFormtrueになります。

まとめ

RxTest、RxBlockingの概要、基本的なテストコードパターン、そして実践的なテストコード例を説明しました。

もっと実践的な例をご紹介できればよかったのですが、正直なところ自分が関わっているプロジェクトではこの程度しかテストコードのパターンが出てきませんでした。
特にColdObservableは使ったことがなく、うまい使い方が思いついていないです。

他に想定されるケースとしては、自作オペレータ、3rdパーティライブラリのrxエクステンションなどでしょうか。
今後プロジェクトを進めていく中で新しい実践例を見つけたら、本記事を更新していきたいと思います。
こんなパターンがあるよという方がいましたら、是非コメントいただけると嬉しいです!

takehilo
iOSアプリ開発エンジニア note: https://note.mu/takehilo 個人開発アプリ: https://apple.co/2S9HRR4
uzabase
企業活動の意思決定を支える情報インフラの提供
https://www.uzabase.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした