$30 off During Our Annual Pro Sale. View Details »

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. TESTING THE
    UNTESTABLE
    1 — @basthomas

    View Slide

  2. THIS IS A
    PARADOX
    2 — @basthomas

    View Slide

  3. TESTING THE SEEMINGLY
    UNTESTABLE
    3 — @basthomas

    View Slide

  4. COMMUNICATION
    IS HARD
    4 — @basthomas

    View Slide

  5. COMMUNICATION
    IS HARD
    5 — @basthomas

    View Slide

  6. TESTING IS HARD
    6 — @basthomas

    View Slide

  7. WRITING TESTABLE CODE IS
    HARD
    7 — @basthomas

    View Slide

  8. TESTABLE CODE
    IS NICE
    8 — @basthomas

    View Slide

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

    View Slide

  10. THE JOURNEY
    10 — @basthomas

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  15. WHAT PROBLEM ARE YOU
    TRYING TO SOLVE?
    11 — @basthomas

    View Slide

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

    View Slide

  17. !
    5 October 2016
    13 — @basthomas

    View Slide

  18. !
    NO TESTS IN SIGHT
    14 — @basthomas

    View Slide

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

    View Slide

  20. MOVING THE IMPLEMENTATION
    // registerForRemoteNotificationsWithCompletion
    BOOL shouldImmediatelyRequestNotificationAccess =
    [ABTestCentral isFeatureEnabled:EnableImmediateNotificationRequest];
    if (shouldImmediatelyRequestNotificationAccess || [NSUserDefaults isBrazeDisabled]) {
    [self requestAuthorization];
    } else {
    [self handleNotificationAuthorization];
    }
    16 — @basthomas

    View Slide

  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

    View Slide

  22. MOVING THE IMPLEMENTATION
    [requester requestNotificationSettingsWithCompletionHandler:^(id 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

    View Slide

  23. AND NOW WHAT?
    19 — @basthomas

    View Slide

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

    View Slide

  25. - registerForRemoteNotificationsWithCompletion;
    - requestAuthorization;
    - handleNotificationAuthorization;
    + registerForRemoteNotificationsWithCompletion:user:notificationRequester:tracker:application:delegate;
    + requestAuthorizationViaDeeplink:notificationRequester:tracker:application;
    + handleNotificationAuthorizationViaDeeplink:notificationRequester:tracker:application;
    21 — @basthomas

    View Slide

  26. PROTOCOLS,
    PROTOCOLS,
    PROTOCOLS
    22 — @basthomas

    View Slide

  27. @objc public protocol NotificationSettings {
    var authorizationStatus: UNAuthorizationStatus { get }
    }
    extension UNNotificationSettings: NotificationSettings {}
    23 — @basthomas

    View Slide

  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)
    }
    extension UNUserNotificationCenter: NotificationRequester {
    public func requestNotificationSettings(
    completionHandler: @escaping (NotificationSettings) -> Void
    ) {
    getNotificationSettings(completionHandler: completionHandler)
    }
    }
    24 — @basthomas

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  32. class MockNotificationSettings: NotificationSettings {
    let authorizationStatus: UNAuthorizationStatus
    init(authorizationStatus: UNAuthorizationStatus) {
    self.authorizationStatus = authorizationStatus
    }
    }
    28 — @basthomas

    View Slide

  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) {
    //
    }
    }
    29 — @basthomas

    View Slide

  34. class MockApplication: ApplicationProtocol {
    func registerForRemoteNotifications() {
    //
    }
    func open(
    _ url: URL,
    options: [UIApplication.OpenExternalURLOptionsKey: Any],
    completionHandler completion: ((Bool) -> Void)?
    ) {
    //
    }
    }
    30 — @basthomas

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  39. AND, WELL, THEN WE FOUND
    A BUG...
    SO IT WAS FIXED IN A TEST-
    DRIVEN WAY
    35 — @basthomas

    View Slide

  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

    View Slide

  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

    View Slide


  42. 38 — @basthomas

    View Slide

  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

    View Slide


  44. 40 — @basthomas

    View Slide

  45. TAKEAWAYS
    41 — @basthomas

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  52. THANKS!
    @BASTHOMAS
    42 — @basthomas

    View Slide