Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

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݄ΑΓݱ৬ɻ

Slide 3

Slide 3 text

ߦಈϩάͷ ςετͯ͠·͔͢ʁ

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

RouteActionCreator .shared .setRouteCommand(.repositoryDetail)

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

{ "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" } } ΞϓϦىಈ ϩάͷઃܭ

Slide 18

Slide 18 text

{ "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" } } ΞϓϦىಈ

Slide 19

Slide 19 text

{ "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" } } ΞϓϦىಈ ڞ௨ύϥϝʔλʔ

Slide 20

Slide 20 text

{ "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" } } ΞϓϦىಈ Πϕϯτ͝ͱͷύϥϝʔλʔ

Slide 21

Slide 21 text

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

Slide 22

Slide 22 text

{ … "event" : { "name" : "page-view", "value" : { "additional" : “https://github.com/marty-suzuki/MisterFusion", "page" : "repository-detail" } } } ϦϙδτϦৄࡉදࣔ

Slide 23

Slide 23 text

ViewModel TrackingModel ViewModel Tracking ActionCreator Tracker Tracking Dispatcher TrackingStore

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

@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ʹ ΞΫηεͰ͖ͳ͍

Slide 33

Slide 33 text

@testable import GitHubClientTestSampe Ͱ͸ͳ͘ File Inspector -> Target Membership -> GitHubClientTestSampleUITests ͰϑΝΠϧͭͭʹ νΣοΫΛ͚ͭͳ͍ͱಈ͔ͳ͍

Slide 34

Slide 34 text

Ͳ͏ʹ͔ͯ͠ ϩάΛऔಘͰ͖ͳ͍ ͩΖ͏͔

Slide 35

Slide 35 text

XCTestͰUITest ͢Ε͹ྑ͍͡ΌΜ

Slide 36

Slide 36 text

func testTrackingEvent_pageView_repositorySearch() { let description = "wait TrackingEvent.pageView(.repositorySearch)" let expect = expectation(description: description) let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable 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() } ݕࡧը໘ͷදࣔϩά

Slide 37

Slide 37 text

func testTrackingEvent_pageView_repositorySearch() { let description = "wait TrackingEvent.pageView(.repositorySearch)" let expect = expectation(description: description) let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable 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() }

Slide 38

Slide 38 text

func testTrackingEvent_pageView_repositorySearch() { let description = "wait TrackingEvent.pageView(.repositorySearch)" let expect = expectation(description: description) let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable 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() } ϩά͕ൃՐ͢ΔͷΛ଴ͭ

Slide 39

Slide 39 text

func testTrackingEvent_pageView_repositorySearch() { let description = "wait TrackingEvent.pageView(.repositorySearch)" let expect = expectation(description: description) let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable 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() } ը໘ભҠͤ͞Δ

Slide 40

Slide 40 text

func testTrackingEvent_pageView_repositorySearch() { let description = "wait TrackingEvent.pageView(.repositorySearch)" let expect = expectation(description: description) let disposable = TrackingDispatcher.shared.trackingContainer .flatMap { value -> Observable 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() } ֘౰ͷϩά͕ൃՐ͢Δͱςετ௨ա

Slide 41

Slide 41 text

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 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() } ৄࡉը໘ͷදࣔϩά

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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 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() } ϩά͕ൃՐ͢ΔͷΛ଴ͭ

Slide 44

Slide 44 text

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 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() } ը໘ભҠͤ͞Δ

Slide 45

Slide 45 text

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 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() } ֘౰ͷϩά͕ൃՐ͢Δͱςετ௨ա

Slide 46

Slide 46 text

pageView.mov

Slide 47

Slide 47 text

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ʹ ΞΫηεम০ࢠΛม͑ͣʹ ΞΫηε͍ͨ͠

Slide 48

Slide 48 text

final class RepositorySearchViewController: UIViewController { @IBOutlet private weak var tableView: UITableView! private let searchBar: UISearchBar = { let searchBar = UISearchBar(frame: .zero) searchBar.showsCancelButton = true return searchBar }() ... }

Slide 49

Slide 49 text

let searchVC = RepositorySearchViewController() let tableView = searchVC.privateProperties.tableView let searchBar = searchVC.privateProperties.searchBar

Slide 50

Slide 50 text

https://github.com/krzysztofzablocki/Sourcery

Slide 51

Slide 51 text

// // AutoPrivatePropertyAccessible.swift // @testable import GitHubClientTestSample protocol AutoPrivatePropertyAccessible {} extension RepositorySearchViewController: AutoPrivatePropertyAccessible {}

