$30 off During Our Annual Pro Sale. View Details »

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

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. RxTest/RxBlocking
    ςετύλʔϯ
    iOSDC 2018 Reject Conference days1
    2018/09/18
    @takehilo_kaneko
    ͲͪΒ͔ͱ͍͏ͱ
    ॳ৺ऀ޲͚

    View Slide

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

    View Slide

  3. View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  7. ViewModelͷέʔε

    View Slide

  8. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ
    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 }
    }
    }

    View Slide

  9. 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ΛόΠϯυ͢ΔͨΊͷϓϩύςΟ
    ϝʔϧΞυϨεͱύεϫʔυͷঢ়ଶΛ࣋ͭ

    View Slide

  10. 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ͱ͢Δ
    Ϗϡʔ͸͜ͷϓϩύςΟΛαϒεΫϥΠϒ͠ɺόϦσʔγϣϯ݁ՌΛϏϡʔ
    ʹ൓ө͢ΔʢϩάΠϯϘλϯͷ༗ޮ/ແޮΛ੾Γସ͑Δͱ͔ʣ

    View Slide

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

    View Slide

  12. RxTest

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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&

    View Slide

  25. APIΫϥΠΞϯτͷέʔε

    View Slide

  26. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ
    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()
    }
    }
    }

    View Slide

  27. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ
    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ϦΫΤετΛૹ৴͢Δ
    ඇಉظʹΠϕϯτ͕ൃߦ͞ΕΔ

    View Slide

  28. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ
    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ܕʹσίʔυ͢Δ

    View Slide

  29. ͜ͷίʔυͷςετͲ͏΍ͬͯॻ͘ʁ
    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ܕ

    View Slide

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

    View Slide

  31. Mockingjay

    View Slide

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

    View Slide

  33. RxBlocking

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  41. ·ͱΊ

    View Slide

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

    View Slide

  43. ͓·͚

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  47. Thank you!!

    View Slide