test
iOS
Swift
Quick
RxSwift

RxSwiftでのTest事例


なぜテストを書くのか?


  • コードレビューの効率化

  • 密結合から疎結合になり、破壊的な設計を防止

  • デバッグ時間の短縮

  • バグ & デグレの早期発見 また 発生箇所の特定の短縮

  • テームの設計力の向上


結論

開発効率が向上!?


Quick, RSpec, Better Specs

Quickとは

The Swift (and Objective-C) testing framework.

Quick is a behavior-driven development framework for Swift and Objective-C. Inspired by RSpec, Specta, and Ginkgo.

→ Swiftのテストフレームワーク

RSpecとは

RSpec is a behavior-driven development framework for Ruby.

→ Rubyのテストフレームワーク

BDDとは

Behaviour-Driven Development (BDD) is an evolution in the thinking behind TestDrivenDevelopment and AcceptanceTestDrivenPlanning.

→ テスト駆動開発の派生型(可読性が高い、仕様とテストが同一になりやすい)

Better Specsとは

RSpecで良いテストの書き方を説明したもの

Nimbleとは

Nimble is A Matcher Framework for Swift and Objective-C


  • 自然な英文のようなAPIを提供

  • 失敗時のエラーがわかりやすい

Quickでテストコードを構造化して

Nimbleで期待通りの動作をしているかのテストを書きやすく書く!


結論

RSpecに影響されたQuickでテストを書くなら、Better Specsにならって書くことでテストコードの可読性が上がるはず!!

Quickでテストコードを構造化して、Nimbleで期待通りの動作をしているかのテストを書きやすく書く!!


Quick版 Better Specs

Better Specsについて説明していく!

http://www.betterspecs.org/jp/

途中で出てくるメソッドやクラスは簡略化して書いているので、動くコードではありません。ニュアンスが伝わる程度です。


メソッドの説明をする

作成中のメソッドを明らかにしましょう。


BAD.swift

describe("the authenticate class") { }

describe("the authenticate method for User") { }



GOOD.swift

describe("Authenticate") { }

describe("authenticate()") { }



Contextsを使う

Contextsはテストを構造化にして、まとめる素晴らしい方法です。

長い目で見ると、この方法はテストを読みやすく、書きやすくなる。


BAD.swift

it("has 200 status code if logged in") {

APIStub.add(requestEndpoint)
expect(user.login.status).to(equal(.success))
}

it("has 401 status code if not logged in") {
APIStub.add(requestEndpoint, http(401))
expect(user.login.status).to(equal(.failed))
}



GOOD.swift

context("logged in"){

context("success") {
beforEach {
APIStub.add(requestEndpoint, jsonData(data))
}
it("status") {
expect(user.login.status).to(equal(.success))
}
}
context("faild") {
beforEach {
APIStub.add(requestEndpoint, http(401))
}
it("status") {
expect(user.login.status).to(equal(.failed))
}
}
}


説明を短く

specの説明は40文字を超えないようにしましょう。超えた場合はcontextを分けてください。


BAD.swift

it("has 422 status code if an unexpected params will be added") { }



GOOD.swift

context("when not valid") {

it("satus 422") { }
}


単一条件テスト

単一条件各テストは一つだけ確認すべき という表現でより広く知られています。 これはエラーを探しやすくし、失敗するテストをすぐ見つけるようにし、コードを読みやすくします。

独立したユニットでは、各例はただ一つの振る舞いだけテストするのが望ましいです。例の中で多数のテストが有るのは幾つかの振る舞いに分離する必要があることを示しています。

しかし、分離できないテストで(例えばDBや外部システムとの連動、前後があるテストの場合)分離するだけでは同じセットアップを何回も行い、テストが重くなる現象が現れます。こういった重いテストは分けなくても良いでしょう。


GOOD_ISOLATED.swift

it("first load") {

expect(results.count).to(equal(10))
}

it("next load") {
expect(results.count).to(equal(20))
}



GOOD_NOT_ISOLATED.swift

it("first load") { 

expect(results.events.map { $0.value }).to(equal([.success, .failed, .success]))
expect(results.events.map { $0.time }).to(equal([10, 20, 30]))
}


可能な限り全部をテスト

テストはやった方がいいですが、全ケースをテストしないと、有用とはいえません。有効な場合と無効な場合を全部テストしましょう。例えばこんなアクションが有るとしましょう。


Example.swift

protocol TestModelDataProvider: AnyObject {

func getTestModel() -> Observable<API.TestModel>
}


BAD.swift

