mokacoding

unit and acceptance testing, automation, productivity

How to bypass the SwiftUI App when running unit tests

When an app launches, it kicks off setup operations like asking the remote API for new data, loading information from the local storage, or checking-in with analytics providers.

All this work gives the user a smooth startup experience but is unnecessary when running the unit tests and dangerous too: it can meddle with the global state, resulting in hard to diagnose failures. Sometimes, it can even make the tests noticeably slower or log noise into your analytics.

One of the first things I do when taking up a new project, whether greenfield or established, is preventing the unit tests from running the app startup flow.

I published a post about this back in 2016, when UIKit was the only framework in town. It's now time to revisit it for SwiftUI based applications.

SwiftUI App

// AppLauncher.swift
import SwiftUI

@main
struct AppLauncher {

    static func main() throws {
        if NSClassFromString("XCTestCase") == nil {
            MyAwesomeApp.main()
        } else {
            TestApp.main()
        }
    }
}

struct TestApp: App {

    var body: some Scene {
        WindowGroup { Text("Running Unit Tests") }
    }
}

// MyAwesomeApp.swift
import SwiftUI

struct MyAwesomeApp: App {

    var body: some Scene { ... }
}

You can find the source code for this example on GitHub.

Let's unpack what the code does. First of all, the top-level entry point for the program flow, marked by the @main attribute is AppLauncher. That's different from the default code generated by Xcode, where MyAwesomeApp would have been the entry point. This dedicated launcher allows us to differentiated between tests and genuine runs of the app.

To support the @main, AppLauncher must provide a static main method that the OS will call when launching the executable.

Checkout Keith Harrison's post "What does @main do in Swift 5.3" for a deep dive into how the app launches.

AppLauncher controls whether we're running the unit tests using the availability of the XCTestCase class as a proxy. If the class is found, the XCTest framework is loaded, which means the app launched as the host of the tests target.

Finally, if AppLauncher determines that the tests are running, it returns a dummy App implementation.

SwiftUI with UIKit App Delegate

If you are mix-and-matching SwiftUI and UIKit, that is, if you have an app with SwiftUI interface and "UIKit App Delegate" life cycle, then you can use the same approach as a UIKit only app.

// main.swift
import UIKit

private func delegateClassName() -> String? {
  if NSClassFromString("XCTestCase") == nil {
    NSStringFromClass(AppDelegate.self)
  } else {
    return nil
  }
}

UIApplicationMain(
  CommandLine.argc,
  CommandLine.unsafeArgv,
  nil,
  delegateClassName()
)

// AppDelegate.swift
import UIKit

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

This example is also available on GitHub.

Another option is to use an AppLauncher returning a different UIApplicationDelegate implementation for the tests, like in the SwiftUI only case. I find this approach with a dedicated main.swift file easier to discover, and I like how there is no need for a dummy app delegate when running the tests.


I hope you'll find this technique useful. If you have any questions or need help adopting this approach in your codebase, leave a comment below or get in touch on Twitter at @mokagio.

Want more of these posts?

Subscribe to receive new posts in your inbox.