Unit testing iOS 10 notifications
Asked Answered
C

4

18

In my app I wish to assert that notifications have been added in the correct format. I'd normally do this with dependency injection, but I can't think of a way to test the new UNUserNotificationCenter API.

I started to create a mock object which would capture the notification request:

import Foundation
import UserNotifications

class NotificationCenterMock: UNUserNotificationCenter {
    var request: UNNotificationRequest? = nil
    override func add(_ request: UNNotificationRequest, withCompletionHandler completionHandler: ((Error?) -> Void)? = nil) {
        self.request = request
    }
}

However, UNUserNotificationCenter has no accessible initializers I can't instantiate the mock.

I'm not even sure I can test by adding the notification request and fetching the current notifications, as the tests would need to request permission on the Simulator which would stall the tests. Currently I've refactored the notification logic into a wrapper, so I can at least mock that throughout my application and manually test.

Do I have any better options than manual testing?

Chitchat answered 29/11, 2016 at 9:46 Comment(0)
G
28

You can create a protocol for the methods you are using, and make an extension on UNUserNotificationCenter to conform to it. This protocol would act as a "bridge" between the original UNUserNotificationCenter implementation and your mock object to replace its method implementations.

Here's an example code I wrote in a playground, and works fine:

/* UNUserNotificationCenterProtocol.swift */

// This protocol allows you to use UNUserNotificationCenter, and replace the implementation of its 
// methods in you test classes.
protocol UNUserNotificationCenterProtocol: class {
  // Declare only the methods that you'll be using.
  func add(_ request: UNNotificationRequest,
           withCompletionHandler completionHandler: ((Error?) -> Void)?)
}

// The mock class that you'll be using for your test classes. Replace the method contents with your mock
// objects.
class MockNotificationCenter: UNUserNotificationCenterProtocol {

  var addRequestExpectation: XCTestExpectation?

  func add(_ request: UNNotificationRequest,
           withCompletionHandler completionHandler: ((Error?) -> Void)?) {
    // Do anything you want here for your tests, fulfill the expectation to pass the test.
    addRequestExpectation?.fulfill()
    print("Mock center log")
    completionHandler?(nil)
  }
}

// Must extend UNUserNotificationCenter to conform to this protocol in order to use it in your class.
extension UNUserNotificationCenter: UNUserNotificationCenterProtocol {
// I'm only adding this implementation to show a log message in this example. In order to use the original implementation, don't add it here.
  func add(_ request: UNNotificationRequest, withCompletionHandler completionHandler: ((Error?) -> Void)?) {
    print("Notification center log")
    completionHandler?(nil)
  }
}

/* ExampleClass.swift */

class ExampleClass {

  // Even though the type is UNUserNotificationCenterProtocol, it will take UNUserNotificationCenter type
  // because of the extension above.
  var notificationCenter: UNUserNotificationCenterProtocol = UNUserNotificationCenter.current()

  func doSomething() {
    // Create a request.
    let content = UNNotificationContent()
    let request = UNNotificationRequest(identifier: "Request",
                                           content: content,
                                           trigger: nil)
    notificationCenter.add(request) { (error: Error?) in
      // completion handler code
    }
  }
}

let exampleClass = ExampleClass()
exampleClass.doSomething() // This should log "Notification center log"

EDITED:
/* TestClass.Swift (unit test class) */

class TestClass {
  // Class being tested 
  var exampleClass: ExampleClass!    
  // Create your mock class.
  var mockNotificationCenter = MockNotificationCenter()

  func setUp() {
     super.setUp()
     exampleClass = ExampleClass()
     exampleClass.notificationCenter = mockNotificationCenter 
  }

  func testDoSomething() {
    mockNotificationCenter.addRequestExpectation = expectation(description: "Add request should've been called")
    exampleClass.doSomething()
    waitForExpectations(timeout: 1)
  }
}
// Once you run the test, the expectation will be called and "Mock Center Log" will be printed

Keep in mind that every time you use a new method, you'll have to add it to the protocol, or the compiler will complain.

Hope this helps!

