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.

79fe3c13c618a61329298bdd6a86ec42?s=128

Bas Broek

April 04, 2019
Tweet

Transcript

  1. TESTING THE UNTESTABLE 1 — @basthomas

  2. THIS IS A PARADOX 2 — @basthomas

  3. TESTING THE SEEMINGLY UNTESTABLE 3 — @basthomas

  4. COMMUNICATION IS HARD 4 — @basthomas

  5. COMMUNICATION IS HARD 5 — @basthomas

  6. TESTING IS HARD 6 — @basthomas

  7. WRITING TESTABLE CODE IS HARD 7 — @basthomas

  8. TESTABLE CODE IS NICE 8 — @basthomas

  9. LET'S JUMP INTO IT. 9 — @basthomas

  10. THE JOURNEY 10 — @basthomas

  11. THE JOURNEY ▸ What do I want to test? 10

    — @basthomas
  12. THE JOURNEY ▸ What do I want to test? ▸

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

    How do I want to test it? ▸ Does this increase my confidence? 10 — @basthomas
  14. 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
  15. WHAT PROBLEM ARE YOU TRYING TO SOLVE? 11 — @basthomas

  16. WHAT DOES THE CURRENT SITUATION LOOK LIKE? 12 — @basthomas

  17. ! 5 October 2016 13 — @basthomas

  18. ! NO TESTS IN SIGHT 14 — @basthomas

  19. ! BREAKING THINGS UP registerForRemoteNotificationsWithCompletion; + requestAuthorization; + handleNotificationAuthorization; 15

    — @basthomas
  20. MOVING THE IMPLEMENTATION // registerForRemoteNotificationsWithCompletion BOOL shouldImmediatelyRequestNotificationAccess = [ABTestCentral isFeatureEnabled:EnableImmediateNotificationRequest];

    if (shouldImmediatelyRequestNotificationAccess || [NSUserDefaults isBrazeDisabled]) { [self requestAuthorization]; } else { [self handleNotificationAuthorization]; } 16 — @basthomas
  21. 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
  22. 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
  23. AND NOW WHAT? 19 — @basthomas

  24. I'VE BEEN HIDING SOMETHING. DEPENDENCY INJECTION. 20 — @basthomas

  25. - registerForRemoteNotificationsWithCompletion; - requestAuthorization; - handleNotificationAuthorization; + registerForRemoteNotificationsWithCompletion:user:notificationRequester:tracker:application:delegate; + requestAuthorizationViaDeeplink:notificationRequester:tracker:application;

    + handleNotificationAuthorizationViaDeeplink:notificationRequester:tracker:application; 21 — @basthomas
  26. PROTOCOLS, PROTOCOLS, PROTOCOLS 22 — @basthomas

  27. @objc public protocol NotificationSettings { var authorizationStatus: UNAuthorizationStatus { get

    } } extension UNNotificationSettings: NotificationSettings {} 23 — @basthomas
  28. @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
  29. @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
  30. 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
  31. 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
  32. class MockNotificationSettings: NotificationSettings { let authorizationStatus: UNAuthorizationStatus init(authorizationStatus: UNAuthorizationStatus) {

    self.authorizationStatus = authorizationStatus } } 28 — @basthomas
  33. 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
  34. class MockApplication: ApplicationProtocol { func registerForRemoteNotifications() { // } func

    open( _ url: URL, options: [UIApplication.OpenExternalURLOptionsKey: Any], completionHandler completion: ((Bool) -> Void)? ) { // } } 30 — @basthomas
  35. 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
  36. 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
  37. 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
  38. 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
  39. AND, WELL, THEN WE FOUND A BUG... SO IT WAS

    FIXED IN A TEST- DRIVEN WAY 35 — @basthomas
  40. 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
  41. 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
  42. ❌ 38 — @basthomas

  43. 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
  44. ✅ 40 — @basthomas

  45. TAKEAWAYS 41 — @basthomas

  46. TAKEAWAYS ▸ Testing is hard, but never impossible 41 —

    @basthomas
  47. TAKEAWAYS ▸ Testing is hard, but never impossible ▸ Writing

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

    testable code helps with more than tests ▸ Protocols and default expressions are ok 41 — @basthomas
  49. 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
  50. 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
  51. 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
  52. THANKS! @BASTHOMAS 42 — @basthomas