Slide 52

Slide 52 text

// // PrivateProperties.swift // protocol PrivatePropertyAccessible { associatedtype PrivatePropertiesCompatible var privateProperties: PrivateProperties { get } } struct PrivateProperties { let base: Base init(_ base: Base) { self.base = base } }

Slide 53

Slide 53 text

// // PrivateProperties.swift // extension PrivatePropertyAccessible where PrivatePropertiesCompatible == Self { var privateProperties: PrivateProperties { return PrivateProperties(self) } } extension PrivateProperties { func property(forKey key: String) -> T { return Mirror(reflecting: base).children.first { $0.label == key }!.value as! T } }

Slide 54

Slide 54 text

// // 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 %}

Slide 55

Slide 55 text

// // 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Λ࠾༻͍ͯ͠Δ ΦϒδΣΫτʹରͯ͠ॲཧΛ͢Δ

Slide 56

Slide 56 text

// // 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 Λ࠾༻͢Δ

Slide 57

Slide 57 text

// // 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ͳ΋ͷΛର৅ʹ͢Δ

Slide 58

Slide 58 text

// // 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ؔ਺͔Β஋Λऔಘ͢Δ

Slide 59

Slide 59 text

// // .sourcery.yml // sources: - GitHubClientTestSample - GitHubClientTestSampleUITests templates: - GitHubClientTestSampleUITests/Template/AutoPrivatePropertyAccessible.stencil output: GitHubClientTestSampleUITests/Gen/zzz.Sourcery.out.swift

Slide 60

Slide 60 text

No content

Slide 61

Slide 61 text

generate.mov

Slide 62

Slide 62 text

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

Slide 63

Slide 63 text

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

Slide 64

Slide 64 text

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

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

final class ViewControllerLifeCycleHandler { static let shared = ViewControllerLifeCycleHandler() let viewDidAppearCalled: Observable fileprivate let _viewDidAppearCalled = PublishRelay() private init() { self.viewDidAppearCalled = _viewDidAppearCalled.asObservable() } } extension UIViewController { @objc fileprivate func _swizzled_viewDidAppear(_ animated: Bool) { ViewControllerLifeCycleHandler.shared._viewDidAppearCalled.accept(self) _swizzled_viewDidAppear(animated) } }

Slide 67

Slide 67 text

ΞϓϦଆͷιʔεʹ Өڹ͕ͳ͍Α͏swizzle͢Δ λΠϛϯά͸

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

@implementation _RuntimeHandler + (void)handleLoad { NSLog(@"Please override RuntimeHandler.handleLoad if you want to use"); } @end @implementation RuntimeHandler + (void)load { [super load]; [self handleLoad]; } @end

Slide 71

Slide 71 text

extension RuntimeHandler { override open class func handleLoad() { _ = ViewControllerLifeCycleHandler.swizzleOnce } }

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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ͷΠϯελϯεΛ଴ͭ

Slide 74

Slide 74 text

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Λදࣔͤ͞Δ

Slide 75

Slide 75 text

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͕දࣔ͞ΕΔͱݺͼग़͞ΕΔ

Slide 76

Slide 76 text

https://github.com/cats-oss/Degu

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

uitest.mov

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

https://github.com/linkedin/bluepill

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

bluepill \ --xctestrun-path \ build/Build/Products/GitHubClientTestSample-UITest_iphonesimulator11.4-x86_64.xctestrun \ --output-dir output \ --num-sims 2

Slide 85

Slide 85 text

uitest.mov

Slide 86

Slide 86 text

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

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

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

Slide 89

Slide 89 text

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

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

ViewControllerʹ͸ Ͱ͖ΔݶΓը໘ߋ৽ͷ ॲཧ͚ͩΛ࣮૷͠ ͦΕҎ֎͸ViewModelʹ ࣮૷͢Δ

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

final class RepositorySearchViewModel { init(viewDidAppear: Observable, trackingModel: TrackingModel = .shared) { viewDidAppear .map { _ in TrackingEvent.pageView(.repositorySearch) } .subscribe(onNext: { trackingModel.sendTrackingEvent($0) }) .disposed(by: disposeBag) } }

Slide 94

Slide 94 text

final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay! private var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay() 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) } }

Slide 95

Slide 95 text

final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay! private var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay() 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ͷґଘͷղܾ

Slide 96

Slide 96 text

