はじめに
多くの開発現場では、日々成長しているアプリのコードをどうやってメンテナンス性が高く、安全で品質の高いものにしていくかが重要な課題になっているかと思います。
「iOSアプリ設計パターン入門」などが出版される背景にも、やはり多くの方が絶対的な正解のない中で、模索しながら開発しているのではないでしょうか。
そこでこの記事ではiOSアプリのMVVMパターンにおけるViewModelの責務、そしてViewModelについてのユニットテストについてフォーカスした記事を書きたいと思い投稿しました。
なぜViewModelがテスタブルであるべきか
いろんな記事や参考書で書かれていることかと思いますが、ViewModelの役割としては、Data Bindingの機構を用いてViewからの入力とViewに対する出力のロジックまかないます。
Viewのライフサイクルに依存しないモジュールとなるため、ViewModelはテストが書きやすいはずであり、テスタブルであることはアプリ品質を担保するための重要な指標となります。
テスタブルなViewModelにするたのポイント
テスタブルなViewModelを設計しユニットテストを実装するために、以下のような事がポイントだと考えています。
- in-outを意識した実装
- RxTest、RxBlockingを用いたテストコードの実装
- 外部モジュールに対してMockによる差し替えを可能とする実装(Dependency Injection)
以下に具体的な実装例を用いて説明していきたいと思います。
実装例
環境
- macOS 10.14.1
- Xcode 10.1
- Swift 4.2
- RxSwift 4.4
- RxCocoa 4.4
- RxTest 4.4
- RxBlocking 4.4
Labelのbindを例としたViewModelのObservableプロパティに対するテスト
一番シンプルな例として、
例えばUILabel
にbindするようなプロパティとしてViewModelが以下のように定義されている場合、
private var myTitleSubject = BehaviorRelay(value: "MyTitle")
var myTitle: Driver<String> {
return myTitleSubject.asDriver()
}
ユニットテストのコードは以下のようになります。
func testMyTitle() {
let viewModel = MyViewModel()
let disposeBag = DisposeBag()
/// 1. TestSchedulerを作成
let scheduler = TestScheduler(initialClock: 0)
/// 2. TestableObservableの作成
let result = scheduler.createObserver(String.self)
/// 3. subscribeを実行
scheduler.scheduleAt(100) {
viewModel.myTitle
.drive(result)
.disposed(by: disposeBag)
}
/// 4. スケジューラー開始
scheduler.start()
/// 5. 予測結果を定義します。
let expectedEvents = Recorded.events([.next(100, "MyTitle")])
/// 6. 結果を検証します。
XCTAssertEqual(result.events, expectedEvents)
}
-
RxTest
をimport
し、TestScheduler
を用います。
TestScheduler
は仮想時間という概念をもっており、0 -- 100 -- 200 -- ... 1000
のような数字で、Rxのストリームに流れるデータと時間を定義していきます。
initialClockは初期状態の時間です。 -
TestScheduler#createObserver
でTestableObservable<T>
を作成します。
今回は評価対象がString
型なので、String.self
を指定します。 -
TestScheduler#scheduleAt
でスケジュールを追加します。
適当に時間を100と決め、subscribeを行います。 -
TestScheduler#start
でスケジューラーを開始します。 -
Recorded.events
でObservableに流れるイベントのシーケンスを予測した結果を作成します。
今回はDriverつまり、BehaviorSubjectタイプなので、subscribeした時点で最後の値がreplayします。
subscribeを仮想の時間100のときに行っていますので、onNextが時間100のときに流れ、Stringの値が取れるという想定になります。 - 値の検証は、通常通り
XCTAssertEqual
を使って、2で作成したTestableObservable#result
と予測結果を比較します。
TextFieldの入力値に応じたValidationプロパティに対するテスト
例として、password
というプロパティと、それが8桁以上の文字列を入力した場合のみ有効と判定するValidationプロパティをViewModelが持っているとして、
var password = BehaviorRelay<String?>(value: nil)
lazy var passwordValid: Driver<Bool> = {
return password
.map { ($0 ?? "").count >= 8 }
.asDriver(onErrorJustReturn: false)
}()
これに対するテストは以下のようになります。
func testPasswordValid() {
let scheduler = TestScheduler(initialClock: 0)
let viewModel = MVVMListViewModel()
let disposeBag = DisposeBag()
let results = scheduler.createObserver(Bool.self)
scheduler.scheduleAt(100) {
viewModel.passwordValid
.drive(results)
.disposed(by: disposeBag)
}
scheduler.scheduleAt(200) {
viewModel.password.accept("")
}
scheduler.scheduleAt(300) {
viewModel.password.accept("1234567")
}
scheduler.scheduleAt(400) {
viewModel.password.accept("12345678")
}
scheduler.start()
let expectedEvents = Recorded.events(
.next(100, false),
.next(200, false),
.next(300, false),
.next(400, true)
)
XCTAssertEqual(results.events, expectedEvents)
}
先程のscheduleAt
を複数利用し、subscribeから値の入力まだスケジュールを登録していきます。
仮想時間100~300までは8桁未満のためfalseが流れますが、最終的に時間400のときに8桁を入力してtrueとなることがテストできました。
画面遷移を例としたViewModelに対するコマンド呼び出しとイベントの発火に対するテスト
次は画面遷移を例としてテストを書いてみましょう。
まず画面遷移方法としてpushとmodalの2種類をもち、Associated ValueにUIViewControllerを持つenumを定義しました。
rawValueを定義しているのはテストではこちらを用いて比較しやすくするためです。
enum ViewControllerTransitionType {
case push(UIViewController)
case modal(UIViewController)
var rawValue: Int {
switch self {
case .push: return 0
case .modal: return 1
}
}
}
ViewModelではこちらを用いて画面遷移を実装します。
Signalとして公開し、イベントを発火させるためのopenSecond()
というメソッドを定義しました。
private var viewControllerTransitionSubject = PublishRelay<ViewControllerTransitionType>()
var viewControllerTransition: Signal<ViewControllerTransitionType> {
return viewControllerTransition.asSignal()
}
func openSecond() {
let viewController = SecondViewController()
viewControllerTransitionSubject.accept(.push(viewController))
}
ViewControllerではsecondViewControllerSignal
を購読し、シーケンスに流れてきたViewControllerを用いて画面遷移を実装します。あとは任意のタイミングでopenSecond()
を呼び出すといった形になります。
viewModel.viewControllerTransition
.emit(onNext: { [weak self] type in
switch type {
case .push(let viewController):
self?.navigationController?.pushViewController(viewController, animated: true)
case .modal(let viewController):
let navigationController = UINavigationController(rootViewController: viewController)
self?.present(navigationController, animated: true, completion: nil)
}
})
.disposed(by: disposeBag)
こちらをテストするコードは以下のようになります。
func testOpenSecond() {
let scheduler = TestScheduler(initialClock: 0)
let viewModel = MyViewModel()
let disposeBag = DisposeBag()
let results = scheduler.createObserver(Int.self)
scheduler.scheduleAt(100) {
viewModel.viewControllerTransition
.map { $0.rawValue }
.emit(to: results)
.disposed(by: disposeBag)
}
scheduler.scheduleAt(200) {
viewModel.openSecond()
}
scheduler.start()
let expectedEvents = Recorded.events(
.next(200, ViewControllerTransitionType.push(UIViewController()).rawValue)
)
XCTAssertEqual(results.events, expectedEvents)
}
基本的にはこれまでと同じです。
仮想時間100にsubscribeが発生し、200の時間で画面遷移イベントを発行しています。
ObservableがSignalなので、subscribeした時点ではonNextは流れず、
200時間でopenSecond()
を実行したことで、onNextに.push
が流れ、Int
型のrawValueで比較しています。
ViewControllerTransitionType.push(UIViewController())
の部分で、UIViewControllerのインスタンス生成していますが、あくまで検証対象はrawValueなので、UIViewControllerのインスタンスを比較しているわけではありません。
実際の画面遷移の実装はViewControllerで行っており、あくまでViewModelのテストはenumの値の検証になります。
ViewControllerのテストとなると、ViewControllerの生成や、ライフサイクル、階層構造などテストする上でハードルとなりうる要素が多くなってきます。
こういったViewModelで画面遷移を絡めたテストをシンプルに書けることも、MVVMパターンのメリットだと感じています。
API通信を例とした非同期処理に対するコマンドの呼び出しと状態遷移に対するテスト
API通信などViewModel以外のモジュールと繋がっている場合は、Mockによるテストが実施できる状態にしなければなりません。
例えばItem
というModelを取得するようなAPIモジュールがあった場合、そのprotocolを定義します。
protocol ItemAPIRequestable {
func getItems() -> Single<[Item]>
}
class ItemAPIRequest: ItemAPIRequestable {
func getItems() -> Single<[Item]> {
...
}
}
ViewModelではinit時に外からAPIモジュールを挿入できる形にします。
private var apiRequest: ItemAPIRequestable
convenience init() {
self.init(request: ItemAPIRequest())
}
init(request: ItemAPIRequestable) {
self.apiRequest = request
}
次に、ViewControllerから任意のタイミングでイベントを発火させるためのメソッドを用意します。
private let itemsSubject = BehaviorRelay<[Item]>(value: [])
var items: Driver<[Item]> {
return itemsSubject.asDriver(onErrorJustReturn: [])
}
func onViewDidLoadCompleted() -> Completable {
return fetchItems()
}
private func fetchItems() -> Completable {
return apiRequest.getItems()
.do(
onSuccess: { [weak self] response in
/// データをクリア
self?.itemsSubject.accept([])
/// 新しいデータを追加
self?.itemsSubject.accept(response.data)
},
onError: { [weak self] error in
print("Error: \(error)")
}
)
.asCompletable()
}
ViewControllerではviewDidLoadでonViewDidLoadCompleted
を呼び出し、UITableView
などにitems
がbindされるようなイメージです。
こちらをテストするコードは以下のようになります。
class ItemAPIRequestMock: ItemAPIRequestable {
func getItems() -> Single<[Item]> {
let testData = [
Item(id: 1, name: "Apple", category: "Fruit", price: 100),
Item(id: 2, name: "Orange", category: "Fruit", price: 200),
Item(id: 3, name: "Banana", category: "Fruit", price: 300)
]
return Observable.of(testData).asSingle()
}
}
func testOnViewDidLoadCompleted() {
let scheduler = TestScheduler(initialClock: 0)
let itemAPIRequestMock = ItemAPIRequestMock()
let viewModel = MyViewModel(request: itemAPIRequestMock)
let disposeBag = DisposeBag()
let results = scheduler.createObserver(Int.self)
scheduler.scheduleAt(100) {
viewModel.items
.map { $0.count }
.drive(results)
.disposed(by: disposeBag)
}
scheduler.scheduleAt(200) {
_ = try? viewModel.onViewDidLoadCompleted().toBlocking().first()
}
scheduler.start()
let expectedEvents = Recorded.events(
.next(100, 0),
.next(200, 0),
.next(200, 3)
)
XCTAssertEqual(results.events, expectedEvents)
}
ここでは、200の時間のonViewDidLoadCompleted
の呼び出しですが、
toBlocking
はRxBlocking
のextensionです。
非同期メソッドを実行する場合、これがないと非同期処理の完了まで待たずに次のステップに移ってしまいます。
toBlocking
をつけることによって、Observableのシーケンスが進むまでスレッドをブロックします。
first()
は最初にonNextで流れてきた要素を返します。
Operatorは他にもあります。詳しくは公式ドキュメントを確認して頂くのがいいと思います。
検証対象はitems
の件数をテストします。
Observableのシーケンスとしては、3つのonNextが呼ばれます。
Driver
なのでsubscribeした時点(時間100)ではまだ0件です。
次に、200の時間でonViewDidLoadCompleted()
が呼び、Mockが値を返します。
値を返したあとは一度ViewModel内でitemsをクリアしていますので、0が取得でき、同じ時間で3件が取得できるという結果になります。
最後に
4つの実装例を使ってテスタブルなViewModel設計について解説していきました。
ViewModelを実装していく段階で、どうやったらテストが書けるかなと考えながら進めていくと、結果としてきれいなコードになっていくと感じています。
また今回触れていない、TestScheduler
のcreateHotObservable
、createColdObservable
については、こちらの記事が参考になると思います。
RxTest、RxBlockingによるテストパターン