Enabling isolated testability for your iOS project

By default with iOS and Swift, the test target runs your actual application in a simulator, but this can be a problem because you usually don’t want to run the entire launch sequence when running unit tests. The launch sequence may trigger analytics events, or pull data from an API for no good reason since we are only interested in running our tests in the most isolated environment possible.

To avoid this situation, we are going to tell Xcode to launch our application from a dummy entry point, only for test purposes. This testing launch sequence will do… nothing, and that’s the point!

Step 1: Removing existing @main modifiers

By default, Xcode uses a the @main annotation to know how to launch the application. This annotation is automatically added by the Xcode project template in the AppDelegate. In order to change the entry point of the application during unit tests, let’s remove the annotation altogether. To do that, change your `AppDelegate.swift` from:

// AppDelegate.swift

@main
final class AppDelegate: UIResponder, UIApplicationDelegate {
    ...
}

to:

// AppDelegate.swift

final class AppDelegate: UIResponder, UIApplicationDelegate {
    ...
}

Step 2: main.swift

Xcode now needs a way to know what the entry point of the application is, if not using the @main annotation, you use UIApplicationMain function from a special file named main.swift. This file is optional and doesn’t exist by default in your app, so let’s create it:

// main.swift

import UIKit

let appDelegateClass: AnyClass = NSClassFromString("TestingAppDelegate") ?? AppDelegate.self
UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(appDelegateClass))

With this code, we say: try to use the TestingAppDelegate as a starting point for the application, but if it does not exist (and this file will only exist in the test target), use the standard AppDelegate.

Step 3: Creating test utilities

Let’s fill in the blanks by creating the TestingAppDelegate and associated classes. This special AppDelegate will not do anything besides connect to a TestingSceneDelegate that itself will do nothing besides adding a dummy view controller as its root.

In your test target, create a new Test Utilities folder and add the following 3 new files to it:

  • TestingAppDelegate.swift
  • TestingSceneDelegate.swift
  • TestingViewController.swift
// TestingAppDelegate.swift

// MARK: - TestingAppDelegate
@objc(TestingAppDelegate)
final class TestingAppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        return true
    }

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        let sceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
        sceneConfiguration.delegateClass = TestingSceneDelegate.self
        sceneConfiguration.storyboard = nil
        return sceneConfiguration
    }
}
// TestingSceneDelegate.swift

import UIKit

// MARK: - TestingSceneDelegate
final class TestingSceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else {
            return
        }
        window = UIWindow(windowScene: windowScene)
        window?.rootViewController = TestingViewController()
        window?.makeKeyAndVisible()
    }
}
// TestingViewController.swift

import UIKit

// MARK: - TestingViewController
final class TestingViewController: UIViewController {
    override func loadView() {
        let label = UILabel()
        label.text = "Running unit tests..."
        label.textAlignment = .center
        label.textColor = .white
        label.backgroundColor = .black

        view = label
    }
}

Step 4: work around the Xcode caching issue

Since the introduction of SceneDelegate in iOS 13, testing was made a tiny bit more complicated due to the iOS operating system caching the SceneDelegate across sessions. This is a problem because switching from the actual app running in the simulator to the unit tests running in the same simulator results in Xcode being confused and using the cached version instead the one we decided to use in AppDelegate or TestingAppDelegate.

To bypass this issue, you can manually kill the app and remove it from the app switcher in between sessions, but the easiest way to always try to run the tests in a different simulator (eg: I used to run the app in a iPhone 14 Pro simulator, but the tests in an iPhone 14). Unfortunately, this is still error-prone and you always end up running the wrong scheme in the wrong simulator.

But I recently came across this solution that helped solve this problem by removing all cached scene configurations in the testing AppDelegate to ensure the configuration declared TestingSceneDelegate is used. We need to modify our TestingAppDelegate to add the following code:

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    // Remove any cached scene configurations to ensure that TestingAppDelegate.application(_:configurationForConnecting:options:)
    // is called and TestingSceneDelegate will be used when running unit tests.
    // NOTE: THIS IS PRIVATE API AND MAY BREAK IN THE FUTURE!
    for sceneSession in application.openSessions {
        application.perform(Selector(("_removeSessionFromSessionSet:")), with: sceneSession)
    }

    return true
}

As noted, this is using a private API! Normally, you would want to avoid using private APIs because Apple will systematically detect them during App Review and reject your submission to the App Store. But in this case, the private API is used in the unit tests target, and since this code never reaches the App Store, it is 100% safe to use.

It’s done

Now when running the unit tests, the app that launches in the simulator isn’t your full app, it’s this nicely isolated dummy app that simply says “Running unit tests…”.

Happy testing!