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

ログの発火テストをXCUITestで自動化しようとしたがUnitテストで実装した話

Ae276805027a01983503c3edafbdb6b2?s=47 Taiki Suzuki
September 13, 2018

 ログの発火テストをXCUITestで自動化しようとしたがUnitテストで実装した話

俺コン 2018 Summer / Day. 2
https://orecon.connpass.com/event/94867/

- アプリの設計
- UITestの実装
- UnitTestの実装

GitHubClientTestSample
https://github.com/marty-suzuki/GitHubClientTestSample

KIF
https://github.com/kif-framework/KIF

Sourcery
https://github.com/krzysztofzablocki/Sourcery

bluepill
https://github.com/linkedin/bluepill

Ae276805027a01983503c3edafbdb6b2?s=128

Taiki Suzuki

September 13, 2018
Tweet

Transcript

  1. ϩάͷൃՐςετΛ XCUITestͰࣗಈԽ͠Α͏ͱ͕ͨ͠ UnitςετͰ࣮૷ͨ͠࿩ Զίϯ 2018 Summer / Day. 2: September

    13th Taiki Suzuki / @marty_suzuki
  2. Popular repositories Overview SAHistoryNavigationViewController Swift 1,534 SAHistoryNavigationViewController realizes iOS task

    manager like UI in UINavigationController. SABlurImageView Swift 504 You can use blur effect and it's animation easily to call only two methods. URLEmbeddedView Swift 480 URLEmbeddedView automatically caches the object that is confirmed the Open Graph Protocol. ReverseExtension Swift 1,348 A UITableView extension that enables cell insertion from the bottom of a table view. marty_suzuki marty-suzuki Taiki Suzuki ΠϯλʔωοτςϨϏہʮAbemaTVʯΛ୲౰͢ΔiOSΤϯδχΞɻ 2014೥αΠόʔΤʔδΣϯτ৽ଔೖࣾɻ ίϛϡχςΟαʔϏεͰαʔόʔαΠυΛ୲౰ͨ͠ޙɺiOSΤϯδχΞ ʹస޲͠ϑΝογϣϯ௨ൢαΠτʮVILECTʯͷ্ཱͪ͛ɾӡӦʹै ࣄɻͦͷޙ৽ײ֮SNSʮ755ʯͰͷ։ൃΛܦͯɺ2017೥3݄ΑΓݱ৬ɻ
  3. ߦಈϩάͷ ςετͯ͠·͔͢ʁ

  4. bug fix feature master feature ৽ػೳ௥Ճ΍ όάमਖ਼Ͱ ͍ͭͷؒʹ͔ ϩά͕ ૹΒΕ͍ͯͳ͍

  5. ࣄۀతʹେࣄͳ σʔλͰ͋Δ͸ͣ http://img01.gahag.net/201512/05o/gahag-0033099635.jpg

  6. ϩάͷൃՐςετΛ ࣮૷͢Δ·ͰͷաఔΛ αϯϓϧΞϓϦͰ ղઆ͍͖ͯ͠·͢

  7. ΞδΣϯμ -ΞϓϦͷઃܭ -UITestͷ࣮૷ -UnitTestͷ࣮૷

  8. Route ActionCreator Dispatcher Store Tracking ActionCreator Dispatcher Store Repository ActionCreator

    Dispatcher Store Device ActionCreator Dispatcher Store RootViewController ViewModel ViewModel ViewModel ΞϓϦͷઃܭ Flux + MVVM
  9. Route ActionCreator Dispatcher Store Tracking ActionCreator Dispatcher Store Repository ActionCreator

    Dispatcher Store Device ActionCreator Dispatcher Store RootViewController ViewModel ViewModel ViewModel
  10. Route ActionCreator Dispatcher Store Tracking ActionCreator Dispatcher Store Repository ActionCreator

    Dispatcher Store Device ActionCreator Dispatcher Store RootViewController ViewModel ViewModel ViewModel ը໘ભҠͷྫ
  11. Route ActionCreator Dispatcher Store Tracking ActionCreator Dispatcher Store Repository ActionCreator

    Dispatcher Store Device ActionCreator Dispatcher Store RootViewController ViewModel ViewModel ViewModel RouteCommand
  12. Route ActionCreator Dispatcher Store Tracking ActionCreator Dispatcher Store Repository ActionCreator

    Dispatcher Store Device ActionCreator Dispatcher Store RootViewController ViewModel ViewModel ViewModel RouteCommand
  13. Route ActionCreator Dispatcher Store Tracking ActionCreator Dispatcher Store Repository ActionCreator

    Dispatcher Store Device ActionCreator Dispatcher Store RootViewController ViewModel ViewModel ViewModel RouteCommand
  14. Route ActionCreator Dispatcher Store Tracking ActionCreator Dispatcher Store Repository ActionCreator

    Dispatcher Store Device ActionCreator Dispatcher Store RootViewController ViewModel ViewModel ViewModel
  15. RouteActionCreator .shared .setRouteCommand(.repositoryDetail)

  16. ΋͔͢͠ΔͱUITestͰ RouteActionCreatorΛ ࢖ͬͯը໘ભҠ͕ Ͱ͖Δ͔΋͠Εͳ͍

  17. { "user_id" : "F3476B52-160D-4CB9-9022-DE3B34E732EC", "time" : 1536753250.9239712, "event" : {

    "name" : "background" } } όοΫάϥ΢ϯυ { "user_id" : "F3476B52-160D-4CB9-9022-DE3B34E732EC", "time" : 1536753247.3513379, "event" : { "name" : "launch" } } ΞϓϦىಈ ϩάͷઃܭ
  18. { "user_id" : "F3476B52-160D-4CB9-9022-DE3B34E732EC", "time" : 1536753250.9239712, "event" : {

    "name" : "background" } } όοΫάϥ΢ϯυ { "user_id" : "F3476B52-160D-4CB9-9022-DE3B34E732EC", "time" : 1536753247.3513379, "event" : { "name" : "launch" } } ΞϓϦىಈ
  19. { "user_id" : "F3476B52-160D-4CB9-9022-DE3B34E732EC", "time" : 1536753250.9239712, "event" : {

    "name" : "background" } } όοΫάϥ΢ϯυ { "user_id" : "F3476B52-160D-4CB9-9022-DE3B34E732EC", "time" : 1536753247.3513379, "event" : { "name" : "launch" } } ΞϓϦىಈ ڞ௨ύϥϝʔλʔ
  20. { "user_id" : "F3476B52-160D-4CB9-9022-DE3B34E732EC", "time" : 1536753250.9239712, "event" : {

    "name" : "background" } } όοΫάϥ΢ϯυ { "user_id" : "F3476B52-160D-4CB9-9022-DE3B34E732EC", "time" : 1536753247.3513379, "event" : { "name" : "launch" } } ΞϓϦىಈ Πϕϯτ͝ͱͷύϥϝʔλʔ
  21. { … "event" : { "name" : "page-view", "value" :

    { "page" : "repository-search" } } } ݕࡧը໘දࣔ { … "event" : { "name" : "search", "value" : { "query" : "flux", "page" : 1 } } } ݕࡧঢ়ଶ
  22. { … "event" : { "name" : "page-view", "value" :

    { "additional" : “https://github.com/marty-suzuki/MisterFusion", "page" : "repository-detail" } } } ϦϙδτϦৄࡉදࣔ
  23. ViewModel TrackingModel ViewModel Tracking ActionCreator Tracker Tracking Dispatcher TrackingStore

  24. TrackingDispatcher .shared .trackingContainer .subscribe(onNext: { event in print(event) }) .disposed(by:

    disposeBag)
  25. ΋͔͢͠ΔͱUITestͰ TrackingDispatcherΛ ࢖ͬͯϩάͷ஋Λऔಘ Ͱ͖Δ͔΋͠Εͳ͍

  26. ΋͔͢͠Δͱ FluxͱUITestͷ ૬ੑྑ͍͔΋͠Εͳ͍

  27. ͱΓ͋͑ͣ XCUITestΛ࢖ͬͯΈΑ͏

  28. @testable import GitHubClientTestSample let app = XCUIApplication() app.launch()

  29. @testable import GitHubClientTestSample let app = XCUIApplication() app.launch() XCUIxxxʹϥοϓ͞Ε͗ͯ͢viewͳͲʹ ௚઀ΞΫηεͰ͖ͳ͍

  30. ͱΓ͋͑ͣ ϩάΛऔಘ͢Δ͜ͱʹ

  31. @testable import GitHubClientTestSample let app = XCUIApplication() app.launch() TrackingDispatcher.shared .trackingContainer

    .subscribe(onNext: { event in print(event) }) .disposed(by: disposeBag)
  32. @testable import GitHubClientTestSample let app = XCUIApplication() app.launch() TrackingDispatcher.shared .trackingContainer

    .subscribe(onNext: { event in print(event) }) .disposed(by: disposeBag) Undefined symbols for architecture x86_64: ʹͳͬͯ͠·ͬͯTrackingDispatcherʹ ΞΫηεͰ͖ͳ͍
  33. @testable import GitHubClientTestSampe Ͱ͸ͳ͘ File Inspector -> Target Membership ->

    GitHubClientTestSampleUITests ͰϑΝΠϧͭͭʹ νΣοΫΛ͚ͭͳ͍ͱಈ͔ͳ͍
  34. Ͳ͏ʹ͔ͯ͠ ϩάΛऔಘͰ͖ͳ͍ ͩΖ͏͔

  35. XCTestͰUITest ͢Ε͹ྑ͍͡ΌΜ

  36. func testTrackingEvent_pageView_repositorySearch() { let description = "wait TrackingEvent.pageView(.repositorySearch)" let expect

    = expectation(description: description) let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable<Void> in if case .pageView(.repositorySearch) = value.event { return .just(()) } return .empty() } .subscribe(onNext: { expect.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositorySearch) wait(for: [expect], timeout: 5) disposable.dispose() } ݕࡧը໘ͷදࣔϩά
  37. func testTrackingEvent_pageView_repositorySearch() { let description = "wait TrackingEvent.pageView(.repositorySearch)" let expect

    = expectation(description: description) let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable<Void> in if case .pageView(.repositorySearch) = value.event { return .just(()) } return .empty() } .subscribe(onNext: { expect.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositorySearch) wait(for: [expect], timeout: 5) disposable.dispose() }
  38. func testTrackingEvent_pageView_repositorySearch() { let description = "wait TrackingEvent.pageView(.repositorySearch)" let expect

    = expectation(description: description) let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable<Void> in if case .pageView(.repositorySearch) = value.event { return .just(()) } return .empty() } .subscribe(onNext: { expect.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositorySearch) wait(for: [expect], timeout: 5) disposable.dispose() } ϩά͕ൃՐ͢ΔͷΛ଴ͭ
  39. func testTrackingEvent_pageView_repositorySearch() { let description = "wait TrackingEvent.pageView(.repositorySearch)" let expect

    = expectation(description: description) let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable<Void> in if case .pageView(.repositorySearch) = value.event { return .just(()) } return .empty() } .subscribe(onNext: { expect.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositorySearch) wait(for: [expect], timeout: 5) disposable.dispose() } ը໘ભҠͤ͞Δ
  40. func testTrackingEvent_pageView_repositorySearch() { let description = "wait TrackingEvent.pageView(.repositorySearch)" let expect

    = expectation(description: description) let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable<Void> in if case .pageView(.repositorySearch) = value.event { return .just(()) } return .empty() } .subscribe(onNext: { expect.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositorySearch) wait(for: [expect], timeout: 5) disposable.dispose() } ֘౰ͷϩά͕ൃՐ͢Δͱςετ௨ա
  41. func testTrackingEvent_pageView_repositoryDetail() { let description = "wait TrackingEvent.pageView(.repositoryDetail)" let expect

    = expectation(description: description) let repository = helper.repositoryMock() let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable<URL> in if case let .pageView(.repositoryDetail(url)) = value.event { return .just(url) } return .empty() } .subscribe(onNext: { url in XCTAssertEqual(url, repository.htmlURL) expect.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositoryDetail(.object(repository))) wait(for: [expect], timeout: 5) disposable.dispose() } ৄࡉը໘ͷදࣔϩά
  42. func testTrackingEvent_pageView_repositoryDetail() { let description = "wait TrackingEvent.pageView(.repositoryDetail)" let expect

    = expectation(description: description) let repository = helper.repositoryMock() let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable<URL> in if case let .pageView(.repositoryDetail(url)) = value.event { return .just(url) } return .empty() } .subscribe(onNext: { url in XCTAssertEqual(url, repository.htmlURL) expect.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositoryDetail(.object(repository))) wait(for: [expect], timeout: 5) disposable.dispose() }
  43. func testTrackingEvent_pageView_repositoryDetail() { let description = "wait TrackingEvent.pageView(.repositoryDetail)" let expect

    = expectation(description: description) let repository = helper.repositoryMock() let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable<URL> in if case let .pageView(.repositoryDetail(url)) = value.event { return .just(url) } return .empty() } .subscribe(onNext: { url in XCTAssertEqual(url, repository.htmlURL) expect.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositoryDetail(.object(repository))) wait(for: [expect], timeout: 5) disposable.dispose() } ϩά͕ൃՐ͢ΔͷΛ଴ͭ
  44. func testTrackingEvent_pageView_repositoryDetail() { let description = "wait TrackingEvent.pageView(.repositoryDetail)" let expect

    = expectation(description: description) let repository = helper.repositoryMock() let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable<URL> in if case let .pageView(.repositoryDetail(url)) = value.event { return .just(url) } return .empty() } .subscribe(onNext: { url in XCTAssertEqual(url, repository.htmlURL) expect.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositoryDetail(.object(repository))) wait(for: [expect], timeout: 5) disposable.dispose() } ը໘ભҠͤ͞Δ
  45. func testTrackingEvent_pageView_repositoryDetail() { let description = "wait TrackingEvent.pageView(.repositoryDetail)" let expect

    = expectation(description: description) let repository = helper.repositoryMock() let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable<URL> in if case let .pageView(.repositoryDetail(url)) = value.event { return .just(url) } return .empty() } .subscribe(onNext: { url in XCTAssertEqual(url, repository.htmlURL) expect.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositoryDetail(.object(repository))) wait(for: [expect], timeout: 5) disposable.dispose() } ֘౰ͷϩά͕ൃՐ͢Δͱςετ௨ա
  46. pageView.mov

  47. final class RepositorySearchViewController: UIViewController { @IBOutlet private weak var tableView:

    UITableView! private let searchBar: UISearchBar = { let searchBar = UISearchBar(frame: .zero) searchBar.showsCancelButton = true return searchBar }() ... } ΞϓϦଆͰ Privateʹͳ͍ͬͯΔViewʹ ΞΫηεम০ࢠΛม͑ͣʹ ΞΫηε͍ͨ͠
  48. final class RepositorySearchViewController: UIViewController { @IBOutlet private weak var tableView:

    UITableView! private let searchBar: UISearchBar = { let searchBar = UISearchBar(frame: .zero) searchBar.showsCancelButton = true return searchBar }() ... }
  49. let searchVC = RepositorySearchViewController() let tableView = searchVC.privateProperties.tableView let searchBar

    = searchVC.privateProperties.searchBar
  50. https://github.com/krzysztofzablocki/Sourcery

  51. // // AutoPrivatePropertyAccessible.swift // @testable import GitHubClientTestSample protocol AutoPrivatePropertyAccessible {}

    extension RepositorySearchViewController: AutoPrivatePropertyAccessible {}
  52. // // PrivateProperties.swift // protocol PrivatePropertyAccessible { associatedtype PrivatePropertiesCompatible var

    privateProperties: PrivateProperties<PrivatePropertiesCompatible> { get } } struct PrivateProperties<Base> { let base: Base init(_ base: Base) { self.base = base } }
  53. // // PrivateProperties.swift // extension PrivatePropertyAccessible where PrivatePropertiesCompatible == Self

    { var privateProperties: PrivateProperties<Self> { return PrivateProperties(self) } } extension PrivateProperties { func property<T>(forKey key: String) -> T { return Mirror(reflecting: base).children.first { $0.label == key }!.value as! T } }
  54. // // AutoPrivatePropertyAccessible.stencil // {% for type in types.implementing.AutoPrivatePropertyAccessible %}

    // MARK: - {{ type.name }} extension {{type.name}}: PrivatePropertyAccessible {} extension PrivateProperties where Base == {{type.name}} { {% for variable in type.storedVariables where variable.readAccess == "private" %} var {{variable.name}}: {{variable.typeName}} { return property(forKey: "{{variable.name}}") } {% endfor %} } {% endfor %}
  55. // // AutoPrivatePropertyAccessible.stencil // {% for type in types.implementing.AutoPrivatePropertyAccessible %}

    // MARK: - {{ type.name }} extension {{type.name}}: PrivatePropertyAccessible {} extension PrivateProperties where Base == {{type.name}} { {% for variable in type.storedVariables where variable.readAccess == "private" %} var {{variable.name}}: {{variable.typeName}} { return property(forKey: "{{variable.name}}") } {% endfor %} } {% endfor %} "VUP1SJWBUF1SPQFSUZ"DDFTTJCMFΛ࠾༻͍ͯ͠Δ ΦϒδΣΫτʹରͯ͠ॲཧΛ͢Δ
  56. // // AutoPrivatePropertyAccessible.stencil // {% for type in types.implementing.AutoPrivatePropertyAccessible %}

    // MARK: - {{ type.name }} extension {{type.name}}: PrivatePropertyAccessible {} extension PrivateProperties where Base == {{type.name}} { {% for variable in type.storedVariables where variable.readAccess == "private" %} var {{variable.name}}: {{variable.typeName}} { return property(forKey: "{{variable.name}}") } {% endfor %} } {% endfor %} "VUP1SJWBUF1SPQFSUZ"DDFTTJCMFΛ࠾༻͍ͯ͠Δ ΦϒδΣΫτʹରͯ͠1SJWBUF1SPQFSUZ"DDFTTJCMF Λ࠾༻͢Δ
  57. // // AutoPrivatePropertyAccessible.stencil // {% for type in types.implementing.AutoPrivatePropertyAccessible %}

    // MARK: - {{ type.name }} extension {{type.name}}: PrivatePropertyAccessible {} extension PrivateProperties where Base == {{type.name}} { {% for variable in type.storedVariables where variable.readAccess == "private" %} var {{variable.name}}: {{variable.typeName}} { return property(forKey: "{{variable.name}}") } {% endfor %} } {% endfor %} TUPSF1SPQFSUZͰQSJWBUFͳ΋ͷΛର৅ʹ͢Δ
  58. // // AutoPrivatePropertyAccessible.stencil // {% for type in types.implementing.AutoPrivatePropertyAccessible %}

    // MARK: - {{ type.name }} extension {{type.name}}: PrivatePropertyAccessible {} extension PrivateProperties where Base == {{type.name}} { {% for variable in type.storedVariables where variable.readAccess == "private" %} var {{variable.name}}: {{variable.typeName}} { return property(forKey: "{{variable.name}}") } {% endfor %} } {% endfor %} TUPSF1SPQFSUZͰͱಉ໊͡લͰQSPQFSUZΛఆٛ͠ QSPQFSUZؔ਺͔Β஋Λऔಘ͢Δ
  59. // // .sourcery.yml // sources: - GitHubClientTestSample - GitHubClientTestSampleUITests templates:

    - GitHubClientTestSampleUITests/Template/AutoPrivatePropertyAccessible.stencil output: GitHubClientTestSampleUITests/Gen/zzz.Sourcery.out.swift
  60. None
  61. generate.mov

  62. ViewController͕ ը໘ʹදࣔ͞ΕͨΒ ͦͷΠϯελϯεΛ औಘ͍ͨ͠

  63. presentViewController ͩͬͨΓ pushViewController ͩͬͨΓ popɺdismiss…

  64. viewDidAppear͕ݺ͹ΕΒ ViewControllerͷ ΠϯελϯεΛ औಘ͢Ε͹ྑ͍

  65. final class ViewControllerLifeCycleHandler { static let swizzleOnce: () = {

    let original = #selector(UIViewController.viewDidAppear(_:)) let swizzled = #selector(UIViewController._swizzled_viewDidAppear(_:)) guard let originalMethod = class_getInstanceMethod(UIViewController.self, original), let swizzledMethod = class_getInstanceMethod(UIViewController.self, swizzled) else { return } method_exchangeImplementations(originalMethod, swizzledMethod) }() }
  66. final class ViewControllerLifeCycleHandler { static let shared = ViewControllerLifeCycleHandler() let

    viewDidAppearCalled: Observable<UIViewController> fileprivate let _viewDidAppearCalled = PublishRelay<UIViewController>() private init() { self.viewDidAppearCalled = _viewDidAppearCalled.asObservable() } } extension UIViewController { @objc fileprivate func _swizzled_viewDidAppear(_ animated: Bool) { ViewControllerLifeCycleHandler.shared._viewDidAppearCalled.accept(self) _swizzled_viewDidAppear(animated) } }
  67. ΞϓϦଆͷιʔεʹ Өڹ͕ͳ͍Α͏swizzle͢Δ λΠϛϯά͸

  68. https://developer.apple.com/documentation/objectivec/nsobject/1418815-load

  69. @interface _RuntimeHandler : NSObject + (void)handleLoad; @end @interface RuntimeHandler :

    _RuntimeHandler @end
  70. @implementation _RuntimeHandler + (void)handleLoad { NSLog(@"Please override RuntimeHandler.handleLoad if you

    want to use"); } @end @implementation RuntimeHandler + (void)load { [super load]; [self handleLoad]; } @end
  71. extension RuntimeHandler { override open class func handleLoad() { _

    = ViewControllerLifeCycleHandler.swizzleOnce } }
  72. var vc: RepositorySearchViewController! = nil let description = "wait RepositorySearchViewController"

    let expectation = base.expectation(description: description) let disposable = ViewControllerLifeCycleHandler.shared .viewDidAppearCalled .flatMap { ($0 as? RepositorySearchViewController) .map(Observable.just) ?? .empty() } .subscribe(onNext: { vc = $0 expectation.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositorySearch) base.wait(for: [expectation], timeout: 5) disposable.dispose()
  73. var vc: RepositorySearchViewController! = nil let description = "wait RepositorySearchViewController"

    let expectation = base.expectation(description: description) let disposable = ViewControllerLifeCycleHandler.shared .viewDidAppearCalled .flatMap { ($0 as? RepositorySearchViewController) .map(Observable.just) ?? .empty() } .subscribe(onNext: { vc = $0 expectation.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositorySearch) base.wait(for: [expectation], timeout: 5) disposable.dispose() 7JFX$POUSPMMFSͷΠϯελϯεΛ଴ͭ
  74. var vc: RepositorySearchViewController! = nil let description = "wait RepositorySearchViewController"

    let expectation = base.expectation(description: description) let disposable = ViewControllerLifeCycleHandler.shared .viewDidAppearCalled .flatMap { ($0 as? RepositorySearchViewController) .map(Observable.just) ?? .empty() } .subscribe(onNext: { vc = $0 expectation.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositorySearch) base.wait(for: [expectation], timeout: 5) disposable.dispose() 7JFX$POUSPMMFSΛදࣔͤ͞Δ
  75. var vc: RepositorySearchViewController! = nil let description = "wait RepositorySearchViewController"

    let expectation = base.expectation(description: description) let disposable = ViewControllerLifeCycleHandler.shared .viewDidAppearCalled .flatMap { ($0 as? RepositorySearchViewController) .map(Observable.just) ?? .empty() } .subscribe(onNext: { vc = $0 expectation.fulfill() }) RouteActionCreator.shared.setRouteCommand(.repositorySearch) base.wait(for: [expectation], timeout: 5) disposable.dispose() ֘౰ͷ7JFX$POUSPMMFS͕දࣔ͞ΕΔͱݺͼग़͞ΕΔ
  76. https://github.com/cats-oss/Degu

  77. ΩʔϘʔυͷೖྗ΍ ը໘্ͰͷεϫΠϓͳͲΛ ൃੜ͍ͤͨ͞

  78. https://github.com/kif-framework/KIF

  79. vc.view.drag(from: CGPoint(x: 0, y: tableView.frame.origin.y + 150), to: CGPoint(x: 0,

    y: tableView.frame.origin.y + 50)) vc.view.tap(at: CGPoint(x: 20, y: 44)) searchBar.becomeFirstResponder() KIFTypist.enterCharacter("swift")
  80. uitest.mov

  81. UITestΛ࣮ߦ͢Δͱ ભҠͳͲ͍ͯ͠ΔͷͰ ςετʹ͕͔͔࣌ؒΔ

  82. https://github.com/linkedin/bluepill

  83. xcodebuild build-for-testing \ -scheme GitHubClientTestSample-UITest \ -derivedDataPath ./build \ -workspace

    ./GitHubClientTestSample.xcworkspace \ -destination 'platform=iOS Simulator,name=iPhone 8,OS=latest' \ CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
  84. bluepill \ --xctestrun-path \ build/Build/Products/GitHubClientTestSample-UITest_iphonesimulator11.4-x86_64.xctestrun \ --output-dir output \ --num-sims

    2
  85. uitest.mov

  86. https://github.com/marty-suzuki/GitHubClientTestSample

  87. ΞϓϦىಈͳͲͷ λΠϛϯά΍ ҟৗܥͷςετ͍ͨ͠

  88. UnitTest΋ ͔ͬ͠Γ࣮૷͍ͯ͜͠͏

  89. UnitTest͢Δʹͯ͠΋ ςετ͕Ͱ͖Δ࣮૷ʹ ͳ͍ͬͯͳ͍ͱͰ͖ͳ͍

  90. final class RepositorySearchViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad()

    rx.methodInvoked(#selector(UIViewController.viewDidAppear(_:))) .map { _ in TrackingEvent.pageView(.repositorySearch) } .subscribe(onNext: { TrackingModel.shared.sendTrackingEvent($0) }) .disposed(by: disposeBag) } }
  91. ViewControllerʹ͸ Ͱ͖ΔݶΓը໘ߋ৽ͷ ॲཧ͚ͩΛ࣮૷͠ ͦΕҎ֎͸ViewModelʹ ࣮૷͢Δ

  92. override func viewDidLoad() { super.viewDidLoad() navigationItem.titleView = searchBar dataSource.setup(tableView: tableView)

    viewModel.resignFirstResponder .bind(to: Binder(searchBar) { bar, _ in bar.resignFirstResponder() }) .disposed(by: disposeBag) viewModel.reloadData .bind(to: Binder(tableView) { tableView, _ in tableView.reloadData() }) .disposed(by: disposeBag) viewModel.deselectIndexPath .bind(to: Binder(tableView) { tableView, indexPath in tableView.deselectRow(at: indexPath, animated: true) }) .disposed(by: disposeBag) }
  93. final class RepositorySearchViewModel { init(viewDidAppear: Observable<Bool>, trackingModel: TrackingModel = .shared)

    { viewDidAppear .map { _ in TrackingEvent.pageView(.repositorySearch) } .subscribe(onNext: { trackingModel.sendTrackingEvent($0) }) .disposed(by: disposeBag) } }
  94. final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay<TrackingContainer?>! private

    var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay<Bool>! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay<Bool>() self.trackingDispatcher = TrackingDispatcher() let trackingActionCreator = TrackingActionCreator(dispatcher: trackingDispatcher, tracker: MockTracker()) let deviceDispatcher = DeviceDispatcher() let userDefaultsManager = UserDefaultsManager(userDefaults: MockUserDefaults()) let deviceStore = DeviceStore(dispatcher: deviceDispatcher, userDefaultsManager: userDefaultsManager) let trackingModel = TrackingModel(trackingActionCreator: trackingActionCreator, deviceStore: deviceStore) self.videModel = RepositorySearchViewModel(viewDidAppear: viewDidAppear.asObservable(), trackingModel: trackingModel) self.trackingContainer = BehaviorRelay(value: nil) self.disposeBag = DisposeBag() dependency.trackingDispatcher.trackingContainer .bind(to: trackingContainer) .disposed(by: disposeBag) } }
  95. final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay<TrackingContainer?>! private

    var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay<Bool>! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay<Bool>() self.trackingDispatcher = TrackingDispatcher() let trackingActionCreator = TrackingActionCreator(dispatcher: trackingDispatcher, tracker: MockTracker()) let deviceDispatcher = DeviceDispatcher() let userDefaultsManager = UserDefaultsManager(userDefaults: MockUserDefaults()) let deviceStore = DeviceStore(dispatcher: deviceDispatcher, userDefaultsManager: userDefaultsManager) let trackingModel = TrackingModel(trackingActionCreator: trackingActionCreator, deviceStore: deviceStore) self.videModel = RepositorySearchViewModel(viewDidAppear: viewDidAppear.asObservable(), trackingModel: trackingModel) self.trackingContainer = BehaviorRelay(value: nil) self.disposeBag = DisposeBag() dependency.trackingDispatcher.trackingContainer .bind(to: trackingContainer) .disposed(by: disposeBag) } } 7JFX.PEFMͷґଘͷղܾ
  96. final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay<TrackingContainer?>! private

    var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay<Bool>! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay<Bool>() self.trackingDispatcher = TrackingDispatcher() let trackingActionCreator = TrackingActionCreator(dispatcher: trackingDispatcher, tracker: MockTracker()) let deviceDispatcher = DeviceDispatcher() let userDefaultsManager = UserDefaultsManager(userDefaults: MockUserDefaults()) let deviceStore = DeviceStore(dispatcher: deviceDispatcher, userDefaultsManager: userDefaultsManager) let trackingModel = TrackingModel(trackingActionCreator: trackingActionCreator, deviceStore: deviceStore) self.videModel = RepositorySearchViewModel(viewDidAppear: viewDidAppear.asObservable(), trackingModel: trackingModel) self.trackingContainer = BehaviorRelay(value: nil) self.disposeBag = DisposeBag() dependency.trackingDispatcher.trackingContainer .bind(to: trackingContainer) .disposed(by: disposeBag) } } ϩάͷऔಘͷ࣮૷
  97. func test_TrackingEvent_pageView() { viewDidAppear.accept(true) guard let value = trackingContainer.value else

    { XCTFail("trackingContainer.value is nil") return } guard case .pageView(.repositorySearch) = value.event else { XCTFail(""" value.event must be TrackingEvent.pageView(.repositorySearch)) but it is \(value.event) """) return } }
  98. final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay<TrackingContainer?>! private

    var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay<Bool>! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay<Bool>() self.trackingDispatcher = TrackingDispatcher() let trackingActionCreator = TrackingActionCreator(dispatcher: trackingDispatcher, tracker: MockTracker()) let deviceDispatcher = DeviceDispatcher() let userDefaultsManager = UserDefaultsManager(userDefaults: MockUserDefaults()) let deviceStore = DeviceStore(dispatcher: deviceDispatcher, userDefaultsManager: userDefaultsManager) let trackingModel = TrackingModel(trackingActionCreator: trackingActionCreator, deviceStore: deviceStore) self.videModel = RepositorySearchViewModel(viewDidAppear: viewDidAppear.asObservable(), trackingModel: trackingModel) self.trackingContainer = BehaviorRelay(value: nil) self.disposeBag = DisposeBag() dependency.trackingDispatcher.trackingContainer .bind(to: trackingContainer) .disposed(by: disposeBag) } }
  99. final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay<TrackingContainer?>! private

    var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay<Bool>! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay<Bool>() self.trackingDispatcher = TrackingDispatcher() let trackingActionCreator = TrackingActionCreator(dispatcher: trackingDispatcher, tracker: MockTracker()) let deviceDispatcher = DeviceDispatcher() let userDefaultsManager = UserDefaultsManager(userDefaults: MockUserDefaults()) let deviceStore = DeviceStore(dispatcher: deviceDispatcher, userDefaultsManager: userDefaultsManager) let trackingModel = TrackingModel(trackingActionCreator: trackingActionCreator, deviceStore: deviceStore) self.videModel = RepositorySearchViewModel(viewDidAppear: viewDidAppear.asObservable(), trackingModel: trackingModel) self.trackingContainer = BehaviorRelay(value: nil) self.disposeBag = DisposeBag() dependency.trackingDispatcher.trackingContainer .bind(to: trackingContainer) .disposed(by: disposeBag) } } 'MVYͷґଘͷղܾ
  100. final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay<TrackingContainer?>! private

    var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay<Bool>! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay<Bool>() self.trackingDispatcher = TrackingDispatcher() let trackingActionCreator = TrackingActionCreator(dispatcher: trackingDispatcher, tracker: MockTracker()) let deviceDispatcher = DeviceDispatcher() let userDefaultsManager = UserDefaultsManager(userDefaults: MockUserDefaults()) let deviceStore = DeviceStore(dispatcher: deviceDispatcher, userDefaultsManager: userDefaultsManager) let trackingModel = TrackingModel(trackingActionCreator: trackingActionCreator, deviceStore: deviceStore) self.videModel = RepositorySearchViewModel(viewDidAppear: viewDidAppear.asObservable(), trackingModel: trackingModel) self.trackingContainer = BehaviorRelay(value: nil) self.disposeBag = DisposeBag() dependency.trackingDispatcher.trackingContainer .bind(to: trackingContainer) .disposed(by: disposeBag) } } setUpͰ౎౓Fluxͷ ґଘΛղܾ͢Δ࣮૷͕ ௕͘ͳͬͯ͠·͏্ʹෳࡶ
  101. final class Environment { static let shared = Environment() let

    flux: Flux let trackingModel: TrackingModel init(flux: Flux = .shared, trackingModel: TrackingModel = .shared) { self.flux = flux self.trackingModel = trackingModel } }
  102. extension Environment { final class Flux { static let shared

    = Flux() let deviceActionCreator: DeviceActionCreator let deviceDispatcher: DeviceDispatcher let deviceStore: DeviceStore let trackingActionCreator: TrackingActionCreator let trackingDispatcher: TrackingDispatcher let trackingStore: TrackingStore init(deviceActionCreator: DeviceActionCreator = .shared, deviceDispatcher: DeviceDispatcher = .shared, deviceStore: DeviceStore = .shared, trackingActionCreator: TrackingActionCreator = .shared, trackingDispatcher: TrackingDispatcher = .shared, trackingStore: TrackingStore = .shared) { self.deviceActionCreator = deviceActionCreator self.deviceDispatcher = deviceDispatcher self.deviceStore = deviceStore self.trackingActionCreator = trackingActionCreator self.trackingDispatcher = trackingDispatcher self.trackingStore = trackingStore } } }
  103. extension Environment.Flux { static func mock(userDefaults: MockUserDefaults = .init(), tracker:

    MockTracker = .init()) -> Environment.Flux { let deviceDispatcher = DeviceDispatcher() let deviceActionCreator = DeviceActionCreator(dispatcher: deviceDispatcher) let userDefaultsManager = UserDefaultsManager(userDefaults: userDefaults) let deviceStore = DeviceStore(dispatcher: deviceDispatcher, userDefaultsManager: userDefaultsManager) let trackingDispatcher = TrackingDispatcher() let trackingActionCreator = TrackingActionCreator(dispatcher: trackingDispatcher, tracker: tracker) let trackingStore = TrackingStore(dispatcher: trackingDispatcher) return Environment.Flux(deviceActionCreator: deviceActionCreator, deviceDispatcher: deviceDispatcher, deviceStore: deviceStore, trackingActionCreator: trackingActionCreator, trackingDispatcher: trackingDispatcher, trackingStore: trackingStore) } }
  104. extension Environment { static func mock(flux: Flux = .mock()) ->

    Environment { let trackingModel = TrackingModel(flux: flux) return Environment(flux: flux, trackingModel: trackingModel) } }
  105. final class TrackingModel { // old init(trackingActionCreator: TrackingActionCreator = .shared,

    deviceStore: DeviceStore = .shared) { self.trackingActionCreator = trackingActionCreator self.deviceStore = deviceStore } // new init(flux: Environment.Flux = .shared) { self.trackingActionCreator = flux.trackingActionCreator self.deviceStore = flux.deviceStore } }
  106. // new init(viewDidAppear: Observable<Bool>, environment: Environment = .shared) { let

    trackingModel = environment.trackingModel viewDidAppear .map { _ in TrackingEvent.pageView(.repositorySearch) } .subscribe(onNext: { trackingModel.sendTrackingEvent($0) }) .disposed(by: disposeBag) } // old init(viewDidAppear: Observable<Bool>, trackingModel: TrackingModel = .shared) { viewDidAppear .map { _ in TrackingEvent.pageView(.repositorySearch) } .subscribe(onNext: { trackingModel.sendTrackingEvent($0) }) .disposed(by: disposeBag) }
  107. override func setUp() { super.setUp() let flux = Environment.Flux.mock() self.trackingDispatcher

    = flux.trackingDispatcher self.viewDidAppear = PublishRelay<Bool>() self.videModel = RepositorySearchViewModel(viewDidAppear: viewDidAppear.asObservable(), environment: Environment.mock(flux: flux)) self.trackingContainer = BehaviorRelay(value: nil) self.disposeBag = DisposeBag() trackingDispatcher.trackingContainer .bind(to: trackingContainer) .disposed(by: disposeBag) }
  108. override func setUp() { super.setUp() let flux = Environment.Flux.mock() self.trackingDispatcher

    = flux.trackingDispatcher self.viewDidAppear = PublishRelay<Bool>() self.videModel = RepositorySearchViewModel(viewDidAppear: viewDidAppear.asObservable(), environment: Environment.mock(flux: flux)) self.trackingContainer = BehaviorRelay(value: nil) self.disposeBag = DisposeBag() trackingDispatcher.trackingContainer .bind(to: trackingContainer) .disposed(by: disposeBag) } 7JFX.PEFMʹґଘ͍ͯ͠Δ෦෼Λ·ͩ·ͱΊΒΕͦ͏
  109. extension RepositorySearchViewModelTestCase { private struct Dependency { let viewDidAppear =

    PublishRelay<Bool>() let trackingDispatcher: TrackingDispatcher let viewModel: RepositorySearchViewModel init() { let flux = Environment.Flux.mock() self.trackingDispatcher = flux.trackingDispatcher self.viewModel = RepositorySearchViewModel(viewDidAppear: viewDidAppear.asObservable(), environment: Environment.mock(flux: flux)) } } }
  110. private var dependency: Dependency! private var trackingContainer: BehaviorRelay<TrackingContainer?>! private var

    disposeBag: DisposeBag! override func setUp() { super.setUp() dependency = Dependency() trackingContainer = BehaviorRelay(value: nil) disposeBag = DisposeBag() dependency.trackingDispatcher.trackingContainer .bind(to: trackingContainer) .disposed(by: disposeBag) }
  111. ґଘΛ஫ೖͨͯ͘͠΋ ஫ೖ͠ʹ͍͘Օॴ͕

  112. ΞϓϦىಈͳͲͷ ςετΛ͢ΔͨΊʹ AppDelegateʹ ΦϒδΣΫτΛ ஫ೖ͍ͨ͠

  113. @UIApplicationMain final class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { self.window = { let window = UIWindow(frame: UIScreen.main.bounds) window.rootViewController = RootViewController() window.makeKeyAndVisible() return window }() return true } func applicationDidEnterBackground(_ application: UIApplication) { TrackingModel.shared.sendTrackingEvent(.background) } func applicationDidBecomeActive(_ application: UIApplication) { TrackingModel.shared.sendTrackingEvent(.launch) } }
  114. @UIApplicationMain final class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow?

    { get { return handler.window } set { fatalError("setter must not be called") } } private lazy var handler = _AppDelegate() func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { return handler.application(application, didFinishLaunchingWithOptions: launchOptions) } func applicationDidEnterBackground(_ application: UIApplication) { handler.applicationDidEnterBackground(application) } func applicationDidBecomeActive(_ application: UIApplication) { handler.applicationDidBecomeActive(application) } }
  115. final class _AppDelegate { private(set) lazy var window: UIWindow =

    { let window = UIWindow(frame: UIScreen.main.bounds) window.rootViewController = rootViewController window.makeKeyAndVisible() return window }() private(set) lazy var rootViewController: UIViewController = RootViewController() private let trackingModel: TrackingModel init(rootViewController: UIViewController? = nil, environment: Environment = .shared) { self.trackingModel = environment.trackingModel if let rootViewController = rootViewController { self.rootViewController = rootViewController } } }
  116. extension _AppDelegate { func application(_ application: ApplicationType, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey:

    Any]?) -> Bool { _ = window return true } func applicationDidEnterBackground(_ application: ApplicationType) { trackingModel.sendTrackingEvent(.background) } func applicationDidBecomeActive(_ application: ApplicationType) { trackingModel.sendTrackingEvent(.launch) } }
  117. protocol ApplicationType: class {} extension UIApplication: ApplicationType {}

  118. extension _AppDelegateTestCase { func test_TrackingEvent_background() { dependency.appDelegate.applicationDidEnterBackground(MockApplication()) guard let value

    = trackingContainer.value else { XCTFail("trackingContainer.value is nil") return } guard case .background = value.event else { XCTFail("value.event must be TrackingEvent.background, but it is \(value.event)") return } } func test_TrackingEvent_launch() { dependency.appDelegate.applicationDidBecomeActive(MockApplication()) guard let value = trackingContainer.value else { XCTFail("trackingContainer.value is nil") return } guard case .launch = value.event else { XCTFail("value.event must be TrackingEvent.launch, but it is \(value.event)") return } } }
  119. extension _AppDelegateTestCase { func test_TrackingEvent_background() { appDelegate.applicationDidEnterBackground(MockApplication()) guard let value

    = trackingContainer.value else { XCTFail("trackingContainer.value is nil") return } guard case .background = value.event else { XCTFail("value.event must be TrackingEvent.background, but it is \(value.event)") return } } func test_TrackingEvent_launch() { appDelegate.applicationDidBecomeActive(MockApplication()) guard let value = trackingContainer.value else { XCTFail("trackingContainer.value is nil") return } guard case .launch = value.event else { XCTFail("value.event must be TrackingEvent.launch, but it is \(value.event)") return } } } AppDelegate͸ _AppDeleagateʹ Proxy͍ͯ͠Δ͚ͩͳͷͰ @AppDeleagateͷ ςετΛ͢Ε͹ྑ͍
  120. extension _AppDelegateTestCase { func test_TrackingEvent_background() { appDelegate.applicationDidEnterBackground(MockApplication()) guard let value

    = trackingContainer.value else { XCTFail("trackingContainer.value is nil") return } guard case .background = value.event else { XCTFail("value.event must be TrackingEvent.background, but it is \(value.event)") return } } func test_TrackingEvent_launch() { appDelegate.applicationDidBecomeActive(MockApplication()) guard let value = trackingContainer.value else { XCTFail("trackingContainer.value is nil") return } guard case .launch = value.event else { XCTFail("value.event must be TrackingEvent.launch, but it is \(value.event)") return } } } ֎෦ͷFrameworkͳͲΛ ࢖͍ͬͯΔ͜ͱʹΑͬͯ ςετ͕͠೉͍
  121. import UserNotifications final class UserNotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate { private let

    trackingModel: TrackingModel init(environment: Environment = .shared) { self.trackingModel = environment.trackingModel } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let request = response.notification.request trackingModel.sendTrackingEvent(.notification(identifier: request.identifier, userInfo: request.content.userInfo)) completionHandler() } }
  122. None
  123. final class _UserNotificationCenterDelegate { private let trackingModel: TrackingModel init(environment: Environment)

    { self.trackingModel = environment.trackingModel } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: NotificationData, withCompletionHandler completionHandler: @escaping () -> Void) { trackingModel.sendTrackingEvent(.notification(identifier: response.identifier, userInfo: response.userInfo)) completionHandler() } } extension _UserNotificationCenterDelegate { struct NotificationData { let identifier: String let userInfo: [AnyHashable: Any] } }
  124. final class UserNotificationCenterDelegate: NSObject, UNUserNotificationCenterDelegate { private typealias NotificationDate =

    _UserNotificationCenterDelegate.NotificationDate private let handler: _UserNotificationCenterDelegate init(environment: Environment = .shared) { self.handler = _UserNotificationCenterDelegate(environment: environment) } func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { let request = response.notification.request let data = NotificationDate(identifier: request.identifier, userInfo: request.content.userInfo) handler.userNotificationCenter(center, didReceive: data, withCompletionHandler: completionHandler) } }
  125. func testTrackingEvent_notification() { let data = Delegate.NotificationDate(identifier: "test-id", userInfo: [:])

    dependency.delegate.userNotificationCenter(.current(), didReceive: data, withCompletionHandler: {}) guard let value = trackingContainer.value else { XCTFail("trackingContainer.value is nil") return } guard case let .notification(id, info) = value.event else { XCTFail(""" value.event must be TrackingEvent.notification, but it is \(value.event) """) return } XCTAssertEqual(id, data.identifier) XCTAssertEqual(info.isEmpty, data.userInfo.isEmpty) }
  126. ϩάͷύϥϝʔλΛ खೖྗͯ͠͠·͍͕ͪ

  127. extension TrackingEvent: Encodable { private enum CodingKeys: String, CodingKey {

    case name case value } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .launch: try container.encode("launch", forKey: .name) case .background: try container.encode("background", forKey: .name) case let .pageView(value): try container.encode("page-view", forKey: .name) try container.encode(value, forKey: .value) case let .search(value): try container.encode("search", forKey: .name) try container.encode(value, forKey: .value) } } }
  128. extension TrackingEvent: Encodable { private enum CodingKeys: String, CodingKey {

    case name case value } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case .launch: try container.encode("launch", forKey: .name) case .background: try container.encode("background", forKey: .name) case let .pageView(value): try container.encode("page-view", forKey: .name) try container.encode(value, forKey: .value) case let .search(value): try container.encode("search", forKey: .name) try container.encode(value, forKey: .value) } } } ΋͠λΠϙ͍ͯͨ͠Β
  129. gtm-definitions gtm-swift carthage developed by rinov Sourcery

  130. gtm-definitions gtm-swift carthage developed by rinov Sourcery (5.ͷఆٛΛ؅ཧπʔϧ͔Βऔಘ͠ υΩϡϝϯτٴͼ+40/4DIFNBΛ࡞੒

  131. gtm-definitions gtm-swift carthage developed by rinov Sourcery +40/4DIFNB͔Β RVJDLUZQFΛར༻͠ 4XJGUͷΦϒδΣΫτ

    Λࣗಈੜ੒͠4PVSDFSZ Λ࢖࣮ͬͯࡍʹར༻͢Δ ͨΊͷߏ଄Խ
  132. gtm-definitions gtm-swift carthage Sourcery GSBNFXPSLͱͯ͠ΞϓϦऔΓࠐΈɺར༻ developed by rinov

  133. ϩάͷςετ΋ॻ͍ͯ ΑΓ඼࣭ͷߴ͍ΞϓϦΛ ໨ࢦ͠·͠ΐ͏

  134. ͝ਗ਼ௌ͋Γ͕ͱ͏͍͟͝·ͨ͠