はじめに
RxSwiftを最近触り始めたので、自分なりのUI層の設計とテストについて書きます。
RxSwiftのバージョンは3.0.1です。
基本的な設計
- 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