.st0{fill:#FFFFFF;}

How to Unit Test Scene Delegates when Swift Won’t Let You 

 October 12, 2021

by René Pirringer

2 comments

What can we do when Swift refuses to create an object we need for testing? René Pirringer has a solution for us which he found while unit testing scene delegates. —Jon

Scene Delegates: When Swift Gets in the Way

When you create a new project, Xcode generates a scene delegate. Very often, you don‘t need to change anything there. So why would you want to unit test the scene delegate?

As long as you don‘t have any custom code in the scene delegate, it’s fine not to have any unit tests. But with the first line of custom code in the scene delegate, it makes sense to also have unit tests for it. For example, I prefer to do all the UI in code and not use storyboards. This implies that the scene delegate must contain code to create the UIWindow and the root view controller. For me, this makes sense to unit test—but writing unit tests for a scene delegate is harder than expected. So let’s take a closer look at how to do this.

The first thing that we want to test is that the UIWindow instance is created when the scene(_:willConnectTo:options:) function is called. Here’s a unit test I wrote to test this behavior:

func test_window_is_created_on_willConnectTo() throws {
    // given
    let sceneDelegate = SceneDelegate()
    let scene = try createScene()
    // when
    sceneDelegate.scene(scene, willConnectTo: scene.session, options: options)
    // then
    XCTNotNil(sceneDelegate.window)
}

That looks straightforward, but what about the scene variable in this test case? How does the createScene() method create the UIScene object? When you try something like let scene = UIWindowScene() you get the error message:

'init()' is unavailable

That’s not good. How can we create a dummy object for the scene object?

If Swift Won’t Help, Why Not Objective-C?

I have to be honest: this problem bothered me for quite some time. It was driving me nuts to have some production code that was not testable. I did some ugly workarounds with a self-defined willConnect function in the production code that contained all logic from the scene(_:willConnectTo:options:). With this, I was able to test this logic in a unit test. But I wasn’t happy with this solution.

Days after implemented this workaround, I was thinking about this problem again, when I got an idea that I wanted to try. Because I’ve done iOS development for 10 years now, I’ve also done a lot of Objective-C, including some reflection. So why not try to create the UIWindowScene using Objective-C and reflection? Let’s see if this code works:

Class clazz = [UIWindowScene class];
UIWindowScene *scene = [[clazz alloc] init];
NSLog(@"scene %@", scene);

And the output was:

<UIWindowScene: 0x126714f50; scene = <(null): 0x0; identifier: (null)>; persistentIdentifier = nil; activationState = UISceneActivationStateUnattached; settingsCanvas = <UIWindowScene: 0x126714f50>; windows = (null)>

Eureka! It successfully created the object! The following code also works, to instantiate a UISceneSession:

Class clazz = [UISceneSession class];
UISceneSession *session = [[clazz alloc] init];
NSLog(@"session %@", session);

Setting the session into the new scene object was then easy, just by using setValue:forKey:

So now I was able to implement the createScene function in Objective-C to create a UIWindowScene dummy object:

- (UIWindowScene *)createScene {
    UIWindowScene *scene = [[[UIWindowScene class] alloc] init];
    UISceneSession *session = [[[UISceneSession class] alloc] init];
    [scene setValue:session forKey:@"session"];
    return scene;
}

With our good old friend Objective-C, it’s possible to create instances of objects you can’t make with Swift.

Click to Tweet

Simplifying with InstanceHelper

But it would be nice not to have to write Objective-C code to create this instance. To do this, I created a generic InstanceHelper class that you can use as shown in the following example. It implements the createScene function that we need for the unit test at the beginning of this article:

func createScene() throws -> UIWindowScene {
    let session = try InstanceHelper.create(UISceneSession.self)
    let scene = try InstanceHelper.create(UIWindowScene.self, properties: [
        "session": session
    ])
    return scene
}

The InstanceHelper implementation has 3 files: InstanceHelper.h,  InstanceHelper.m, and InstanceHelper.swift.

InstanceHelper.h:

@interface InstanceHelper : NSObject
+ (id)createInstance:(Class)clazz;
+ (id)createInstance:(Class)clazz properties:(NSDictionary *)properties;
@end

InstanceHelper.m:

#import "InstanceHelper.h"
@implementation InstanceHelper
+ (id)createInstance:(Class)clazz {
    return [[clazz alloc] init];
}
+ (id)createInstance:(Class)clazz properties:(NSDictionary *)properties {
    id object = [self createInstance:clazz];
    if ([object isKindOfClass:[NSObject class]]) {
        NSObject *nsObject = object;
        [properties enumerateKeysAndObjectsUsingBlock:^(id key, id valueObject, BOOL *stop) {
            [nsObject setValue:valueObject forKey:key];
        }];
    }
    return object;
}
@end

InstanceHelper.swift:

public extension InstanceHelper {
    static func create<T:AnyObject>(_ clazz: T.Type, properties: [String: Any] = [:]) throws -> T {
        if let instance = InstanceHelper.createInstance(clazz, properties: properties) as? T {
            return instance
        }
        throw NSError(domain: "InstanceHelper", code: 1, userInfo: [NSLocalizedDescriptionKey:"Cannot create instance of \(clazz)"])
    }
}

With InstanceHelper you now have a tool for unit testing the scene delegate. You now can create objects for classes where the init is not public. For example, UIOpenURLContext also can’t be created with a normal init. So when testing

scene(_ scene: UIScene, openURLContexts contexts: Set<UIOpenURLContext>)

you also have to use the InstanceHelper to create your own UIOpenURLContext.

Scene Delegate Conclusion

With our good old friend Objective-C, it’s possible to create instances of objects you can’t make with Swift. I don't think this was the intention, but here it helps a lot to make the UISceneDelegate unit testable. We can use the same mechanism in other places where the API uses objects that don’t have a public init method.

It would have been much better if we didn't need a hack like this. Imagine if UIWindowScene and UISceneSession were protocols instead of classes. This would not change much in the implementation, and it remains completely private, but unit testing using these protocols would be so much easier.

ApprovalTests for Powerful Assertions in Swift: How to Get Started

René Pirringer

About the author

I'm a big fan of TDD. Why? Let me explain with a short anecdote: I started developing iOS apps more than 10 years ago. At the beginning of my iOS development journey, I did only a few unit tests, but not TDD. One day when a new app release was due and I thought I had created a good working app, I was wrong. The testers sent me a long list of things that had to be fixed. First, I was annoyed about myself, but then I thought: I want to do better. That was the day I started with TDD. One year later, there was a similar situation with another bigger app release. But this time, the testers did not find any show stoppers. So TDD really does pay off.

  • A number of issues have been trailed out with the above code.
    1. createScene method return nonoptional value whereas the return values shown as optional
    2. When createScene has been used in the test class, it also shows the error of “Call can throw, but it is not marked with ‘try’ and the error is not handled”. In the above code snippet no where this has been handled.

    The above code seems like posted without proper testing

    • Thanks for the feedback. You are right it looks like I did made some mistakes. I have now created an example project with a fixed version that you can find here:
      https://github.com/openbakery/InstanceHelper

      I have also updated the current project I’m working on to include this new project (via carthage), so that I’m sure now that this code is working.
      Because the project contains mixed code I don’t know if it is possible to add swift package manager support.

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >