How to unit test @unknown default
switch cases in Swift
TLDR: Use pointers to instantiate an invalid instance of the enum
The problem
When writing a switch
statement over a non-final enum in Swift, covering all known cases is not enough to guarantee exhaustivity. If you only cover known cases, the compiler will emit a warning.
Switch covers known cases, but 'UNAuthorizationStatus' may have additional unknown values, possibly added in future versions; this is an error in the Swift 6 language mode
This is important because when you ship your app to customers, it could run on a future version of iOS that has additional cases you need to handle.
In order to sepcify how your app should handle future cases that may be added to the enum, you use an @unknown default
case.
func notificationStatus(_ permission: UNAuthorizationStatus) -> String {
switch permission {
case .authorized:
return "authorized"
case .denied:
return "denied"
case .notDetermined:
return "not determined"
case .provisional:
return "provisional"
case .ephemeral:
return "ephemeral"
@unknown default:
return "unknown case"
}
}
In principle, this is a great way to ensure that your app has clearly defined behavior against future OS versions. However, it leaves developers with a problem to solve.
If it is important to specifify how your app should behave when it encounters an unknown enum case, your unit tests should cover that behavior as well. But in principle, testing how your app handles enum cases that do not exist requires you to instantiate enum cases that don't exist.
The solution
While it sounds impossible to create an instance of an enum outside of the defined cases, it turns out it's very possible if the enum in question has a raw value that we can instantiate.
#if DEBUG
extension UNAuthorizationStatus {
// It is important to ensure this extension is not used in release builds
static var unknownForTesting: UNAuthorizationStatus {
let raw = 999 // Choose a value that doesn't correspond to the raw value of an existing enum case
let pointer = UnsafeMutablePointer<Int>.allocate(capacity: 1)
pointer.pointee = raw
defer { pointer.deallocate() }
return pointer.withMemoryRebound(to: UNAuthorizationStatus.self, capacity: 1) { $0.pointee }
}
}
#endif
If we create a raw value that doesn't correspond to any known case of our enum and assign it as the value of a pointer, then we can get an undefined instance of the enum for testing by rebinding the memory to our enum type.
Once we have access to an undefined instance of our enum, we can pass it to the function under test just like any other enum value.
struct UnitTestDefaultCasesTests {
@Test func example() async throws {
let unknownAuthStatus = UNAuthorizationStatus.unknownForTesting
#expect(notificationStatus(unknownAuthStatus) == "unknown case")
}
}