Skip to content

Instantly share code, notes, and snippets.

@HiddenJester
Last active August 19, 2025 03:09
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.

Revisions

  1. HiddenJester renamed this gist Nov 11, 2019. 1 changed file with 0 additions and 1 deletion.
    1 change: 0 additions & 1 deletion gistfile1.txt → UnitTestingSceneDelegateGist.md
    Original file line number Diff line number Diff line change
    @@ -147,4 +147,3 @@ It's not a great situation. I'd hope that Apple will provide some better way to
    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](https://twitter.com/HiddenJester), or via email at [[email protected]](mailto:[email protected])

  2. HiddenJester created this gist Nov 11, 2019.
    150 changes: 150 additions & 0 deletions gistfile1.txt
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,150 @@
    # 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](https://pragprog.com/book/jrlegios/ios-unit-testing-by-example), 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](https://twitter.com/HiddenJester), or via email at [[email protected]](mailto:[email protected])