Slide 1

Slide 1 text

RxTest/RxBlocking ςετύλʔϯ iOSDC 2018 Reject Conference days1 2018/09/18 @takehilo_kaneko ͲͪΒ͔ͱ͍͏ͱ ॳ৺ऀ޲͚

Slide 2

Slide 2 text

ࣗݾ঺հ ۚࢠ ༤େ @takehilo_kaneko 1೥ऑ https://qiita.com/takehilo

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

ViewModelͷέʔε

Slide 8

Slide 8 text

͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ class LoginViewModel { let email = BehaviorRelay(value: "") let password = BehaviorRelay(value: "") var isValidForm: Driver { return Driver.combineLatest( email.asDriver().map { isValidEmail($0) }, password.asDriver().map { isValidPassword($0) } ) .map { $0 && $1 } } }

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

΍Γ͍ͨ͜ͱ • Ϣʔβͷจࣈྻೖྗૢ࡞ΛΤϛϡϨʔτ͍ͨ͠ • ྫ͑͹ɺϝʔϧΞυϨεͱύεϫʔυΛަޓʹೖྗͯ͠ɺͦͷͨ ͼʹόϦσʔγϣϯ݁Ռ͕ظ଴Ͳ͓ΓʹͳΔ͔Λݕূ͍ͨ͠

Slide 12

Slide 12 text

RxTest

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

HotObservableͱColdObservable • HotObservable • Φϒβʔό͕͍Δ͍ͳ͍ʹ͔͔ΘΒͣɺࢦఆ͞Εͨ࣌ࠁʹਖ਼֬ʹΠ ϕϯτΛൃߦ͢ΔObservable • ColdObservable • ৽͘͠Φϒβʔό͕αϒεΫϥΠϒ͢ΔͱɺΠϕϯτΛ࠷ॳ͔ΒϦ ϓϨΠ͢Δ • HotObservableͱ͸ҧ͍ɺΦϒβʔό͸αϒεΫϥΠϒ͢ΔλΠϛ ϯάʹؔΘΒͣ࠷ॳͷΠϕϯτ͔Βड͚औΔ͜ͱ͕ग़དྷΔ

Slide 15

Slide 15 text

ςετίʔυ͸͜͏ͳΔ

Slide 16

Slide 16 text

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) ])) }

Slide 17

Slide 17 text

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) ...

Slide 18

Slide 18 text

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Πϯελϯε

Slide 19

Slide 19 text

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Λੜ੒ ϢʔβͷϝʔϧΞυϨεೖྗΛΤϛϡϨʔτ͢Δ΋ͷ

Slide 20

Slide 20 text

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Λੜ੒ ϢʔβͷύεϫʔυೖྗΛΤϛϡϨʔτ͢Δ΋ͷ

Slide 21

Slide 21 text

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 ϓϩύςΟʹόΠϯυ͢Δ

Slide 22

Slide 22 text

... 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ʹαϒεΫ ϥΠϒͤ͞Δ

Slide 23

Slide 23 text

... 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()ϝιουΛݺͼग़͢͜ͱͰɺԾ૝͕࣌ؒ։࢝͞ΕΔ

Slide 24

Slide 24 text

... 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&

Slide 25

Slide 25 text

APIΫϥΠΞϯτͷέʔε

Slide 26

Slide 26 text

͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ class UserService { func fetchUser(by userId: Int) -> Single { 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() } } }

Slide 27

Slide 27 text

͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ class UserService { func fetchUser(by userId: Int) -> Single { 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ϦΫΤετΛૹ৴͢Δ ඇಉظʹΠϕϯτ͕ൃߦ͞ΕΔ

Slide 28

Slide 28 text

͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ class UserService { func fetchUser(by userId: Int) -> Single { 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ܕʹσίʔυ͢Δ

Slide 29

Slide 29 text

͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ class UserService { func fetchUser(by userId: Int) -> Single { 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ܕ

Slide 30

Slide 30 text

΍Γ͍ͨ͜ͱ • GETϦΫΤετΛελϒԽ͍ͨ͠ • ਖ਼͍͠URLʹϦΫΤετͯ͠Δ͔Λݕূ͍ͨ͠ • ඇಉظʹൃߦ͞ΕΔΠϕϯτΛݕূ͍ͨ͠

Slide 31

Slide 31 text

Mockingjay

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

RxBlocking

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

ςετίʔυ͸͜͏ͳΔ

Slide 36

Slide 36 text

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)) }

Slide 37

Slide 37 text

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จࣈྻΛϨε ϙϯεͱͯ͠ฦ͢Α͏ʹࢦఆ

Slide 38

Slide 38 text

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ʹม׵

Slide 39

Slide 39 text

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ΠϯελϯεʣΛऔΓग़͢

Slide 40

Slide 40 text

BlockingObservableͷΦϖϨʔλ • ڞ௨ͷڍಈ: Observable͕completed·ͨ͸errorΛൃߦ͢Δ·ͰΧϨϯτε ϨουΛϒϩοΫ͠ɺड৴ͨ͠ΠϕϯτͷཁૉΛه࿥͢Δ • materialize(): completed/failedέʔεΛ࣋ͭEnumΛฦ͢ • first()/last(): ࠷ॳ/࠷ޙͷཁૉΛฦ͢ɻerrorͷ৔߹͸ྫ֎Λεϩʔ͢Δ • toArray(): શͯͷཁૉΛฦ͢ɻerrorͷ৔߹͸ྫ֎Λεϩʔ͢Δ • single(): ࠷ॳͷཁૉΛฦ͢ɻ2ͭҟৗͷΠϕϯτΛड৴͍ͯͨ͠৔߹͸ྫ֎ Λεϩʔ͢Δ

Slide 41

Slide 41 text

·ͱΊ

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

͓·͚

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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])) } ϒϩοΫ͞Εͨ··ʹͳΓςετ͕ऴྃ͠ͳ͍

Slide 46

Slide 46 text

ศརͳΤΫεςϯγϣϯ extension Recorded: Equatable where Value: Equatable {} extension Event: Equatable where Element: Equatable {} NimbleͷexpectͰΠϕϯτͷݕূ͕͠΍͘͢ͳΔʂ https://github.com/Quick/Nimble/issues/523

Slide 47

Slide 47 text

Thank you!!