14
16

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.

iOSにおけるテスタブルなViewModel設計/ Designing Testable ViewModel in iOS

Posted at

はじめに

多くの開発現場では、日々成長しているアプリのコードをどうやってメンテナンス性が高く、安全で品質の高いものにしていくかが重要な課題になっているかと思います。
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が以下のように定義されている場合、

ViewModel
private var myTitleSubject = BehaviorRelay(value: "MyTitle")
var myTitle: Driver<String> {
    return myTitleSubject.asDriver()
}

ユニットテストのコードは以下のようになります。

Test
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)
}
  1. RxTestimportし、TestSchedulerを用います。
    TestSchedulerは仮想時間という概念をもっており、0 -- 100 -- 200 -- ... 1000のような数字で、Rxのストリームに流れるデータと時間を定義していきます。 initialClockは初期状態の時間です。
  2. TestScheduler#createObserverTestableObservable<T>を作成します。 今回は評価対象がString型なので、String.selfを指定します。    
  3. TestScheduler#scheduleAtでスケジュールを追加します。
    適当に時間を100と決め、subscribeを行います。
  4. TestScheduler#startでスケジューラーを開始します。
  5. Recorded.eventsでObservableに流れるイベントのシーケンスを予測した結果を作成します。 今回はDriverつまり、BehaviorSubjectタイプなので、subscribeした時点で最後の値がreplayします。
       subscribeを仮想の時間100のときに行っていますので、onNextが時間100のときに流れ、Stringの値が取れるという想定になります。
  6. 値の検証は、通常通りXCTAssertEqualを使って、2で作成したTestableObservable#resultと予測結果を比較します。

TextFieldの入力値に応じたValidationプロパティに対するテスト

例として、passwordというプロパティと、それが8桁以上の文字列を入力した場合のみ有効と判定するValidationプロパティをViewModelが持っているとして、

ViewModel
var password = BehaviorRelay<String?>(value: nil)
lazy var passwordValid: Driver<Bool> = {
    return password
        .map { ($0 ?? "").count >= 8 }
        .asDriver(onErrorJustReturn: false)
}()

これに対するテストは以下のようになります。

Test
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を定義しているのはテストではこちらを用いて比較しやすくするためです。

ViewModel
enum ViewControllerTransitionType {
    case push(UIViewController)
    case modal(UIViewController)

    var rawValue: Int {
        switch self {
        case .push: return 0
        case .modal: return 1
        }
    }
}

ViewModelではこちらを用いて画面遷移を実装します。
Signalとして公開し、イベントを発火させるためのopenSecond()というメソッドを定義しました。

ViewModel
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()を呼び出すといった形になります。

ViewController
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)

こちらをテストするコードは以下のようになります。

Test
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モジュールを挿入できる形にします。

ViewModel
private var apiRequest: ItemAPIRequestable

convenience init() {
    self.init(request: ItemAPIRequest())
}

init(request: ItemAPIRequestable) {
    self.apiRequest = request
}

次に、ViewControllerから任意のタイミングでイベントを発火させるためのメソッドを用意します。

ViewModel

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されるようなイメージです。

こちらをテストするコードは以下のようになります。

Test
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の呼び出しですが、
toBlockingRxBlockingのextensionです。
非同期メソッドを実行する場合、これがないと非同期処理の完了まで待たずに次のステップに移ってしまいます。
toBlockingをつけることによって、Observableのシーケンスが進むまでスレッドをブロックします。
first()は最初にonNextで流れてきた要素を返します。
Operatorは他にもあります。詳しくは公式ドキュメントを確認して頂くのがいいと思います。

検証対象はitemsの件数をテストします。
Observableのシーケンスとしては、3つのonNextが呼ばれます。
Driverなのでsubscribeした時点(時間100)ではまだ0件です。
次に、200の時間でonViewDidLoadCompleted()が呼び、Mockが値を返します。
値を返したあとは一度ViewModel内でitemsをクリアしていますので、0が取得でき、同じ時間で3件が取得できるという結果になります。

最後に

4つの実装例を使ってテスタブルなViewModel設計について解説していきました。
ViewModelを実装していく段階で、どうやったらテストが書けるかなと考えながら進めていくと、結果としてきれいなコードになっていくと感じています。

また今回触れていない、TestSchedulercreateHotObservablecreateColdObservableについては、こちらの記事が参考になると思います。
RxTest、RxBlockingによるテストパターン

14
16
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
14
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?