it("getTestModel()") {

APIStub.add(endpoint, jsonData(data))
let testModel = try? provider.getTestModel().toBlocking().single()
expect(testModel).notTo(beNil())
}


GOOD.swift

context("status 200") {

beforeEach {
APIStub.add(endpoint, jsonData(data))
}
it("getTestModel()") {
let testModel = try? provider.getTestModel().toBlocking().single()
expect(testModel).notTo(beNil())
}
}

context("status 404") {
beforeEach {
APIStub.add(endpoint, http(404))
}
it("getTestModel()") {
let testModel = try? provider.getTestModel().toBlocking().single()
expect(testModel).to(beNil())
}
}

context("status 500") {
beforeEach {
APIStub.add(endpoint, http(500))
}
it("getTestModel()") {
let testModel = try? provider.getTestModel().toBlocking().single()
expect(testModel).to(beNil())
}
}



Subjectを使う

もしも、同じsubjectに対して複数のテストをしていたら、subject{}を使ってDRYしましょう。


GOOD.swift

describe("TestViewModel") {

var providerMock: TestProviderMock!
var viewModel: TestViewModel { // ここがsubjectに相当!!
return TestViewModel(provider: providerMock)
}
context("TestProvider") {
beforeEach {
providerMock = HogeTestProviderMock()
}
it("hogehoge()") {
expect(viewModel.hogehoge()).to(equal("hogehoge"))
}
}
}


結論

BetterSpecにならって書くことで、可読性が高いテストコードが書ける


RxSwift Example


例題①


ViewModel.swift

// inputのtextに対して、10文字以内のときにlabelTextを出力する。

class TestViewModel {

let labelText: Driver<String>

static let maxTextNumber = 10
static let addText = "Good!"

init(text: Observable<String>) {

labelText = text.asDriver(onErrorJustReturn: "")
.filter { $0.count <= TestViewModel.maxTextNumber }
.map { $0 + TestViewModel.addText }
}
}



TestScheduler, TestableObservable, TestableObserver

実時間とは異なる 仮想時間 に基づきイベントを発生させる RxTest が提供するClass


  • TestScheduler


    • 仮想時間に基づいてイベントを発生させることができるスケジューラ。

    • 仮想時刻を指定して任意アクションを実行

    • TestableObservable TestableObserver のファクトリメソッドを持つ



  • TestableObservable


    • 仮想時間にイベントが発生するObservable



  • TestableObserver


    • 受信したイベントを仮想時間とともに保持するObserver



  • Recorded


    • 生成されたときの、仮想時間と値の記録している

    • TestSchedulerからRecordedを用いてTestableObservableを作成可能

    • TestableObserverには、Recordedが保持されている




例題①のTEST1


TestViewModelSpec.swift

final class TestViewModelSpec: QuickSpec {

override func spec() {
describe("TestViewModel") {
var scheduler: TestScheduler!
var disposeBag: DisposeBag!
var inputText: PublishRelay<String>!

var viewModel: TestViewModel! { // subject
return TestViewModel(text: inputText.asObservable())
}

context("output") {
beforeEach {
// タイムを0からスタートしたいため、毎回スケジューラーを生成する。
scheduler = TestScheduler(initialClock: 0)
// 1つ前のテストで使用している不要な購読を削除する。
disposeBag = DisposeBag()

// ① schedulerを使って、inputにいつ、どんな値を流すかを決定したTestableObservable<String>を作成して、inputにbindさせる
inputText = PublishRelay<String>()
scheduler.createColdObservable([.next(10, ""),
.next(20, "a"),
.next(30, "abc"),
.next(40, "abcdefghi"),
.next(50, "abcdefghigklmn"),
.next(60, "a")])
.bind(to: inputText)
.disposed(by: disposeBag)
}

it("labelText") {
// ② viewModel.labelTextの結果を受け取るTestObserverを作成
let labelText = scheduler.createObserver(String.self)

// viewModel.labelTextに②で作成した、ObserverをBindして結果を監視する
viewModel.labelText
.drive(labelText)
.disposed(by: disposeBag)

// --- ここまででlabelTextのテストの用意完了! ---

// スケジューラーを開始させて①で作成したinputに値を流す
scheduler.start()

// Nimbleを使って値が合っているかをチェック
expect(labelText.events).to(equal([
.next(10, "GOOD!"),
.next(20, "aGOOD!"),
.next(30, "abcGOOD!"),
.next(40, "abcdefghiGOOD!"),
.next(60, "aGOOD!"),
]
))
}
}
}
}
}


