RxTestを使ったUI層のテスト

  • 12
    Like
  • 0
    Comment

はじめに

RxSwiftを最近触り始めたので、自分なりのUI層の設計とテストについて書きます。

RxSwiftのバージョンは3.0.1です。

基本的な設計

ui.001.jpeg

  • ViewController
    • UserEventの通知
    • UIの反映
  • Presenter
    • ViewControllerからUserEventを受け取る
    • ViewModelに取得したデータを反映
    • ViewControllerにUI反映の通知
  • ViewModel
    • 取得したデータを保持

実装

ViewController

// ViewController.swift

import UIKit
import RxSwift
import RxCocoa

class ViewControlller: UIViewController {

    // MARK: - Outlet

    @IBOutlet private weak var tableView: UITableView!

    // MARK: - Property

    var presenter: Presenter!
    private let dataSource = DataSource()
    private let disposeBag = DisposeBag()

    // MARK: - LifeCycle

    override func viewDidLoad() {
        super.viewDidLoad()

        setupUserEventBindings()
        setupDrawingBindings()
    }

    // MARK: - Private

    private func setupUserEventBindings() {
        // 1. UserEvent
        rx.sentMessage(#selector(UIViewController.viewWillAppear(_:)))
            .map { _ in return () }
            .shareReplay(1)
            .bindTo(presenter.refreshTrigger)
            .addDisposableTo(disposeBag)
    }

    private func setupDrawingBindings() {
        // 5, 6. UIの反映
        presenter.refreshTodosTrigger
            .asDriver()
            .drive(self.tableView.rx.items(dataSource: self.dataSource))
            .addDisposableTo(disposeBag)
    }
}

Presenter

// Presenter.swift

import UIKit
import RxSwift

protocol Input: class {
    var refreshTrigger: PublishSubject<Void> { get }
}

protocol Output: class {
    var refreshTodosTrigger: PublishSubject<[Todo]> { get }
}

class Presenter: Input, Output {

    // MARK: - Input

    private(set) var refreshTrigger = PublishSubject<Void>()

    // MARK: - Output

    private(set) var refreshTodosTrigger = PublishSubject<[Todo]>()

    // MARK: - Property

    private let useCase: UseCase

    private var viewModel = ViewModel()
    private let disposeBag = DisposeBag()

    // MARK: - LifeCycle

    init(useCase: UseCase) {
        self.useCase = useCase

        setupBindings()
    }

    // MARK: - Private

    private func setupBindings() {
        // 3. ViewModelに保持してるSubjectでEventを発行
        // viewWillAppearが呼ばれたときにTodo一覧を取得するStream
        refreshTrigger
            .flatMap { [weak self] _ -> Observable<[Todo]> in
                guard let weakSelf = self else {
                    return Observable<[Todo]>.error(PresentationError.caputureFailed)
                }
                return self.useCase.getAll()
            }
            .subscribe(
                onNext: { [weak self] todos in
                    guard let weakSelf = self else {
                        return
                    }
                    self.viewModel.todos.onNext(todos)
                },
                onError: { error in
                    print(error)
                }
            )
            .addDisposableTo(disposeBag)

        // 4. PresenterにBinding
        // Todo一覧を取得した際にTableViewに反映するStream
        viewModel.todos
            .bindTo(refreshTodosTrigger)
            .addDisposableTo(disposeBag)
    }
}

ViewModel

// ViewModel.swift

import Foundation
import RxSwift

struct ViewModel {
    var todos = PublishSubject<[Todo]>()
}

UI層のテスト

いよいよ、メインのテストの話です。

個人的にはUIのテストはUnit Testで保証するのはかなり難しいと考えているので、E2Eテストなどで保証すればいいと考えてます。ですので、ここで取り上げるUI層のテストは主にPresenterのテストになります。

PresenterはProtocolでInput, Outputを定義しているので、Unit TestはInputとOutputの組み合わせを網羅するように書きます。今回はviewWillAppearのタイミングでtableViewにデータを反映するだけですので1組み合わせだけですが...

TestにはQuick, Nimble, RxTestを用います。RxTestのTestSchedulerを用いて、想定するInputを発生させます。

// PresenterSpec.swift

import Quick
import Nimble
import RxSwift
import RxTest

class PresenterSpec: QuickSpec {

    override func spec() {
        describe("Presenter") {
            var presenter: Presenter!
            var scheduler: TestScheduler!

            beforeEach {
                presenter = Mock.container.resolve(Presenter.self)
            }

            context("when will appear") {
                // API RequestをMockしたUseCase
                class MockUseCaseImpl: UseCaseImpl {
                    var xs: TestableObservable<[Todo]>
                    init(repository: Repository, scheduler: TestScheduler) {
                        // Subscribeされてから100経過後にonNextが出力される
                        self.xs = scheduler.createColdObservable([
                            next(100,
                                 [
                                    Todo(title: "RxSwift"),
                                    Todo(title: "Swinject"),
                                    Todo(title: "Quick"),
                                    Todo(title: "Nimble")
                                ])
                            ])
                        super.init(repository: repository)
                    }

                    override func getAll() -> Observable<[Todo]> {
                        // 返り値をMockしている
                        return xs.asObservable()
                    }
                }

                beforeEach {
                    scheduler = TestScheduler(initialClock: 0)
                    // MockしているUseCaseをInjectしている
                    presenter = Presenter(useCase: MockUseCaseImpl(repository: RepositoryImpl(dataStore: DataStore()), scheduler: scheduler))
                }

                it("show todo items in tablew view") {
                    let results = scheduler.createObserver([Todo].self)
                    let disposeBag = DisposeBag()

                    scheduler.scheduleAt(100) {
                        // useCase.getAll()が呼ばれる
                        presenter.refreshTrigger.onNext()
                    }

                    scheduler.scheduleAt(200) {
                        // schedulerで設定したものが呼ばれるのでSubscribeする
                        presenter.refreshTodosTrigger
                            .subscribe(results)
                            .addDisposableTo(disposeBag)
                    }

                    scheduler.start()

                    expect(results.events.count).to(equal(1))
                    expect(results.events[0].time).to(equal(200))
                    expect(results.events[0].value.element?.count).to(equal(4))
                    expect(results.events[0].value.element?[0].title).to(equal("RxSwift"))
                    expect(results.events[0].value.element?[1].title).to(equal("Swinject"))
                    expect(results.events[0].value.element?[2].title).to(equal("Quick"))
                    expect(results.events[0].value.element?[3].title).to(equal("Nimble"))
                }
            }
        }
    }
}

まとめ

RxSwiftを触ってみて自分なりに色々と考えてみましたが、まだまだ理解が足りてないないなといった感じがします。デモのプロジェクトには表示だけではなく、表示・追加・編集・削除の機能を実装してますので、もう少し複雑ですので興味を持っていただいたら見てみてください。
https://github.com/Nonchalant/Rx_Demo