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. 3.
  2. 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 } } }
  3. 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ΛόΠϯυ͢ΔͨΊͷϓϩύςΟ ϝʔϧΞυϨεͱύεϫʔυͷঢ়ଶΛ࣋ͭ
  4. 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ͱ͢Δ Ϗϡʔ͸͜ͷϓϩύςΟΛαϒεΫϥΠϒ͠ɺόϦσʔγϣϯ݁ՌΛϏϡʔ ʹ൓ө͢ΔʢϩάΠϯϘλϯͷ༗ޮ/ແޮΛ੾Γସ͑Δͱ͔ʣ
  5. 12.
  6. 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) ])) }
  7. 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) ...
  8. 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Πϯελϯε
  9. 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Λੜ੒ ϢʔβͷϝʔϧΞυϨεೖྗΛΤϛϡϨʔτ͢Δ΋ͷ
  10. 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Λੜ੒ ϢʔβͷύεϫʔυೖྗΛΤϛϡϨʔτ͢Δ΋ͷ
  11. 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 ϓϩύςΟʹόΠϯυ͢Δ
  12. 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ʹαϒεΫ ϥΠϒͤ͞Δ
  13. 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()ϝιουΛݺͼग़͢͜ͱͰɺԾ૝͕࣌ؒ։࢝͞ΕΔ
  14. 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&
  15. 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() } } }
  16. 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ϦΫΤετΛૹ৴͢Δ ඇಉظʹΠϕϯτ͕ൃߦ͞ΕΔ
  17. 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ܕʹσίʔυ͢Δ
  18. 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ܕ
  19. 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)) }
  20. 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จࣈྻΛϨε ϙϯεͱͯ͠ฦ͢Α͏ʹࢦఆ
  21. 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ʹม׵
  22. 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ΠϯελϯεʣΛऔΓग़͢
  23. 40.

    BlockingObservableͷΦϖϨʔλ • ڞ௨ͷڍಈ: Observable͕completed·ͨ͸errorΛൃߦ͢Δ·ͰΧϨϯτε ϨουΛϒϩοΫ͠ɺड৴ͨ͠ΠϕϯτͷཁૉΛه࿥͢Δ • materialize(): completed/failedέʔεΛ࣋ͭEnumΛฦ͢ • first()/last():

    ࠷ॳ/࠷ޙͷཁૉΛฦ͢ɻerrorͷ৔߹͸ྫ֎Λεϩʔ͢Δ • toArray(): શͯͷཁૉΛฦ͢ɻerrorͷ৔߹͸ྫ֎Λεϩʔ͢Δ • single(): ࠷ॳͷཁૉΛฦ͢ɻ2ͭҟৗͷΠϕϯτΛड৴͍ͯͨ͠৔߹͸ྫ֎ Λεϩʔ͢Δ
  24. 41.
  25. 43.
  26. 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])) } ϒϩοΫ͞Εͨ··ʹͳΓςετ͕ऴྃ͠ͳ͍
  27. 46.

    ศརͳΤΫεςϯγϣϯ extension Recorded: Equatable where Value: Equatable {} extension Event:

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