Rxのテストはテストを書くのが結構大変!!😱

→ Extensionを使って簡単に書けるようにしていこう!!


例題①のTEST2

いちいちViewModelのアウトプットとBindしたテスト用のObservableの作成するのがめんどくさい!!


TestSchedulerExtention.swift

extension TestScheduler {

/// 特定のオブザーバーとバインドしたテストオブザーバーを作成する。
/// disposeの設定を行う。
///
/// - Parameter source: テストオブザーバーを作成したいObservableConvertibleType
/// - Returns: sourceの値を返すテストオブザーバー
func record<O: ObservableConvertibleType>(source: O) -> TestableObserver<O.E> {
let observer = createObserver(O.E.self)
let disposable = source.asObservable().bind(to: observer)
scheduleAt(100000) {
disposable.dispose()
}
return observer
}
}



TestViewModelSpec.swift

final class TestViewModelSpec: QuickSpec {

override func spec() {
describe("TestViewModel") {
var scheduler: TestScheduler!
var disposeBag: DisposeBag!
var inputText: PublishRelay<String>!

var viewModel: TestViewModel! { // subject
return TestViewModel(text: inputText.asObservable())
}

context("output") {
beforeEach {
// タイムを0からスタートしたいため、毎回スケジューラーを生成する。
scheduler = TestScheduler(initialClock: 0)
// 1つ前のテストで使用している不要な購読を削除する。
disposeBag = DisposeBag()

// ① schedulerを使って、inputにいつ、どんな値を流すかを決定したObservableを作って、inputにbindさせる
inputText = PublishRelay<String>()
scheduler.createColdObservable([.next(10, ""),
.next(20, "a"),
.next(30, "abc"),
.next(40, "abcdefghi"),
.next(50, "abcdefghigklmn"),
.next(60, "a")])
.bind(to: inputText)
.disposed(by: disposeBag)
}

it("labelText") {
// 特定のオブザーバーとバインドしたTestObserverを作成する。
let labelText = scheduler.record(source: viewModel.labelText)

// --- ここまででlabelTextのテストの用意完了! ---

// スケジューラーを開始させて①で作成したinputに値を流す
scheduler.start()

// Nimbleを使って値が合っているかをチェック
expect(labelText.events).to(equal([
.next(10, "GOOD!"),
.next(20, "aGOOD!"),
.next(30, "abcGOOD!"),
.next(40, "abcdefghiGOOD!"),
.next(60, "aGOOD!"),
]
))
}
}
}
}
}


結果を受け取るTestObserverの作成が1行で書けるようになった!!


例題①のTEST3

input用とexpectでの検証がめんどくさい!!


TestSchedulerExtention.swift

extension TestScheduler {

func parseEventsAndTimes<T>(timeline: String, values: [String: T], errors: [String: Swift.Error] = [:]) -> [[Recorded<Event<T>>]] {
//print("parsing: \(timeline)")
typealias RecordedEvent = Recorded<Event<T>>

let timelines = timeline.components(separatedBy: "|")

let allExceptLast = timelines[0 ..< timelines.count - 1]

return (allExceptLast.map { $0 + "|" } + [timelines.last!])
.filter { $0.count > 0 }
.map { timeline -> [Recorded<Event<T>>] in
let segments = timeline.components(separatedBy:"-")
let (time: _, events: events) = segments.reduce((time: 0, events: [RecordedEvent]())) { state, event in
let tickIncrement = event.count + 1

if event.count == 0 {
return (state.time + tickIncrement, state.events)
}

if event == "#" {
let errorEvent = RecordedEvent(time: state.time, value: Event<T>.error(NSError(domain: "Any error domain", code: -1, userInfo: nil)))
return (state.time + tickIncrement, state.events + [errorEvent])
}

if event == "|" {
let completed = RecordedEvent(time: state.time, value: Event<T>.completed)
return (state.time + tickIncrement, state.events + [completed])
}

guard let next = values[event] else {
guard let error = errors[event] else {
fatalError("Value with key \(event) not registered as value:\n\(values)\nor error:\n\(errors)")
}

let nextEvent = RecordedEvent(time: state.time, value: Event<T>.error(error))
return (state.time + tickIncrement, state.events + [nextEvent])
}

let nextEvent = RecordedEvent(time: state.time, value: Event<T>.next(next))
return (state.time + tickIncrement, state.events + [nextEvent])
}

//print("parsed: \(events)")
return events
}
}
}



TestViewModelSpec.swift