final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay! private var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay() 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) } } ϩάͷऔಘͷ࣮૷

Slide 97

Slide 97 text

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

Slide 98

Slide 98 text

final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay! private var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay() 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) } }

Slide 99

Slide 99 text

final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay! private var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay() 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ͷґଘͷղܾ

Slide 100

Slide 100 text

final class RepositorySearchViewModelTestCase: XCTestCase { private var trackingContainer: BehaviorRelay! private var disposeBag: DisposeBag! private var viewDidAppear: PublishRelay! private var trackingDispatcher: TrackingDispatcher! private var videModel: RepositorySearchViewModel! override func setUp() { super.setUp() self.viewDidAppear = PublishRelay() 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ͷ ґଘΛղܾ͢Δ࣮૷͕ ௕͘ͳͬͯ͠·͏্ʹෳࡶ

Slide 101

Slide 101 text

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

Slide 102

Slide 102 text

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

Slide 103

Slide 103 text

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

Slide 104

Slide 104 text

extension Environment { static func mock(flux: Flux = .mock()) -> Environment { let trackingModel = TrackingModel(flux: flux) return Environment(flux: flux, trackingModel: trackingModel) } }

Slide 105

Slide 105 text

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

Slide 106

Slide 106 text

// new init(viewDidAppear: Observable, 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, trackingModel: TrackingModel = .shared) { viewDidAppear .map { _ in TrackingEvent.pageView(.repositorySearch) } .subscribe(onNext: { trackingModel.sendTrackingEvent($0) }) .disposed(by: disposeBag) }

Slide 107

Slide 107 text

override func setUp() { super.setUp() let flux = Environment.Flux.mock() self.trackingDispatcher = flux.trackingDispatcher self.viewDidAppear = PublishRelay() 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) }

Slide 108

Slide 108 text

override func setUp() { super.setUp() let flux = Environment.Flux.mock() self.trackingDispatcher = flux.trackingDispatcher self.viewDidAppear = PublishRelay() 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ʹґଘ͍ͯ͠Δ෦෼Λ·ͩ·ͱΊΒΕͦ͏

Slide 109

Slide 109 text

extension RepositorySearchViewModelTestCase { private struct Dependency { let viewDidAppear = PublishRelay() 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)) } } }

Slide 110

Slide 110 text

private var dependency: Dependency! private var trackingContainer: BehaviorRelay! 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) }

Slide 111

Slide 111 text

ґଘΛ஫ೖͨͯ͘͠΋ ஫ೖ͠ʹ͍͘Օॴ͕

Slide 112

Slide 112 text

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

Slide 113

Slide 113 text

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

Slide 114

Slide 114 text

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

Slide 115

Slide 115 text

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

Slide 116

Slide 116 text

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

Slide 117

Slide 117 text

protocol ApplicationType: class {} extension UIApplication: ApplicationType {}

Slide 118

Slide 118 text

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

Slide 119

Slide 119 text

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ͷ ςετΛ͢Ε͹ྑ͍

Slide 120

Slide 120 text

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ͳͲΛ ࢖͍ͬͯΔ͜ͱʹΑͬͯ ςετ͕͠೉͍

Slide 121

Slide 121 text

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

Slide 122

Slide 122 text

No content

Slide 123

Slide 123 text

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

Slide 124

Slide 124 text

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

Slide 125

Slide 125 text

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

Slide 126

Slide 126 text

ϩάͷύϥϝʔλΛ खೖྗͯ͠͠·͍͕ͪ

Slide 127

Slide 127 text

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

Slide 128

Slide 128 text

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) } } } ΋͠λΠϙ͍ͯͨ͠Β

Slide 129

Slide 129 text

gtm-definitions gtm-swift carthage developed by rinov Sourcery

Slide 130

Slide 130 text

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

Slide 131

Slide 131 text

gtm-definitions gtm-swift carthage developed by rinov Sourcery +40/4DIFNB͔Β RVJDLUZQFΛར༻͠ 4XJGUͷΦϒδΣΫτ Λࣗಈੜ੒͠4PVSDFSZ Λ࢖࣮ͬͯࡍʹར༻͢Δ ͨΊͷߏ଄Խ

Slide 132

Slide 132 text

gtm-definitions gtm-swift carthage Sourcery GSBNFXPSLͱͯ͠ΞϓϦऔΓࠐΈɺར༻ developed by rinov

Slide 133

Slide 133 text

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

Slide 134

Slide 134 text

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