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

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

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

Taiki Suzuki

September 13, 2018
Tweet

More Decks by Taiki Suzuki

Other Decks in Programming

Transcript

  1. 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݄ΑΓݱ৬ɻ
  2. Route ActionCreator Dispatcher Store Tracking ActionCreator Dispatcher Store Repository ActionCreator

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

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

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

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

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

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

    Dispatcher Store Device ActionCreator Dispatcher Store RootViewController ViewModel ViewModel ViewModel
  9. { "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" } } ΞϓϦىಈ ϩάͷઃܭ
  10. { "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" } } ΞϓϦىಈ
  11. { "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" } } ΞϓϦىಈ ڞ௨ύϥϝʔλʔ
  12. { "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" } } ΞϓϦىಈ Πϕϯτ͝ͱͷύϥϝʔλʔ
  13. { … "event" : { "name" : "page-view", "value" :

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

    { "additional" : “https://github.com/marty-suzuki/MisterFusion", "page" : "repository-detail" } } } ϦϙδτϦৄࡉදࣔ
  15. @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ʹ ΞΫηεͰ͖ͳ͍
  16. @testable import GitHubClientTestSampe Ͱ͸ͳ͘ File Inspector -> Target Membership ->

    GitHubClientTestSampleUITests ͰϑΝΠϧͭͭʹ νΣοΫΛ͚ͭͳ͍ͱಈ͔ͳ͍
  17. 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() } ݕࡧը໘ͷදࣔϩά
  18. 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() }
  19. 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() } ϩά͕ൃՐ͢ΔͷΛ଴ͭ
  20. 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() } ը໘ભҠͤ͞Δ
  21. 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() } ֘౰ͷϩά͕ൃՐ͢Δͱςετ௨ա
  22. 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() } ৄࡉը໘ͷදࣔϩά
  23. 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() }
  24. 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() } ϩά͕ൃՐ͢ΔͷΛ଴ͭ
  25. 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() } ը໘ભҠͤ͞Δ
  26. 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() } ֘౰ͷϩά͕ൃՐ͢Δͱςετ௨ա
  27. 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ʹ ΞΫηεम০ࢠΛม͑ͣʹ ΞΫηε͍ͨ͠
  28. final class RepositorySearchViewController: UIViewController { @IBOutlet private weak var tableView:

    UITableView! private let searchBar: UISearchBar = { let searchBar = UISearchBar(frame: .zero) searchBar.showsCancelButton = true return searchBar }() ... }
  29. // // PrivateProperties.swift // protocol PrivatePropertyAccessible { associatedtype PrivatePropertiesCompatible var

    privateProperties: PrivateProperties<PrivatePropertiesCompatible> { get } } struct PrivateProperties<Base> { let base: Base init(_ base: Base) { self.base = base } }
  30. // // 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 } }
  31. // // 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 %}
  32. // // 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Λ࠾༻͍ͯ͠Δ ΦϒδΣΫτʹରͯ͠ॲཧΛ͢Δ
  33. // // 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 Λ࠾༻͢Δ
  34. // // 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ͳ΋ͷΛର৅ʹ͢Δ
  35. // // 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ؔ਺͔Β஋Λऔಘ͢Δ
  36. // // .sourcery.yml // sources: - GitHubClientTestSample - GitHubClientTestSampleUITests templates:

    - GitHubClientTestSampleUITests/Template/AutoPrivatePropertyAccessible.stencil output: GitHubClientTestSampleUITests/Gen/zzz.Sourcery.out.swift
  37. 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) }() }
  38. 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) } }
  39. @implementation _RuntimeHandler + (void)handleLoad { NSLog(@"Please override RuntimeHandler.handleLoad if you

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

    = ViewControllerLifeCycleHandler.swizzleOnce } }
  41. 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()
  42. 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ͷΠϯελϯεΛ଴ͭ
  43. 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Λදࣔͤ͞Δ
  44. 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͕දࣔ͞ΕΔͱݺͼग़͞ΕΔ
  45. 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")
  46. 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
  47. 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) } }
  48. 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) }
  49. final class RepositorySearchViewModel { init(viewDidAppear: Observable<Bool>, trackingModel: TrackingModel = .shared)

    { viewDidAppear .map { _ in TrackingEvent.pageView(.repositorySearch) } .subscribe(onNext: { trackingModel.sendTrackingEvent($0) }) .disposed(by: disposeBag) } }
  50. 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) } }
  51. 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ͷґଘͷղܾ
  52. 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) } } ϩάͷऔಘͷ࣮૷
  53. 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 } }
  54. 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) } }
  55. 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ͷґଘͷղܾ
  56. 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ͷ ґଘΛղܾ͢Δ࣮૷͕ ௕͘ͳͬͯ͠·͏্ʹෳࡶ
  57. 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 } }
  58. 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 } } }
  59. 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) } }
  60. extension Environment { static func mock(flux: Flux = .mock()) ->

    Environment { let trackingModel = TrackingModel(flux: flux) return Environment(flux: flux, trackingModel: trackingModel) } }
  61. 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 } }
  62. // 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) }
  63. 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) }
  64. 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ʹґଘ͍ͯ͠Δ෦෼Λ·ͩ·ͱΊΒΕͦ͏
  65. 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)) } } }
  66. 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) }
  67. @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) } }
  68. @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) } }
  69. 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 } } }
  70. 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) } }
  71. 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 } } }
  72. 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ͷ ςετΛ͢Ε͹ྑ͍
  73. 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ͳͲΛ ࢖͍ͬͯΔ͜ͱʹΑͬͯ ςετ͕͠೉͍
  74. 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() } }
  75. 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] } }
  76. 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) } }
  77. 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) }
  78. 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) } } }
  79. 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) } } } ΋͠λΠϙ͍ͯͨ͠Β