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]

@hacknicity
Copy link

That's a great explanation of how scenes seem to work and fits with the behaviour I've seen. Your new approach certainly sounds promising. I'm putting my attempts on hold for a while so I can work on other things. I look forward to hearing how it goes! Thanks again.

@codeman9
Copy link

Wow, very thorough explanation! I can't wait to see how it turns out.

@HiddenJester
Copy link
Author

Nope, I think I have to just call the ball on this whole mess. Here's the thing: I'm seeing test runs now that don't execute the code in main.swift. It just jumps straight into the production app delegate and connects to the production scene delegate, and then the tests never even finishes executing. 🤷‍♂️

I think this behavior is new to Xcode 11.3.1, I certainly wasn't seeing anything like this before. I'm still having the issue @hacknicity mentioned about the console log breaking when I add a second window, but a breakpoint at the UIApplicationMain call doesn't even trigger now when I run unit tests. (But it does when I debug the app and other breakpoints still trigger during a test run.)

FWIW, I sort of routed around the printing issue by A ) liberally using breakpoints and B ) changing my view struct to this:

struct ContentView: View {
    enum ViewType: String {
        case production = "Production"
        case testing = "Testing"
    }
    
    let type: ViewType

    let index: Int

    var body: some View {
        Text("This is \(type.rawValue) View \(index)")
    }
}

Both SceneDelegates keep a static count of how many views they have created and I use that to set index when I create the window. That way I know 💯 which scene delegate created the view and how many views exist of that type. So that helps a bit, I can see on-screen when the unit test creates the production app delegate that the UI matches that claim.

--

Furthermore, calling UIApplication.requestSceneSessionDestruction(_:, options) is not working as documented. Specifically the docs say:

If the specified scene is onscreen, calling this method dismisses it using the specified options. The method sends a disconnect notification to the scene and then calls your app delegate's application(_:didDiscardSceneSessions:) method.

But I see neither the disconnect notification, nor the app delegate call.

So … I think unit testing on multiwindow apps is just fairly badly broken right now.

As a last comment, I'm troubled because I can't tell what _removeSessionFromSessionSet actually does. The only thing I can find that looks like a "session set" is UIApplication.openSessions and it's definitely not editing that. The next best concept is UIApplication.connectedScenes which is a set of UIScene objects, which you could walk to derive a set of "connected Sessions". So I'm never 💯 certain that calling it isn't breaking some other sort of structure.

@hacknicity
Copy link

hacknicity commented Jan 22, 2020

Here's the thing: I'm seeing test runs now that don't execute the code in main.swift

My simple test program still seems to work correctly using Xcode 11.3.1. Did you remember to remove the @UIApplicationMain annotation from the AppDelegate class? If that is present then I guess that would bypass your main.swift.

As a last comment, I'm troubled because I can't tell what _removeSessionFromSessionSet actually does.

I don't either! As an aside, I think the snapshots in the task switcher only get taken when the app goes to the background (not when it is killed from Xcode), so they might be misleading.

@codeman9
Copy link

codeman9 commented Jan 22, 2020

I found that _removeSessionFromSessionSet was modifying the UIApplication.openSessions object when I was using it. Of course, I use a subclass of UIApplication like the one below.

import Foundation
import Swinject
import UIKit
import TesterApp1

@objc(TestApplication)
class TestApplication: UIApplication, ApplicationRoot {
    var strongDelegate: UIApplicationDelegate?
    private var assembler: Assembler!

    override init() {
        super.init()

        if let sceneSession = openSessions.first {
            self.perform(Selector(("_removeSessionFromSessionSet:")), with: sceneSession)
        }

        assembler = createAssembler()

        strongDelegate = assembler.resolver.resolve(TestAppDelegate.self)!
        delegate = strongDelegate
    }
}

checking with a breakpoint before the call and after the call shows the openSessions set changed. Also, I haven't tried this on an iPad app.

(lldb) po openSessions
▿ 1 element
  - 0 : <UISceneSession: 0x6000016b7380; scene = (null); role = UIWindowSceneSessionRoleApplication; sceneConfiguration = <UISceneConfiguration: 0x6000016b6280>; persistentIdentifier = 5EADC6C2-2218-4A7F-B090-6AC60C4C0FB5; userInfo = <(null): 0x0>

(lldb) po openSessions
0 elements

(lldb) 

@getOffIt
Copy link

I've just started looking into this issue now as I have to put together a small iOS App after not opening Xcode for a couple of years. Sight..
Anyways, I've created a new project using the singleView template and selecting Storyboard & Unit tests.
This created a bunch of boilerplate, linking and launching the view controller via scenes.
I have proceeded to remove any reference to storyboard and scene from the info.plist and from the AppDelegate.
This let's me run my test without any magic interference from the system. good start.

Next step to make this usable is to extract the storyboard related values into build configs $() that will be the default values for the Debug & Release schemes but empty for the new Test scheme.
Also, changing the bundle identifier inside the test scheme will help sidestep the issues with the system caching the different scenes.

I will update once I get it working

@jaylyerly
Copy link

I've sort stumbled into a workaround by accident. My SceneDelegate (the production one) looks like this

func scene(_ scene: UIScene,
               willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else {
            logger.assert("Failed to get windowScene when bootstrapping a new scene")
            return
        }
        
        window = UIWindow(frame: windowScene.coordinateSpace.bounds)
        window?.windowScene = windowScene
        
        let appDelegate = UIApplication.shared.delegate as? AppDelegate
        if let appDelegate = UIApplication.shared.delegate as? AppDelegate {
            // do production things
            let mainVC = ViewControllerFactory.main()
            window?.rootViewController = mainVC
        } else {
            // Sometimes the production SceneDelegate gets used for testing
            // so just go with it.
            logger.error("Failed to get appEnv for sceneEnv creation. Are we testing?")
            window?.rootViewController = ViewController()
        }
                
        window?.makeKeyAndVisible()
    }

For production, I need to get some app-wide state from via the AppDelegate, so I'm checking for that by class. But during testing, that's TestingAppDelegate so the if falls through to the else block and I can just through up a vanilla view controller. Unit tests can run unencumbered by any of the setup and side effects of my main view controller.

Still not ideal, since now there are two SceneDelegates that are sort of chosen at random, but this seems to be a stable workaround (he says optimistically after 5 minutes of use).

@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