Skip to content

Instantly share code, notes, and snippets.

@HiddenJester
Last active March 20, 2024 14:38
Show Gist options
  • Save HiddenJester/e5409ce2ca823b0003c59ce11a494b1d to your computer and use it in GitHub Desktop.
Save HiddenJester/e5409ce2ca823b0003c59ce11a494b1d to your computer and use it in GitHub Desktop.
Mocking SceneDelegate for Unit Tests on iOS 13

Replacing the SceneDelegate When Running Unit Tests

Overview

I've been working through the exercises in the excellent iOS Unit Testing by Example book by Jon Reid, which I highly recommend. However, the book is in beta at the moment and there are some curveballs thrown by iOS 13 that aren't handled in the text yet. Specifically, when I hit the section about using a testing AppDelegate class I thought "This is very good. But what about the SceneDelegate?"

In Chapter 4 the recommendation is to remove the @UIApplicationMain decoration and make a manual top-level call to UIApplicationMain. To wit:

import UIKit

let appDelegateClass: AnyClass = NSClassFromString("TestingAppDelegate") ?? AppDelegate.self

print("Custom main")
UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(appDelegateClass))

So this checks at runtime for the presence of a class named TestingAppDelegate and if such a class exists it loads it, instead of AppDelegate. In a test run the testing classes are injected into the application, and TestingAppDelegate will be available. In production, the classes are not available and the code falls back on using AppDelegate. This works great in iOS 12.

Providing a Custom SceneDelegate in iOS 13

But now iOS/iPadOS 13 has brought the SceneDelegate and replacing that is not quite as simple. TestingAppDelegate can provide a custom delegate in application(_: configurationForConnecting: options:) but iOS doesn't always call that function. Here's an implementation of the function that does work in certain cases:

    func application(_ application: UIApplication,
                     configurationForConnecting connectingSceneSession: UISceneSession,
                     options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        print("Getting scene configuration from testing app delegate.")

        let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
        config.delegateClass = TestingSceneDelegate.self

        return config
    }

NOTE: It is important that the name provided to the UISceneConfiguration match one provided in the app Info.plist file. You can override the actual delegateClass as this code indicates, but iOS will reject a configuration that doesn't match the name.

So far so good, but iOS won't always call this function. The problem is that that if the production app has been run previously on this device then iOS has already cached a scene that specifies the production SceneDelegate. And iOS will create an instance of that delegate, even when running unit tests. If you kill the (production) scene from the device's multitasking interface before running unit tests then the above code will run on the first test execution. But now the unit tests behave differently based on an external variable the tests don't control.

Sidebar: Testing Scenes and Production code

Interestingly, we don't have the converse problem. If you have created a unit testing scene that uses a test-only SceneDelegate then the next time you run the application iOS will attempt to restore the scene. The testing bundle won't be injected and iOS won't find the class to instantiate and it will emit the following error:

[SceneConfiguration] Encoded configuration for UIWindowSceneSessionRoleApplication contained a UISceneDelegate class named "(null)", but no class with that name could be found.

After that happens iOS will call AppDelegate.application(_:configurationForConnecting:options:). Since that will hit the production AppDelegate it will create a new production scene and everything is back to normal.

The Unit Test has a Production Scene Delegate Running. Now What?

To recap: If you run unit tests on device that has previous created scenes stored (ie: there are one or more app windows visible in the multitasking view), then the unit tests will be connected to those scenes, and the production SceneDelegate will be run. There doesn't seem to be any clean way to prevent that from occurring.

But not all is lost. Since you can still inject a different AppDelegate, your production SceneDelegate can check the class of the AppDelegate. If it's not the production AppDelegate than the production SceneDelegate can attempt to recover. If you're running on a device that supports multiple scenes, the solution is relatively straightforward: you can change the production SceneDelegate to request the scene destruction and then request a new scene be created:

    func scene(_ scene: UIScene,
               willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {
        
        print("Production Scene Delegate attempting scene connection.")
        /// Can't declare a var that is a View, so set up a Bool to decide whether to use the default ContentView or override the scene and create a simple
        /// testing View struct.
        var useContentView = true
        
        #if DEBUG
        let app = UIApplication.shared
        let name = session.configuration.name ?? "Unnamed"
        if let _ = app.delegate as? AppDelegate {
            print("App Delegate is production, connecting session \(name)")
        } else {
            print("App Delegate is not production but this scene delegate *IS*, skipping connection of session \(name)")
            useContentView = false
            if app.supportsMultipleScenes {
                print("\tApp supports multiple scenes, attempting to replace scene with new testing scene")

                let activationOptions = UIScene.ActivationRequestOptions()
                
                let destructionOptions = UIWindowSceneDestructionRequestOptions()
                destructionOptions.windowDismissalAnimation = .standard

                app.requestSceneSessionActivation(nil, userActivity: nil, options: activationOptions) {
                    print("Scene activation failed: \($0.localizedDescription)")
                }

                app.requestSceneSessionDestruction(session, options: destructionOptions) {
                    print("Scene destruction failed: \($0.localizedDescription)")
                }
            } else {
                print("\tApp doesn't support multiple scenes, so we're stuck with a production scene delegate.")
            }
        }
        #endif

(more code listed below)

This will work the way we'd like: the production scene is destroyed. Requesting the new scene then falls back to the AppDelegate.application(_: configurationForConnecting: options:) call and since TestingAppDelegate exists, it will create the new TestingSceneDelegate. It's not perfect because you will see both scenes exist briefly onscreen as the production scene gets destroyed. More on that (and the related useContentView variable further down.)

Note that you have to check to make sure that UIApplication.supportsMultipleScene is true, because iOS rejects calls to requestSceneSessionActivation and requestSceneSessionDestruction unless both:

  • UIApplicationSupportsMultipleScenes is set to true in the scene manifest.
  • The application is running on a device that supports multiple scenes, which appears to only be iPads running iPadOS 13 at the moment.

If you attempt to call the SceneSession calls on an iPhone you'll get the following error:

[Scene] Invalid attempt to call -[UIApplication requestSceneSessionActivation:] from an unsupported device.

The iPhone and useContentView

Honestly I don't have a 💯 satifying solution to this problem on the iPhone. But let me hit you with a theory: The SceneDelegate should be doing almost nothing on the iPhone. All it should be doing is loading a initial view. So we can get most of what we'd like by loading the simplest view possible. After going through this exercise I've decided that doing a lot of stuff in the SceneDelegate is a bit of a code smell: although you can put things in scene(_: willConnectTo: options:) or sceneWillEnterForeground(_:) you probably only should do so if it is specific multi-window functionality. For other cases, using the AppDelegate still makes more sense.

Here's the rest of the production scene(_: willConnectTo: options:) function:

(continuing from previous code block)

        print("Connecting real scene delegate.")

        // Use a UIHostingController as window root view controller.
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            if useContentView {
                let contentView = ContentView()
                window.rootViewController = UIHostingController(rootView: contentView)
            }
            else {
                print("\tDiscarding scene view, creating stub view for unit testing.")
                let stubView = Text("Dead Production View")
                window.rootViewController = UIHostingController(rootView: stubView)
            }
            
            self.window = window
            window.makeKeyAndVisible()
        }
    }

Note that I'm using SwiftUI here, but you could load a XIB file, or a storyboard, or just make a view in code. The key point is that is if useContentView is true then you just create your normal initial view. If it is false then you know you're in a unit test context, so just make some simple do-nothing view.

Conclusion

It's not a great situation. I'd hope that Apple will provide some better way to inject testing SceneDelegates in the future. There's another path that could work which is trying to replace the Info.plist file when you build the tests. But now you've changed a whole bunch of settings and you have to remember to change the Info.plist in both places. What I think would be optimal would be if the test could provide a truncated Info.plist that just overrides a few key values and the two plists would be merged together when the testing bundle is injected into the application. That would let you change the UISceneDelegateClassName value in the scene manifest, without having to replace everything else in the plist.

The whole situation on the iPhone where there is a SceneDelegate and it does receive a UISceneSession object but none of the SceneSession API is functional just seems wonky. (It's even visible at a user level. On the iPad I can call requestSceneSessionDestruction and manipulate the multitasking interface, but there's not a code equivalent on the iPhone.)

Having said all that, if you do need more functionality in the SceneDelegate for your iPadOS implementation, this provides a not-terribly-invasive way to mock that out when running unit tests. Which I think is a worthwhile goal.

If anybody finds this useful, or has a suggestion for improvements please reach out to me! You can contact me on Twitter @HiddenJester, or via email at [email protected]

@JohnnyKehr
Copy link

Did anyone find a good solution to this issue, or see if it was fixed at all in later versions of iOS / xcode?

@HiddenJester
Copy link
Author

Did anyone find a good solution to this issue, or see if it was fixed at all in later versions of iOS / Xcode?

As far as I know, no. I put it on the back burner in … early 2020 and after that everything went crazy for a bit and and I never got back to worrying about unit testing with a scene delegate in play. I'd hope it's better now, but I can't really say one way or the other.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment