RxTestとRxBlocking、プロジェクトにRxSwift導入している人はみんな使っていますよね
私の関わっているプロジェクトでもRxSwiftを使用しており、もちろんテストコードを書く際にはこの2つのライブラリを使っています。
RxTestとRxBlockingを使ったテストコードの書き方にはいくつか基本的なパターンがあり、それらを覚えておけば、Rxなコードのテストは簡単にかけるはずです。
本記事では、RxTestとRxBlockingを使ったテストコードの基本パターンと、実際のプロジェクトで出てくるようなプロダクションコードに対してそれらのパターンをどう適用するかという具体例を説明します。
なお、本記事で紹介するコードはRxSwift/RxCocoa/RxTest/RxBlockingのいずれもバージョン4.1.2
で動作確認しています。
また、テストコードの記述にはQuick、Nimbleを使用しています。
掲載しているコードは以下のリポジトリに全て含まれています。
実際にテストを動かして動作確認してみてください。
そもそもRxとは?RxSwiftとは?という方は、まずこれらの記事から読んでみると良いのではないでしょうか。
RxTest、RxBlockingとは
RxSwiftを利用していると、入力や戻り値の型にObservable
型をとる関数が出てきます。
通常の関数のテストであれば、何か入力を与えて関数を実行し、その結果を検証するということができますが、戻り値の型がObservable
型である関数は通常の関数のようにテストすることは出来ません。
こうしたRxSwift特有のコードをテストするための便利な機能を提供してくれるのが、RxTestおよびRxBlockingです。
RxTest
RxTestが提供するTestScheduler
は、指定した時刻にObservableにイベントを発行することができるスケジューラです。
TestScheduler
を利用することで、どの時刻にどんなイベントがオブザーバに届いたのかを検証することができるようになります。
また、RxTestを使う上でおさえておきたいものとして、HotObservable
とColdObservable
があります。
HotObservable
は、オブザーバがいるいないにかかわらず、指定された時刻に正確にイベントを発行するObservableです。
つまり、オブザーバがサブスクライブするタイミングによってはイベントが受け取れない可能性があるということです。
ColdObservable
は、新しくオブザーバがサブスクライブすると、イベントを最初からリプレイします。
こちらはHotObservable
とは違い、オブザーバはサブスクライブするタイミングに関わらず最初のイベントから受け取ることが出来ます。
HotObservable
とColdObservable
については、公式ドキュメントにも記載があります。
https://github.com/ReactiveX/RxSwift/blob/master/Documentation/HotAndColdObservables.md
また、Qiitaでも素晴らしい記事があります。
RxのHotとColdについて
RxBlocking
RxBlockingは、通常のObservableをBlockingObservable
に変換します。
BlockingObservable
はcompleted
イベントが発行されるかタイムアウトするまで、カレントスレッドをブロックします。
これにより、イベントが非同期に発行される場合でも簡単にテストをすることができます。
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
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
created
、subscribed
、disposed
にはそれぞれデフォルト時刻として100
、200
、1000
が設定されているため、先程のコードは以下のように書くことも可能です。let res = scheduler.start { xs.map { $0 * 2 } }
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
パターンです。
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
パターンです。
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
パターンと同じです。
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
パターンを使用できる典型的な例です。
自分は使ったことがないですが、Completable
やMaybe
を返す関数もBlocking
パターンが使えます。
これらのObservableは必ずcompleted
かerror
イベントを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
や、PublishSubject
、BehaviorSubject
などの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
は、email
とpassword
両方が有効な場合のみtrue
を返します。
isValidForm
はビューのUIButton.rx.isEnabled
にバインドし、ユーザがログインフォームに文字を入力するたびにフォームが評価され、ボタンがリアクティブに有効になったり無効になったりするというような使い方を想定しています。
テストコードを見てみましょう。
はじめに、2つのHotObservableを作成し、それぞれLoginViewModelのemail
とpassword
にバインドします。
次に、オブザーバを作成し、LoginViewModelのisValidForm
にサブスクライブさせます。
これで準備完了です。
ここで期待しているのは、email
とpassword
両方のフィールドが有効なときのみisValidForm
がtrue
になることです。
今回の場合、時刻40のときにどちらのフィールドにも文字が1文字以上入力されている状態になるので、時刻40のときにisValidForm
がtrue
になります。
まとめ
RxTest、RxBlockingの概要、基本的なテストコードパターン、そして実践的なテストコード例を説明しました。
もっと実践的な例をご紹介できればよかったのですが、正直なところ自分が関わっているプロジェクトではこの程度しかテストコードのパターンが出てきませんでした。
特にColdObservable
は使ったことがなく、うまい使い方が思いついていないです。
他に想定されるケースとしては、自作オペレータ、3rdパーティライブラリのrxエクステンションなどでしょうか。
今後プロジェクトを進めていく中で新しい実践例を見つけたら、本記事を更新していきたいと思います。
こんなパターンがあるよという方がいましたら、是非コメントいただけると嬉しいです!