christopherthiebaut.com

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")
    }
}
Tagged with: