How to mock UNNotificationResponse in unit tests

Issue #708

The best way to test is to not have to mock at all. The second best way is to have your own abstraction over the things you would like to test, either it is in form of protocol or some function injection.

But in case you want a quick way to test things, and want to test as real as possible, then for some cases we can be creative to mock the real objects.

One practical example is when we have some logic to handle notification, either showing or deep link user to certain screen. From iOS 10, notifications are to be delivered via UNUserNotificationCenterDelegate

1
2
3
4
5
@available(iOS 10.0, *)
public protocol UNUserNotificationCenterDelegate : NSObjectProtocol {
@available(iOS 10.0, *)
optional func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void)
}

and all we get is UNNotificationResponse which has no real way to construct it.

1
2
3
4
5
6
@available(iOS 10.0, *)
open class UNNotificationResponse : NSObject, NSCopying, NSSecureCoding {


// The notification to which the user responded.
@NSCopying open var notification: UNNotification { get }

That class inherits from NSCopying which means it is constructed from NSCoder, but how do we init it?

1
let response = UNNotificationResponse(coder: ???)

NSObject and NSCoder

The trick is, since UNNotificationResponse is NSObject subclass, it is key value compliant, and since it is also NSCopying compliant, we can make a mock coder to construct it

1
2
3
4
private final class KeyedArchiver: NSKeyedArchiver {
override func decodeObject(forKey _: String) -> Any { "" }
override func decodeInt64(forKey key: String) -> Int64 { 0 }
}

On iOS 12, we need to add decodeInt64 method, otherwise UNNotificationResponse init fails. This is not needed on iOS 14

UNNotificationResponse has a read only UNNotification, which has a readonly UNNotificationRequest, which can be constructed from a UNNotificationContent

Luckily UNNotificationContent has a counterpart UNMutableNotificationContent

Now we can make a simple extension on UNNotificationResponse to quickly create that object in tests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private extension UNNotificationResponse {
static func with(
userInfo: [AnyHashable: Any],
actionIdentifier: String = UNNotificationDefaultActionIdentifier
) throws -> UNNotificationResponse {
let content = UNMutableNotificationContent()
content.userInfo = userInfo
let request = UNNotificationRequest(
identifier: "",
content: content,
trigger: nil
)

let notification = try XCTUnwrap(UNNotification(coder: KeyedArchiver()))
notification.setValue(request, forKey: "request")

let response = try XCTUnwrap(UNNotificationResponse(coder: KeyedArchiver()))
response.setValue(notification, forKey: "notification")
response.setValue(actionIdentifier, forKey: "actionIdentifier")
return response
}
}

We can then test like normal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func testResponse() throws {
let data: [AnyHashable: Any] = [
"data": [
"type": "OPEN_ARTICLE",
"articleId": "1",
"articleType": "Fiction",
"articleTag": "1"
]
]
let response = try UNNotificationResponse.with(userInfo: data)
let centerDelegate = ArticleCenterDelegate()
centerDelegate.userNotificationCenter(
UNUserNotificationCenter.current(),
didReceive: response,
withCompletionHandler: {}
)
XCTAssertEqual(response.notification.request.content.userInfo["type"], "OPEN_ARTICLE")
XCTAssertEqual(centerDelegate.didOpenArticle, true)
}

decodeObject for key

Another way is to build a proper KeyedArchiver that checks key and return correct property. Note that we can reuse the same NSKeyedArchiver to nested properties.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private final class KeyedArchiver: NSKeyedArchiver {
let request: UNNotificationRequest
let actionIdentifier: String
let notification: UNNotification

override func decodeObject(forKey key: String) -> Any? {
switch key {
case "request":
return request
case "actionIdentifier":
return actionIdentifier
case "notification":
return UNNotification(coder: self)
default:
return nil
}
}
}

Updated at 2020-12-07 11:49:55

Comments