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 tsanders@hiddenjester.com

@hacknicity
Copy link

hacknicity commented Jan 14, 2020

@HiddenJester Many thanks for following up. This is an interesting problem!

Although each call to SceneDelegate.scene(_, wilConnectTo: options:) only destroys a single session that's OK (-ish) because each existing UISceneSession object will cause iOS to call the production SceneDelegate which then deletes it and creates a new one that will use the testing delegate.

In my trivial sample app it appeared to me that only one scene was being replaced each time I ran the tests. Thinking about it more in light of your comments, I wonder if it might related to the tests running so quickly that the app almost immediately exits in my sample. Or it might depend on the exact arrangement of windows and whether they are split or not. My SceneDelegate code was slightly different to yours (I wasn't using UIWindowSceneDestructionRequestOptions):

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
        guard let _ = (scene as? UIWindowScene) else { return }

        let app = UIApplication.shared

        if !(app.delegate is AppDelegate) && app.supportsMultipleScenes {
            app.requestSceneSessionDestruction(session, options: nil)
        }
    }

   ....

A further thought: In normal use, I don't think we can assume that iOS will reconnect all the scenes at launch. I think part of the reason they have a distinction between sessions and scenes is to allow sessions to have a shorter lifetime than the underlying scenes. It really only needs to reconnect to the scenes that are visible. However, maybe destroying a scene will force one of the others to come to life and therefore they all get re-connected and destroyed in turn if the app runs for long enough. Perhaps that explains why you see all the scenes connected and destroyed when the destruction code is in SceneDelegate?

I did a bit more testing with my original sample app (i.e. with the private API called from TestAppDelegate's _:didFinishLaunchingWithOptions:). I enabled support for multiple scenes in the Info.plist and created several windows (including one split pair). When I ran unit tests I seem to get a new scene which shows my "Running Unit Tests..." message rather than an existing scene being re-used. After several test runs, the App Switcher still showed my original separate app scenes.

It seems like the behaviour varies depending on what state the app was left in. As you say, having a split of two app windows does behave differently. I find it hard to debug multi-window apps because Xcode sometimes doesn't show console log messages for me when there are multiple scenes. There's definitely some strange behaviour occurring when the app has created multiple windows. I'll update my article to try and explain it!

@HiddenJester
Copy link
Author

Oh yeah, my test case wastes a bunch of time to allow the system to settle down and dismiss everything. I had the same problem about the test running so fast I didn't get to see everything happen. Here's the test I use:

class AppLaunchTests: XCTestCase {
    var results = [Float]()
    let iterations = 1000000

    func killSomeTime() {
        results.removeAll()
        
        for i in 0 ..< iterations {
            results.append(Float(i) * Float(i + 1))
        }
        print("Final value \(results.last ?? 0.0)")
    }
    
    func testRunSomeComputationsSoWeBurnSomeTime() {
        sleep(1)
        killSomeTime()
        
        sleep(1)
        killSomeTime()
        
        sleep(1)
        XCTAssertEqual(results.count, iterations)
    }
}

I think you're correct that iOS will not necessarily connect all of the scenes available. I've been forcing myself to use multiple Safari windows on my iPad for a few weeks now … and to be quite honest it's a huge pain in the rear. (Specifically, I have one Safari window that is full screen, and I have one space that has Mail on the left and Safari on the right.) I don't think this user interface works very well, but it's the interface we have …

From my Safari experiments what I think happens is this:

  1. On iPads the multitasking interface now shows "spaces" which can contain multiple windows, possibly for multiple apps. If the user selects a space here then those apps are brought into the foreground and potentially reconnected to the attached scenes, if they have been disconnected. The spaces are sorted such the most recently foregrounded space is in the top right, second most recent is bottom right, and then columns to the left go back in time.
  2. On iPads you can now long press an app icon to get "Show All Windows", which looks like a filtered version of the multitasking interface. (This is also where the button to create a new scene is.) You can also get here by bringing the dock on-screen while the app is foregrounded and just tapping the app icon in the Dock. If you select a space here it works like above, and if you press the + button you will get a new space and a default scene will get created.
  3. When an app is launched in any other way (user taps icon in Springboard, url registered to app is opened, etc.) the OS picks a space containing that app and brings it to the foreground as described above. The selection mechanism seems to be "most recently foregrounded". If for example I open a link from Messages or Twitterrific or whatever, it will open in the last Safari window I used and I'll either get the Mail

So if you make two spaces for the test app, one with a full screen, and one space with the app twice in Split View and you just run the app normally … you'll connect to the scenes for the most-recently-foregrounded space. That's what I'm seeing in testing. I can control whether the unit test connects to two scenes or one by controlling which space I foregrounded immediately before the test.

This private API Selector(("_removeSessionFromSessionSet:")) doesn't seem to actually either destroy the sessions, nor disconnect them. Neither AppDelegate.application(_:, didDiscardSceneSessions:) nor SceneDelegate.sceneDidDisconnect(_:) is called when it executes, and the spaces are still shown in the multitasking interface. As far as I can tell, all invoking it does is cause the OS to call AppDelegate.application(_:, configurationForConnecting:, options:) for each scene in the space that the OS is attempting to "hydrate" if you will. But that might be good enough for our purposes, since that lets us hijack the SceneDelegate being used.

Here's what I'm going to try next:

  1. Plan on having a first unit test that is just cleaning up a space for further tests to run in. The goal is to ensure that the most recent space has a single scene that is connected to the testing SceneDelegate.
  2. The testing AppDelegate will look at each session in openSession. The first session where session.configuration.delegateClass == TestingSceneDelegate.self is true is ignored. It will call the private API on any session with a different delegate or on subsequence sessions with the testing SceneDelegate.
  3. This should result in the OS to request new configurations for any session we didn't want Note that AppDelegate.application(_:, configurationForConnecting:, options:) is called even on platforms that don't support multiple scenes when the private API call occurs. So if you run unit tests on an iPhone but find a production SceneDelegate it will still call this and end up creating a single configuration asking for the testing SceneDelegate.
  4. The testing SceneDelegate will create scenes for each configuration. Potentially that includes one unmolested session, and any sessions that were adjusted via the private API trick. Once the testing SceneDelegate has created one scene, any subsequent scenes will be created, and immediately call requestSceneSessionDestruction to destroy the second scene. At the conclusion of this we should have a space that only has a single scene, and that scene specifies the testing SceneDelegate.

So I think going through all those hoops will get a situation where the second unit test can run and know that it will only get a single scene.

@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