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.
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.
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.
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.
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.
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
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
.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.