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

Testing the Untestable

Testing the Untestable

Presented at CocoaHeads Hamburg.

A journey making hard-to-test code testable.

Bas Broek

April 04, 2019
Tweet

More Decks by Bas Broek

Other Decks in Programming

Transcript

  1. THE JOURNEY ▸ What do I want to test? ▸

    How do I want to test it? 10 — @basthomas
  2. THE JOURNEY ▸ What do I want to test? ▸

    How do I want to test it? ▸ Does this increase my confidence? 10 — @basthomas
  3. THE JOURNEY ▸ What do I want to test? ▸

    How do I want to test it? ▸ Does this increase my confidence? ▸ ... where do I start? 10 — @basthomas
  4. MOVING THE IMPLEMENTATION // registerForRemoteNotificationsWithCompletion BOOL shouldImmediatelyRequestNotificationAccess = [ABTestCentral isFeatureEnabled:EnableImmediateNotificationRequest];

    if (shouldImmediatelyRequestNotificationAccess || [NSUserDefaults isBrazeDisabled]) { [self requestAuthorization]; } else { [self handleNotificationAuthorization]; } 16 — @basthomas
  5. MOVING THE IMPLEMENTATION // requestAuthorization UNAuthorizationOptions options = [UNUserNotificationCenter defaultOptions];

    NSMutableSet *categories = [[NSMutableSet alloc] init]; // some userNotification categories [requester setNotificationCategories:categories]; [requester requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError *_Nullable error) { if (self.registrationCompletionHandler && error != nil) { self.registrationCompletionHandler(nil, error); self.registrationCompletionHandler = nil; return; } [self handleNotificationAuthorization]; }]; 17 — @basthomas
  6. MOVING THE IMPLEMENTATION [requester requestNotificationSettingsWithCompletionHandler:^(id <NotificationSettings> settings) { UNAuthorizationStatus status

    = settings.authorizationStatus; if (status != UNAuthorizationStatusAuthorized) { NSError *error = [[NSError alloc] initWithDomain:@"UnauthorizedPushError" code:404 userInfo:@{}]; if (self.registrationCompletionHandler) { self.registrationCompletionHandler(nil, error); } // FIXME: Should the `registrationCompletionHandler` be nilled // here, like in all other cases after it has been called? return; } dispatch_async(dispatch_get_main_queue(), ^{ [application registerForRemoteNotifications]; }); }]; 18 — @basthomas
  7. @objc public protocol NotificationSettings { var authorizationStatus: UNAuthorizationStatus { get

    } } extension UNNotificationSettings: NotificationSettings {} 23 — @basthomas
  8. @objc public protocol NotificationRequester { func requestAuthorization( options: UNAuthorizationOptions, completionHandler:

    @escaping (Bool, Error?) -> Void ) /// This is expected to be a shim around `getNotificationSettings` that is /// usable from Objective-C. Due to selectors, we can not shadow the name /// with only a different type as a closure parameter. func requestNotificationSettings( completionHandler: @escaping (NotificationSettings) -> Void ) func setNotificationCategories(_ categories: Set<UNNotificationCategory>) } extension UNUserNotificationCenter: NotificationRequester { public func requestNotificationSettings( completionHandler: @escaping (NotificationSettings) -> Void ) { getNotificationSettings(completionHandler: completionHandler) } } 24 — @basthomas
  9. @objc public protocol ApplicationProtocol { @objc(openURL:options:completionHandler:) func open( _ url:

    URL, options: [UIApplication.OpenExternalURLOptionsKey: Any], completionHandler completion: ((Bool) -> Void)? ) func registerForRemoteNotifications() } extension UIApplication: ApplicationProtocol {} 25 — @basthomas
  10. CREATING THE MOCKS class MockRemoteNotificationController: NSObject, XNGApplicationRemoteNotificationsDelegate { enum RequestedViaDeeplink

    { case notDetermined case `true` case `false` } var didCallRequestAuthorization = false var didCallHandleNotificationAuthorization = false var didCallRequestAuthorizationViaDeeplink: RequestedViaDeeplink = .notDetermined var didCallHandleNotificationAuthorizationViaDeeplink: RequestedViaDeeplink = .notDetermined // omitting `registerForRemoteNotifications`, as it is not used. 26 — @basthomas
  11. func requestAuthorization( viaDeeplink requestedViaDeeplink: Bool, notificationRequester requester: NotificationRequester = MockNotificationRequester(),

    tracker: XNGTrackerProtocol = MockTracker(), application: ApplicationProtocol = MockApplication() ) { didCallRequestAuthorization = true didCallRequestAuthorizationViaDeeplink = requestedViaDeeplink ? .true : .false } func handleNotificationAuthorization( viaDeeplink handlingViaDeeplink: Bool, notificationRequester requester: NotificationRequester, tracker: XNGTrackerProtocol, application: ApplicationProtocol ) { didCallHandleNotificationAuthorization = true didCallHandleNotificationAuthorizationViaDeeplink = handlingViaDeeplink ? .true : .false } 27 — @basthomas
  12. class MockNotificationRequester: NotificationRequester { func requestAuthorization( options: UNAuthorizationOptions, completionHandler: @escaping

    (Bool, Error?) -> Void ) { completionHandler(true, nil) } func requestNotificationSettings( completionHandler: @escaping (NotificationSettings) -> Void ) { completionHandler(MockNotificationSettings(authorizationStatus: .authorized)) } func setNotificationCategories(_ categories: Set<UNNotificationCategory>) { // } } 29 — @basthomas
  13. class MockApplication: ApplicationProtocol { func registerForRemoteNotifications() { // } func

    open( _ url: URL, options: [UIApplication.OpenExternalURLOptionsKey: Any], completionHandler completion: ((Bool) -> Void)? ) { // } } 30 — @basthomas
  14. THE TESTS func test_thatTheDeeplink_doesNotTriggerRequest_whenNotLoggedIn() { // without a logged in

    user CurrentUserTestHelper.stubCurrentUserAsNil() // given I call request notifications SettingsModule.requestNotifications( with: mockRemoteNotificationController, user: CurrentUser.current() ) // then XCTAssertFalse(mockRemoteNotificationController.didCallRequestAuthorization) XCTAssertEqual( mockRemoteNotificationController.didCallRequestAuthorizationViaDeeplink, .notDetermined ) } 31 — @basthomas
  15. func test_thatTheDeeplink_doesNotTriggerRequest_whenNotLoggedIn() { // without a logged in user CurrentUserTestHelper.stubCurrentUserAsNil()

    // given I call request notifications SettingsModule.requestNotifications( with: mockRemoteNotificationController, user: CurrentUser.current() ) // then XCTAssertFalse(mockRemoteNotificationController.didCallRequestAuthorization) XCTAssertEqual( mockRemoteNotificationController.didCallRequestAuthorizationViaDeeplink, .notDetermined ) } 32 — @basthomas
  16. func test_thatHandlingNotificationAuthorization_viaDeeplink_doesTrack() { guard let delegate = ProxyAppDelegate.sharedInstance().remoteNotificationsDelegate else {

    return XCTFail("Expected to have a non-nil delegate.") } XCTAssertNotNil(delegate.requestAuthorization) // given we request notifications with deeplink = true let mockTracker = MockTracker() delegate.requestAuthorization?( viaDeeplink: true, notificationRequester: mockNotificationRequester, tracker: mockTracker, application: mockApplication ) // it SHOULD track let status = UNAuthorizationStatus.authorized let trackAction = UNUserNotificationCenter.description(for: status) XCTAssertTrue(mockTracker.didLog(origin: trackOrigin, action: trackAction)) } 33 — @basthomas
  17. func test_thatHandlingNotificationAuthorization_viaDeeplink_doesTrack() { guard let delegate = ProxyAppDelegate.sharedInstance().remoteNotificationsDelegate else {

    return XCTFail("Expected to have a non-nil delegate.") } XCTAssertNotNil(delegate.requestAuthorization) // given we request notifications with deeplink = true let mockTracker = MockTracker() delegate.requestAuthorization?( viaDeeplink: true, notificationRequester: mockNotificationRequester, tracker: mockTracker, application: mockApplication ) // it SHOULD track let status = UNAuthorizationStatus.authorized let trackAction = UNUserNotificationCenter.description(for: status) XCTAssertTrue(mockTracker.didLog(origin: trackOrigin, action: trackAction)) } 34 — @basthomas
  18. AND, WELL, THEN WE FOUND A BUG... SO IT WAS

    FIXED IN A TEST- DRIVEN WAY 35 — @basthomas
  19. func test_thatACustomPushPromptInAppMessage_shouldNotShow_ifTheFeatureSwichIsEnabled() { // There is a feature switch, "enable_immediate_notification_request",

    // that reverts to the old behavior; meaning we should not prompt // the message. let delegate = LocalInAppMessageControllerDelegate() inAppMessageModal.extras = ["custom_push_prompt": "true"] withFeatures([EnableImmediateNotificationRequest]) { XCTAssertEqual(delegate.shouldShowCustomPushPrompt( for: inAppMessageModal, notificationCenter: MockNotificationRequester(authorizationStatus: .authorized)), .shouldNotShow ) XCTAssertEqual(delegate.shouldShowCustomPushPrompt( for: inAppMessageModal, notificationCenter: MockNotificationRequester(authorizationStatus: .denied)), .shouldNotShow ) if #available(iOS 12.0, *) { XCTAssertEqual(delegate.shouldShowCustomPushPrompt( for: inAppMessageModal, notificationCenter: MockNotificationRequester(authorizationStatus: .provisional)), .shouldNotShow ) } XCTAssertEqual(delegate.shouldShowCustomPushPrompt( for: inAppMessageModal, notificationCenter: MockNotificationRequester(authorizationStatus: .notDetermined)), .shouldNotShow ) } } 36 — @basthomas
  20. func test_thatACustomPushPromptInAppMessage_shouldNotShow_ifTheFeatureSwichIsEnabled() { // There is a feature switch, "enable_immediate_notification_request",

    // that reverts to the old behavior; meaning we should not prompt // the message. let delegate = LocalInAppMessageControllerDelegate() inAppMessageModal.extras = ["custom_push_prompt": "true"] withFeatures([EnableImmediateNotificationRequest]) { XCTAssertEqual(delegate.shouldShowCustomPushPrompt( for: inAppMessageModal, notificationCenter: MockNotificationRequester(authorizationStatus: .authorized)), .shouldNotShow ) XCTAssertEqual(delegate.shouldShowCustomPushPrompt( for: inAppMessageModal, notificationCenter: MockNotificationRequester(authorizationStatus: .denied)), .shouldNotShow ) if #available(iOS 12.0, *) { XCTAssertEqual(delegate.shouldShowCustomPushPrompt( for: inAppMessageModal, notificationCenter: MockNotificationRequester(authorizationStatus: .provisional)), .shouldNotShow ) } XCTAssertEqual(delegate.shouldShowCustomPushPrompt( for: inAppMessageModal, notificationCenter: MockNotificationRequester(authorizationStatus: .notDetermined)), .shouldNotShow ) } } 37 — @basthomas
  21. func shouldShowCustomPushPrompt( for message: ABKInAppMessage, notificationCenter: NotificationRequester = UNUserNotificationCenter.current() )

    -> CustomPromptAction { + let featureSwitch = FeatureSwitch(rawValue: EnableImmediateNotificationRequest) + let shouldImmediatelyRequestNotificationAccess = ABTestCentral.isFeatureEnabled(featureSwitch) + guard shouldImmediatelyRequestNotificationAccess == false else { return .shouldNotShow } guard let dictionary = message.extras else { return .ignore } let key = "custom_push_prompt" guard let isCustomPushPromptMessageString = dictionary[key] as? String, let isCustomPushPromptMessage = Bool(isCustomPushPromptMessageString), isCustomPushPromptMessage else { return .ignore } return hasShownPushPrompt(for: notificationCenter) ? .shouldNotShow : .shouldShow } 39 — @basthomas
  22. TAKEAWAYS ▸ Testing is hard, but never impossible ▸ Writing

    testable code helps with more than tests 41 — @basthomas
  23. TAKEAWAYS ▸ Testing is hard, but never impossible ▸ Writing

    testable code helps with more than tests ▸ Protocols and default expressions are ok 41 — @basthomas
  24. TAKEAWAYS ▸ Testing is hard, but never impossible ▸ Writing

    testable code helps with more than tests ▸ Protocols and default expressions are ok ▸ Utilize assertions and preconditions 41 — @basthomas
  25. TAKEAWAYS ▸ Testing is hard, but never impossible ▸ Writing

    testable code helps with more than tests ▸ Protocols and default expressions are ok ▸ Utilize assertions and preconditions ▸ Keep side effects in check 41 — @basthomas
  26. TAKEAWAYS ▸ Testing is hard, but never impossible ▸ Writing

    testable code helps with more than tests ▸ Protocols and default expressions are ok ▸ Utilize assertions and preconditions ▸ Keep side effects in check ▸ Mixing Objective-C & Swift is still difficult 41 — @basthomas