final class TestViewModelSpec: QuickSpec {

override func spec() {
describe("TestViewModel") {
var scheduler: TestScheduler!
var disposeBag: DisposeBag!

var inputText: [Recorded<Event<String>>]!

var viewModel: TestViewModel! { // subject
return TestViewModel(text: scheduler.createHotObservable(inputText).asObservable())
}

context("output") {

var expectOutputText: [Recorded<Event<String>>]!

let inputString = [
"s1" : "a",
"s2" : "abc",
"s3" : "abcdefghigklmn",
"e" : ""
]

let expectString = [
"e1" : "a" + TestViewModel.addText,
"e2" : "abc" + TestViewModel.addText,
"e3" : "" + TestViewModel.addText
]

beforeEach {
scheduler = TestScheduler(initialClock: 0)
disposeBag = DisposeBag()

(
inputText,
expectOutputText
) =
(
scheduler.parseEventsAndTimes(timeline: "e---s1---s2---s3---s1---", values: inputString).first!,
scheduler.parseEventsAndTimes(timeline: "e3--e1---e2--------e1---", values: expectString).first!
)
}

it("labelText") {
let labelText = scheduler.record(source: viewModel.labelText)

scheduler.start()

expect(labelText.events).to(equal(expectOutputText))
}
}
}
}
}


inputを流すTestObservableが直感的にかけ、可読性が向上した!!


例題②

outputが増えたとき!!


TestViewModel.swift

// inputのtextに対して、labelTextとerrorTextを出力する

// eroorは10文字以上入力されたときに、alertを出力する
class TestViewModel {

let labelText: Driver<String>
let alertTrigger: Driver<Void>

static let maxTextNumber = 10
static let addText = "Good!"

init(text: Observable<String>) {

let textDriver = text.asDriver(onErrorJustReturn: "")

labelText = textDriver
.filter { $0.count <= TestViewModel.maxTextNumber }
.map { $0 + TestViewModel.addText }

alertTrigger = textDriver
.filter { $0.count > TestViewModel.maxTextNumber }
.map { _ in () }
}
}



例題② TEST


TestViewModelSpec.swift

final class TestViewModelSpec: QuickSpec {

override func spec() {
describe("TestViewModel") {
var scheduler: TestScheduler!
var disposeBag: DisposeBag!

var inputText: [Recorded<Event<String>>]!

var viewModel: TestViewModel! { // subject
return TestViewModel(text: scheduler.createHotObservable(inputText).asObservable())
}

context("output") {

var expectOutputText: [Recorded<Event<String>>]!
var expectOutputAlert: [Recorded<Event<Void>>]!

let inputString = [
"s1" : "a",
"s2" : "abc",
"s3" : "abcdefghigklmn",
"e" : ""
]

let expectString = [
"e1" : "a" + TestViewModel.addText,
"e2" : "abc" + TestViewModel.addText,
"e3" : "" + TestViewModel.addText
]

let expectAlert = [
"a" : ()
]

beforeEach {
scheduler = TestScheduler(initialClock: 0)
disposeBag = DisposeBag()

(
inputText,
expectOutputText,
expectOutputAlert
) =
(
scheduler.parseEventsAndTimes(timeline: "e---s1---s2---s3---s1---", values: inputString).first!,
scheduler.parseEventsAndTimes(timeline: "e3--e1---e2--------e1---", values: expectString).first!,
scheduler.parseEventsAndTimes(timeline: "--------------a---------", values: expectAlert).first!
)
}

it("labelText") {
let labelText = scheduler.record(source: viewModel.labelText)

scheduler.start()

expect(labelText.events).to(equal(expectOutputText))
}

it("alertTrigger") {
let alertTrigger = scheduler.record(source: viewModel.alertTrigger)

scheduler.start()

expect(alertTrigger.events.map { $0.time }).to(equal(expectOutputAlert.map { $0.time }))
}
}
}
}
}



結論

Quickで構造化することと、Extensionを使うことでRx周りのテストを書きやすくする!!


参考

https://github.com/ReactiveX/RxSwift/blob/master/Documentation/UnitTests.md

https://github.com/ReactiveX/RxSwift/blob/master/RxExample/RxExample-iOSTests/RxExample_iOSTests.swift

https://speakerdeck.com/yusukehosonuma/quick-nimble-more-useful

https://qiita.com/tasanobu/items/6672d6d4d1024d97c82a

https://speakerdeck.com/rockname/rspec-like-test-in-swift

https://qiita.com/Kuniwak/items/4314451227f4d5eaa6b8