Upgrade to Pro — share decks privately, control downloads, hide ads and more …

RxTest/RxBlockingテストパターン / RxTest RxBlocking Te...

Takehiro Kaneko
September 17, 2018

RxTest/RxBlockingテストパターン / RxTest RxBlocking Test Patterns

iOSDC 2018 Reject Conferenceで行ったトークのスライドです。
RxTest、RxBlockingというライブラリの概要と、これらを使ったRxなコードのテストの基本的な書き方を、ViewModelとAPIクライアントという2つのクラスを題材に解説しています。

関連記事をQiitaにて公開しています。
https://qiita.com/takehilo/items/09f4a3077e441e5bb9de

iOSDC 2018 Reject Conference days1
https://iosdc-reject-conference.connpass.com/event/93314/

Takehiro Kaneko

September 17, 2018
Tweet

More Decks by Takehiro Kaneko

Other Decks in Programming

Transcript

  1. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ class LoginViewModel { let email = BehaviorRelay<String>(value: "") let

    password = BehaviorRelay<String>(value: "") var isValidForm: Driver<Bool> { return Driver.combineLatest( email.asDriver().map { isValidEmail($0) }, password.asDriver().map { isValidPassword($0) } ) .map { $0 && $1 } } }
  2. class LoginViewModel { let email = BehaviorRelay<String>(value: "") let password

    = BehaviorRelay<String>(value: "") var isValidForm: Driver<Bool> { return Driver.combineLatest( email.asDriver().map { isValidEmail($0) }, password.asDriver().map { isValidPassword($0)} ) .map { $0 && $1 } } } ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ ϏϡʔͷTextFieldΛόΠϯυ͢ΔͨΊͷϓϩύςΟ ϝʔϧΞυϨεͱύεϫʔυͷঢ়ଶΛ࣋ͭ
  3. class LoginViewModel { let email = BehaviorRelay<String>(value: "") let password

    = BehaviorRelay<String>(value: "") var isValidForm: Driver<Bool> { return Driver.combineLatest( email.asDriver().map { isValidEmail($0) }, password.asDriver().map { isValidPassword($0) } ) .map { $0 && $1 } } } ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ TextFieldʹจࣈྻ͕ೖྗ͞ΕΔͨͼʹόϦσʔγϣϯνΣοΫΛߦ͏ ϝʔϧΞυϨεͱύεϫʔυڞʹ༗ޮͳϑΥʔϚοτͰ͋Ε͹trueͱ͢Δ Ϗϡʔ͸͜ͷϓϩύςΟΛαϒεΫϥΠϒ͠ɺόϦσʔγϣϯ݁ՌΛϏϡʔ ʹ൓ө͢ΔʢϩάΠϯϘλϯͷ༗ޮ/ແޮΛ੾Γସ͑Δͱ͔ʣ
  4. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "[email protected]"), Recorded.next(50, "a") ]) let xs2 = scheduler.createHotObservable([ Recorded.next(20, "p"), Recorded.next(40, "passw0rd"), Recorded.next(60, "p") ]) xs1.bind(to: loginViewModel.email).disposed(by: disposeBag) xs2.bind(to: loginViewModel.password).disposed(by: disposeBag) let observer = scheduler.createObserver(Bool.self) loginViewModel.isValidForm.drive(observer).disposed(by: disposeBag) scheduler.start() expect(observer.events).to(equal([ Recorded.next(0, false), Recorded.next(10, false), Recorded.next(20, false), Recorded.next(30, false), Recorded.next(40, true), Recorded.next(50, false), Recorded.next(60, false) ])) }
  5. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "[email protected]"), Recorded.next(50, "a") ]) let xs2 = scheduler.createHotObservable([ Recorded.next(20, "p"), Recorded.next(40, "passw0rd"), Recorded.next(60, "p") ]) xs1.bind(to: loginViewModel.email).disposed(by: disposeBag) xs2.bind(to: loginViewModel.password).disposed(by: disposeBag) ...
  6. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "[email protected]"), Recorded.next(50, "a") ]) let xs2 = scheduler.createHotObservable([ Recorded.next(20, "p"), Recorded.next(40, "passw0rd"), Recorded.next(60, "p") ]) xs1.bind(to: loginViewModel.email).disposed(by: disposeBag) xs2.bind(to: loginViewModel.password).disposed(by: disposeBag) ... TestSchedulerΠϯελϯε
  7. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "[email protected]"), Recorded.next(50, "a") ]) let xs2 = scheduler.createHotObservable([ Recorded.next(20, "p"), Recorded.next(40, "passw0rd"), Recorded.next(60, "p") ]) xs1.bind(to: loginViewModel.email).disposed(by: disposeBag) xs2.bind(to: loginViewModel.password).disposed(by: disposeBag) ... Ծ૝࣌ࠁ10,30,50ʹͦΕͧΕࢦఆͨ͠จࣈྻΛൃߦ͢ ΔHotObservableΛੜ੒ ϢʔβͷϝʔϧΞυϨεೖྗΛΤϛϡϨʔτ͢Δ΋ͷ
  8. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "[email protected]"), Recorded.next(50, "a") ]) let xs2 = scheduler.createHotObservable([ Recorded.next(20, "p"), Recorded.next(40, "passw0rd"), Recorded.next(60, "p") ]) xs1.bind(to: loginViewModel.email).disposed(by: disposeBag) xs2.bind(to: loginViewModel.password).disposed(by: disposeBag) ... Ծ૝࣌ࠁ20,40,60ʹͦΕͧΕࢦఆͨ͠จࣈྻΛൃߦ͢ ΔHotObservableΛੜ੒ ϢʔβͷύεϫʔυೖྗΛΤϛϡϨʔτ͢Δ΋ͷ
  9. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "[email protected]"), Recorded.next(50, "a") ]) let xs2 = scheduler.createHotObservable([ Recorded.next(20, "p"), Recorded.next(40, "passw0rd"), Recorded.next(60, "p") ]) xs1.bind(to: loginViewModel.email).disposed(by: disposeBag) xs2.bind(to: loginViewModel.password).disposed(by: disposeBag) ... ੜ੒ͨ͠ObservableΛViewModelͷemailͱpassword ϓϩύςΟʹόΠϯυ͢Δ
  10. ... let observer = scheduler.createObserver(Bool.self) loginViewModel.isValidForm.drive(observer).disposed(by: disposeBag) scheduler.start() expect(observer.events).to(equal([ Recorded.next(0,

    false), Recorded.next(10, false), Recorded.next(20, false), Recorded.next(30, false), Recorded.next(40, true), Recorded.next(50, false), Recorded.next(60, false) ])) } ΦϒβʔόΛ࡞੒͠ɺViewModelͷisValidFormʹαϒεΫ ϥΠϒͤ͞Δ
  11. ... let observer = scheduler.createObserver(Bool.self) loginViewModel.isValidForm.drive(observer).disposed(by: disposeBag) scheduler.start() expect(observer.events).to(equal([ Recorded.next(0,

    false), Recorded.next(10, false), Recorded.next(20, false), Recorded.next(30, false), Recorded.next(40, true), Recorded.next(50, false), Recorded.next(60, false) ])) } ͜͜·Ͱ͸ࣄલ४උ start()ϝιουΛݺͼग़͢͜ͱͰɺԾ૝͕࣌ؒ։࢝͞ΕΔ
  12. ... let observer = scheduler.createObserver(Bool.self) loginViewModel.isValidForm.drive(observer).disposed(by: disposeBag) scheduler.start() expect(observer.events).to(equal([ Recorded.next(0,

    false), Recorded.next(10, false), Recorded.next(20, false), Recorded.next(30, false), Recorded.next(40, true), Recorded.next(50, false), Recorded.next(60, false) ])) } Φϒβʔόʹه࿥͞Ε͍ͯΔΠϕϯτΛݕূ͢Δ ϝʔϧΞυϨεͱύεϫʔυ͕ڞʹ༗ޮͳϑΥʔϚοτʹ ͳΔ࣌ࠁ40ͷΈtrueʹͳΔ ࣌ࠁ FNBJM QBTTXPSE ݁Ռ  '"-4&  B '"-4&  B Q '"-4&  B!FYBNQMFDPN Q '"-4&  B!FYBNQMFDPN QBTTXSE 536&  B QBTTXSE '"-4&  B Q '"-4&
  13. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ class UserService { func fetchUser(by userId: Int) -> Single<User>

    { return Single.create { single in Alamofire.request("https://api.example.com/users/\(userId)") .responseData { response in switch response.result { case let .success(data): do { let user = try JSONDecoder().decode(User.self, from: data) single(.success(user)) } catch { single(.error(error)) } case let .failure(error): single(.error(error)) } } return Disposables.create() } } }
  14. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ class UserService { func fetchUser(by userId: Int) -> Single<User>

    { return Single.create { single in Alamofire.request("https://api.example.com/users/\(userId)") .responseData { response in switch response.result { case let .success(data): do { let user = try JSONDecoder().decode(User.self, from: data) single(.success(user)) } catch { single(.error(error)) } case let .failure(error): single(.error(error)) } } return Disposables.create() } } } GETϦΫΤετΛૹ৴͢Δ ඇಉظʹΠϕϯτ͕ൃߦ͞ΕΔ
  15. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ class UserService { func fetchUser(by userId: Int) -> Single<User>

    { return Single.create { single in Alamofire.request("https://api.example.com/users/\(userId)") .responseData { response in switch response.result { case let .success(data): do { let user = try JSONDecoder().decode(User.self, from: data) single(.success(user)) } catch { single(.error(error)) } case let .failure(error): single(.error(error)) } } return Disposables.create() } } } ϨεϙϯεσʔλΛUserܕʹσίʔυ͢Δ
  16. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ class UserService { func fetchUser(by userId: Int) -> Single<User>

    { return Single.create { single in Alamofire.request("https://api.example.com/users/\(userId)") .responseData { response in switch response.result { case let .success(data): do { let user = try JSONDecoder().decode(User.self, from: data) single(.success(user)) } catch { single(.error(error)) } case let .failure(error): single(.error(error)) } } return Disposables.create() } } } ໭Γ஋͸Singleܕ
  17. beforeEach { userService = UserService() let userJson = "{\"id\": 1,

    \"name\": \"test-user\"}" self.stub( uri("https://api.example.com/users/1"), jsonData(userJson.data(using: .utf8)!) ) } it("should fetch an user") { let expectedUser = User(id: 1, name: "test-user") let user = try! userService.fetchUser(by: 1).toBlocking().single() expect(user).to(equal(expectedUser)) }
  18. beforeEach { userService = UserService() let userJson = "{\"id\": 1,

    \"name\": \"test-user\"}" self.stub( uri("https://api.example.com/users/1"), jsonData(userJson.data(using: .utf8)!) ) } it("should fetch an user") { let expectedUser = User(id: 1, name: "test-user") let user = try! userService.fetchUser(by: 1).toBlocking().single() expect(user).to(equal(expectedUser)) } ࢦఆͨ͠URLʹϦΫΤετ͕ൃߦ͞ΕͨΒɺuserJsonจࣈྻΛϨε ϙϯεͱͯ͠ฦ͢Α͏ʹࢦఆ
  19. beforeEach { userService = UserService() let userJson = "{\"id\": 1,

    \"name\": \"test-user\"}" self.stub( uri("https://api.example.com/users/1"), jsonData(userJson.data(using: .utf8)!) ) } it("should fetch an user") { let expectedUser = User(id: 1, name: "test-user") let user = try! userService.fetchUser(by: 1).toBlocking().single() expect(user).to(equal(expectedUser)) } toBlocking()ͰBlockingObservableʹม׵
  20. beforeEach { userService = UserService() let userJson = "{\"id\": 1,

    \"name\": \"test-user\"}" self.stub( uri("https://api.example.com/users/1"), jsonData(userJson.data(using: .utf8)!) ) } it("should fetch an user") { let expectedUser = User(id: 1, name: "test-user") let user = try! userService.fetchUser(by: 1).toBlocking().single() expect(user).to(equal(expectedUser)) } ඇಉظΠϕϯτ͕ൃߦ͞ΕΔ·ͰεϨουΛϒϩοΫ͠ɺ ΠϕϯτͷཁૉʢUserΠϯελϯεʣΛऔΓग़͢
  21. BlockingObservableͷΦϖϨʔλ • ڞ௨ͷڍಈ: Observable͕completed·ͨ͸errorΛൃߦ͢Δ·ͰΧϨϯτε ϨουΛϒϩοΫ͠ɺड৴ͨ͠ΠϕϯτͷཁૉΛه࿥͢Δ • materialize(): completed/failedέʔεΛ࣋ͭEnumΛฦ͢ • first()/last():

    ࠷ॳ/࠷ޙͷཁૉΛฦ͢ɻerrorͷ৔߹͸ྫ֎Λεϩʔ͢Δ • toArray(): શͯͷཁૉΛฦ͢ɻerrorͷ৔߹͸ྫ֎Λεϩʔ͢Δ • single(): ࠷ॳͷཁૉΛฦ͢ɻ2ͭҟৗͷΠϕϯτΛड৴͍ͯͨ͠৔߹͸ྫ֎ Λεϩʔ͢Δ
  22. TestableObservableͱtoBlocking()͸ซ༻Ͱ͖ͳ͍ it("Don't do this") { let xs = scheduler.createHotObservable([ Recorded.next(110,

    10), Recorded.next(210, 20), Recorded.next(310, 30), Recorded.completed(40) ]) scheduler.start() expect(try! xs.toBlocking().toArray()).to(equal([10, 20, 30])) } ϒϩοΫ͞Εͨ··ʹͳΓςετ͕ऴྃ͠ͳ͍
  23. ศརͳΤΫεςϯγϣϯ extension Recorded: Equatable where Value: Equatable {} extension Event:

    Equatable where Element: Equatable {} NimbleͷexpectͰΠϕϯτͷݕূ͕͠΍͘͢ͳΔʂ https://github.com/Quick/Nimble/issues/523