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

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

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/

1da8b476058df860d83a12c496b74fff?s=128

Takehiro Kaneko

September 17, 2018
Tweet

Transcript

  1. RxTest/RxBlocking ςετύλʔϯ iOSDC 2018 Reject Conference days1 2018/09/18 @takehilo_kaneko ͲͪΒ͔ͱ͍͏ͱ

    ॳ৺ऀ޲͚
  2. ࣗݾ঺հ ۚࢠ ༤େ @takehilo_kaneko 1೥ऑ https://qiita.com/takehilo

  3. None
  4. ͜ͷτʔΫͷ໨త • RxTestɺRxBlockingΛ࢖ͬͨϢχοτςετΛॻ͍ͨ͜ͱ͕ͳ ͍ਓʹ޲͚ͯɺ͜ΕΒͷϥΠϒϥϦ͕ͲͷΑ͏ͳ΋ͷ͔ɺRxͳ ίʔυͷςετ͸ͲͷΑ͏ʹॻ͚͹Α͍ͷ͔Λཧղͯ͠΋Β͏ • RxTestɺRxBlockingΛͳΜͱͳ͘࢖͍ͬͯΔਓʹ޲͚ͯɺ͜Ε ΒͷϥΠϒϥϦ͕ఏڙ͢ΔػೳΛ੔ཧ͠ɺॻ͖ํͷύλʔϯΛ ͓͑ͯ͞΋Β͏

  5. ౰ॳͷ໨࿦ݟͱͪΐͬͱҧͬͨ… • CFPΛॻ͍ͨ౰ॳ͸ͨ͘͞Μͷ࣮ફతͳςετύλʔϯΛ·ͱΊΑ͏ͱҙ ؾࠐΜͰ͍͕ͨɺ࣮ࡍϓϩδΣΫτͰ͸ͦΕ΄Ͳύλʔϯ͕ͳ͔ͬͨ… • ීஈ͔ΒςετΛॻ͍ͯΔਓʹ͸৽͍͠ൃݟ͸ͳ͍͔΋

  6. ViewModelɺAPIΫϥΠΞϯτ ͷ2ͭͷςετέʔεΛ͝঺հ

  7. ViewModelͷέʔε

  8. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ 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 } } }
  9. 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ΛόΠϯυ͢ΔͨΊͷϓϩύςΟ ϝʔϧΞυϨεͱύεϫʔυͷঢ়ଶΛ࣋ͭ
  10. 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ͱ͢Δ Ϗϡʔ͸͜ͷϓϩύςΟΛαϒεΫϥΠϒ͠ɺόϦσʔγϣϯ݁ՌΛϏϡʔ ʹ൓ө͢ΔʢϩάΠϯϘλϯͷ༗ޮ/ແޮΛ੾Γସ͑Δͱ͔ʣ
  11. ΍Γ͍ͨ͜ͱ • Ϣʔβͷจࣈྻೖྗૢ࡞ΛΤϛϡϨʔτ͍ͨ͠ • ྫ͑͹ɺϝʔϧΞυϨεͱύεϫʔυΛަޓʹೖྗͯ͠ɺͦͷͨ ͼʹόϦσʔγϣϯ݁Ռ͕ظ଴Ͳ͓ΓʹͳΔ͔Λݕূ͍ͨ͠

  12. RxTest

  13. TestScheduler • Ծ૝࣌ؒΛ؅ཧ͢Δεέδϡʔϥ • ࢦఆͨ࣌͠ࠁʹਖ਼֬ʹΠϕϯτΛൃߦ͢Δ TestableObservableΛੜ੒͢Δ͜ͱ͕Ͱ͖Δ • Ͳͷ࣌ࠁʹͲΜͳΠϕϯτΛड৴ͨ͠ͷ͔Λه࿥͢Δ TestableObserverΛੜ੒͢Δ͜ͱ͕Ͱ͖Δ

  14. HotObservableͱColdObservable • HotObservable • Φϒβʔό͕͍Δ͍ͳ͍ʹ͔͔ΘΒͣɺࢦఆ͞Εͨ࣌ࠁʹਖ਼֬ʹΠ ϕϯτΛൃߦ͢ΔObservable • ColdObservable • ৽͘͠Φϒβʔό͕αϒεΫϥΠϒ͢ΔͱɺΠϕϯτΛ࠷ॳ͔ΒϦ

    ϓϨΠ͢Δ • HotObservableͱ͸ҧ͍ɺΦϒβʔό͸αϒεΫϥΠϒ͢ΔλΠϛ ϯάʹؔΘΒͣ࠷ॳͷΠϕϯτ͔Βड͚औΔ͜ͱ͕ग़དྷΔ
  15. ςετίʔυ͸͜͏ͳΔ

  16. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "a@example.com"), 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) ])) }
  17. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "a@example.com"), 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) ...
  18. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "a@example.com"), 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Πϯελϯε
  19. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "a@example.com"), 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Λੜ੒ ϢʔβͷϝʔϧΞυϨεೖྗΛΤϛϡϨʔτ͢Δ΋ͷ
  20. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "a@example.com"), 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Λੜ੒ ϢʔβͷύεϫʔυೖྗΛΤϛϡϨʔτ͢Δ΋ͷ
  21. it("should be true when both email and password are valid")

    { let xs1 = scheduler.createHotObservable([ Recorded.next(10, "a"), Recorded.next(30, "a@example.com"), 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 ϓϩύςΟʹόΠϯυ͢Δ
  22. ... 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ʹαϒεΫ ϥΠϒͤ͞Δ
  23. ... 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()ϝιουΛݺͼग़͢͜ͱͰɺԾ૝͕࣌ؒ։࢝͞ΕΔ
  24. ... 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&
  25. APIΫϥΠΞϯτͷέʔε

  26. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ 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() } } }
  27. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ 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ϦΫΤετΛૹ৴͢Δ ඇಉظʹΠϕϯτ͕ൃߦ͞ΕΔ
  28. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ 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ܕʹσίʔυ͢Δ
  29. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ 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ܕ
  30. ΍Γ͍ͨ͜ͱ • GETϦΫΤετΛελϒԽ͍ͨ͠ • ਖ਼͍͠URLʹϦΫΤετͯ͠Δ͔Λݕূ͍ͨ͠ • ඇಉظʹൃߦ͞ΕΔΠϕϯτΛݕূ͍ͨ͠

  31. Mockingjay

  32. Mockingjay • NSURLConnectionɺNSURLSessionΛ࢖ͬͨHTTP/HTTPSϦΫ ΤετΛελϒԽͰ͖ΔϥΠϒϥϦ • ࣄલʹࢦఆͨ͠ϦΫΤετʹϚονͨ͠Βɺࢦఆͨ͠Ϩεϙϯ εΛฦ͢͜ͱ͕Ͱ͖Δ • ςετϑϨʔϜϫʔΫQuickʹ΋ରԠ͍ͯ͠Δ •

    ςετ͕ऴΘͬͨΒࣗಈͰελϒΛղআͯ͘͠ΕΔ
  33. RxBlocking

  34. RxBlocking • ௨ৗͷObservableΛBlockingObservableʹม׵͢Δ • BlockingObservable͸completed/errorΠϕϯτ͕ൃߦ͞ΕΔ ͔λΠϜΞ΢τ͢Δ·ͰɺΧϨϯτεϨουΛϒϩοΫ͢Δ • ͜ΕʹΑΓɺΠϕϯτ͕ඇಉظʹൃߦ͞ΕΔ৔߹Ͱ΋؆୯ʹς ετΛ͢Δ͜ͱ͕Ͱ͖Δ

  35. ςετίʔυ͸͜͏ͳΔ

  36. 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)) }
  37. 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จࣈྻΛϨε ϙϯεͱͯ͠ฦ͢Α͏ʹࢦఆ
  38. 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ʹม׵
  39. 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ΠϯελϯεʣΛऔΓग़͢
  40. BlockingObservableͷΦϖϨʔλ • ڞ௨ͷڍಈ: Observable͕completed·ͨ͸errorΛൃߦ͢Δ·ͰΧϨϯτε ϨουΛϒϩοΫ͠ɺड৴ͨ͠ΠϕϯτͷཁૉΛه࿥͢Δ • materialize(): completed/failedέʔεΛ࣋ͭEnumΛฦ͢ • first()/last():

    ࠷ॳ/࠷ޙͷཁૉΛฦ͢ɻerrorͷ৔߹͸ྫ֎Λεϩʔ͢Δ • toArray(): શͯͷཁૉΛฦ͢ɻerrorͷ৔߹͸ྫ֎Λεϩʔ͢Δ • single(): ࠷ॳͷཁૉΛฦ͢ɻ2ͭҟৗͷΠϕϯτΛड৴͍ͯͨ͠৔߹͸ྫ֎ Λεϩʔ͢Δ
  41. ·ͱΊ

  42. ·ͱΊ • Rxͳίʔυ͸RxTestͱRxBlockingΛ࢖͏͜ͱͰγϯϓϧʹϢ χοτςετ͕ॻ͚Δ • ॻ͖ํͷύλʔϯΛ͓͑͞ΔͱɺRxͳίʔυͷςετ͸͍ͩͨ ͍ॻ͚Δʢ͸ͣʣ

  43. ͓·͚

  44. https://qiita.com/takehilo/items/09f4a3077e441e5bb9de ΑΓৄ͍͠಺༰ΛQiitaͰެ։͍ͯ͠·͢ʂ ࠓճ঺հ͖͠Εͳ͔ͬͨRxTestɺRxBlockingͷػೳʹ͍ͭͯ΋৮Ε͍ͯ·͢ ͙͢ʹࢼͤΔGithubϦϙδτϦ΋͋ΔΑ ϑΟʔυόοΫ ͓଴͍ͪͯ͠·͢ʂʂ

  45. 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])) } ϒϩοΫ͞Εͨ··ʹͳΓςετ͕ऴྃ͠ͳ͍
  46. ศརͳΤΫεςϯγϣϯ extension Recorded: Equatable where Value: Equatable {} extension Event:

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