Goldenseal answered 12/5, 2017 at 3:45 Comment(7)
This is a great response. Do you think it's possible to follow a similar approach to mock func getNotificationSettings(completionHandler: @escaping (UNNotificationSettings) -> Swift.Void)? I'm having trouble mocking the returned UNNotificationSettings object because it can't be instantiated.Publicspirited
Is this even a good test? From the code above I can see that your TestClass has its own doSomething() which means it is never going to call actual ExampleClass's doSomething(). Which also means that you can write whatever in TestClass's doSomething() and make it passLudwigshafen
@Publicspirited yeah you can still do that. You can create fake UNNotificationSettings but it requires a couple more steps. You can't instantiate UNNotificationsSettings because it requires an NSCoder, so for this you have to also mock NSCoder :p You can do this by subclassing NSCoder, and overriding the required methods: 'allowsKeycoding', 'decodeInt64', 'decodeObject', 'decodeBool'. Once you have that, pass it to the init on MockUNNotification settings and you are done. You'll have the same problem with UNNotification, so you can pass the same MockNSCoder.Goldenseal
@kalehv This is good because we are not testing that UNNotificationCenter works, we just want to make sure it's being called. so in this case, if you create a XCTestException, you can call .fulfill() inside doSomething. Hope that makes sense.Goldenseal
@Goldenseal Please correct me if I am wrong but my impression here is that your TestClass is trying to test if ExampleClass indeed calls add or not. If that is the case then your TestClass is not testing that.Ludwigshafen
You are right, the test was a bit redundant, and I didn't actually make it a test. Now it should be clear that we are testing ExampleClass, while mocking UNNotificationCenter and injecting it to ExampleClass. Thanks for pointing that out!Goldenseal
Did anyone manage to create mocked UNNotificationSettings? Struggling with overriding NSCoderCharla
W
3

You can utilize UNUserNotificationCenter, then setValue on the returned settings

UNUserNotificationCenter.current().getNotificationSettings(completionHandler: { settings in
    let status: UNAuthorizationStatus = .authorized
    settings.setValue(status.rawValue, forKey: "authorizationStatus")
    completionHandler(settings)
})
Washburn answered 4/2, 2020 at 14:32 Comment(1)
Isn't UNUserNotificationCenter.current() == nil in a unit testing context? It is for me.Leaseback
F
2

I managed to mock UNNotificationSettings using below

let object = UIView()
let data = try! NSKeyedArchiver.archivedData(withRootObject: object, requiringSecureCoding: false)
let coder = try! NSKeyedUnarchiver(forReadingFrom: data)
let setting = UNNotificationSettings(coder: coder)
setting?.setValue(UNAuthorizationStatus.notDetermined.rawValue, forKey: "authorizationStatus")
Ferrara answered 11/2, 2022 at 6:21 Comment(1)
I think you found out something similar to my recent solution for this: You can use class_createInstance(UNNotification.classForKeyedArchiver() ...)and cast the value as UNNotification If you want to manipulate its content and date members you can subclass UNNotification and use this same formula «changing class name and cast» to create it, then you override those members- which are open- and return whatever you wantIzmir
G
0

Although it's most probably correct to test that UNUserNotificationCenter is called and not to test that it actually works (Apple should test that), you do not need any permissions to schedule and then check the scheduled notifications. Permissions are only needed to actually display the notification (and you definitely not testing that in your unit tests).

In my unit tests, I call through to real UNUserNotificationCenter implementation and then check the scheduled notifications (UNUserNotificationCenter.current().getPendingNotificationRequests) and all of this works without any permissions and the tests run extremely quick. This approach is much faster than the one already proposed (in that sense that you need to write less code to be able to test).

Gnawing answered 25/7, 2018 at 7:10 Comment(4)
Why the downvote? I wasn't even advocating the approach of calling the real service, I merely reacted to what the author of the question said: "the tests would need to request permission on the Simulator which would stall the tests". I clarified that it wasn't the case, that you don't need any permissions to check the scheduled notifications.Gnawing
You actually need permissions to append the request to the pending notifications queue. Check againIzmir
Not only I checked that, for some time I had automated tests running on different devices using that approach. But this had been posted 3 years ago, it's possible that the newer iOS versions changed the requirements. Right now I am working on a totally different project and not doing any of that. Still, if my word isn't enough, check: https://mcmap.net/q/491476/-do-local-notifications-need-user-permission-on-ios, https://mcmap.net/q/491476/-do-local-notifications-need-user-permission-on-ios. Permissions (maybe were) required only to alert (display/sound) a notification.Gnawing
This doesn't currently workVarus

© 2022 - 2024 — McMap. All rights reserved.