A Reusable Test Harness for Developing SwiftUI Apps

The Swift Package for this test harness is available on GitHub.

The “Test Settings panel available in the SwiftUI Test Harness package.

There are times, during both development and QA testing, when you need a way to observe and control how your app behaves in ways that can be difficult, or time consuming, to accomplish under normal app usage. You need the help of a test harness.

For me, some of the most useful testing aids have been…

  • Enhanced Console Logging, with filtering and additional details on where the log was made in the code.

  • Screen Logging to allow messages to be logged to the screen during runtime for more convenient testing and development, especially for QA.

  • Custom App Control Settings to control and override normal behaviors in the app for more efficient testing.

Now that I’ve been doing more SwiftUI development, I thought it was time to finally take these aids and formalize them as a Swift Package that I can quickly incorporate into any new SwiftUI apps I build.

Installing and Enabling the Test Harness

Because the harness is a Swift Package, you can simply add it to your project by using Xcode’s Swift Package Manager using the following URL:

https://github.com/durablebrandsoftware/durable-test-harness

Once the Swift Package has been added to your project, you can enable it in your app by simply adding the .withTestHarness(…) modifier to your app’s root view. There is a Demo app included with the Swift Package that shows you exactly how this is done. In this snippet, for example, you can see the modifier being added to the Demo’s root view (DemoView) on line 9:


import SwiftUI
import DurableTestHarness

@main
struct DemoApp: App {
    var body: some Scene {
        WindowGroup {
            DemoView()
                .withTestHarness(
                    settings: AppTestSettings(),
                    settingsView: AppTestSettingsView(),
                    enabled: true
                )
                .withTestSettingsPanelAccess()
        }
    }
}

The settings parameter for the view modifier on line 10 is a custom class that you create by subclassing the TestSettings class. This is how you define custom test settings for your own app testing. See “Custom App Test Settings” and “The Test Settings Panel“ below for details.

The AppTestSettingsView, provided as the second parameter on line 11, is a custom SwiftUI view that you can provide the harness with your own UI for setting and controlling your app’s custom test settings. The view you pass here will be embedded in the harness’s “Test Settings” panel whenever it’s opened. Again, see “Custom App Test Settings” and “The Test Settings Panel“ below for details. You can run the Demo app code to see it in action.

The enabled: parameter is how you control whether or not the harness is enabled in your app. The reason this is set up as a parameter is because there are a number of different ways that a development team might configure how their projects are built for development vs. production builds. Some teams often have three different builds: develop, test, and production. In that case you probably want to enable the harness in the both the develop and test builds (so that QA has access to the harness), but not in the production build to make sure your users do not.

You’ll need to determine the best way to pass false for this parameter, based on your team’s build process (automated or otherwise), to make sure the test harness won’t be functional in your App Store release.

Another way you can prevent the test harness from being enabled in App Store builds, of course, is to wrap the view modifier with a check for the DEBUG compiler variable that Apple defines for default Xcode project configurations, as shown here:


import SwiftUI
import DurableTestHarness

@main
struct DemoApp: App {
    var body: some Scene {
        WindowGroup {
            DemoView()
            #if DEBUG
                .withTestHarness(
                    settings: AppTestSettings(),
                    settingsView: AppTestSettingsView(),
                    enabled: true
                )
                .withTestSettingsPanelAccess()
            #endif
        }
    }
}

Notice, too, that we’re also applying the new .withTestSettingsPanelAccess() view modifier to the DemoView as well. (As shown on line 15 above.) This modifier lets you explicitly control which UI elements in your app can open the “Test Settings” panel with a long-press. You have to apply this view modifier to at least one view in your app to gain access to the panel. In this case, we’re making the entire app view the UI that can open the panel. But this might not always be appropriate, or it may get in the way of the normal gestures that your app may have, so this view modifier is provided to give you the flexibility of deciding which UI element is appropriate in your app for triggering the opening of the panel. You can even apply this view modifier to multiple views, if that’s necessary or helpful.

Enhanced Console Logging

Logging to the console is one of the most common ways to debug and test code. This test harness provides three enhancements for logging to the console:

  1. Logging by type (info vs. debug vs. warning vs. error, for example) and being able to exclude messages of certain types.

  2. Providing information on where the message was logged (i.e. the source file, the line number, and the function name)

  3. Being able to filter and disable log types so that you can focus on just the logs you need to see.

To log with the new, enhanced logging, you simply use one of the six new static functions available in the new Log class:


import DurableTestHarness
  
Log.info(_ message: Any?, filter: String? = nil)
Log.debug(_ message: Any?, filter: String? = nil)
Log.warn(_ message: Any?, filter: String? = nil)
Log.error(_ message: Any?, filter: String? = nil)
Log.critical(_ message: Any?, filter: String? = nil)
Log.todo(_ message: Any?, filter: String? = nil)

You can pass any object for the message: parameter (including strings) and it will attempt to display it as a string using String(describing:). Any nil values passed will be displayed as “nil” in the console.

Each type of log is distinguished with its own emoji prefix to help make it stand out in the console. The source file name, the function, and the line number is included automatically for you.

Each type of log message (info, debug, warn, error, critical, and todo) will display its own unique emoji prefix in the console, along with the source file, function, and line number where the log occurred:

The filter: parameter is an optional String you can pass that tags the log for filtering. You can pass anything you like as a filter. You can pass your initials, for example, as a way to only show your own log statements, filtering out all other logs that may have been added by other developers.

See “The Test Settings Panel” below, for details on how to filter your logs, and how to turn log types on and off.

Screen Logging

It’s often helpful for your QA team to be able to observe when things happen under the hood as code executes in your app. Developers have all kinds of debugging tools for observing this behavior, including console logging, break-points, variable monitoring and more, but it’s not as easy for QA testers to gain access to those tools, especially when they are testing a signed, release-candidate of the app that has had those tools disabled for optimization.

Even for developers, it can be convenient to have a way to see what’s happening, right on the app screen, while in the middle of building new features.

A quick and easy way to provide this kind of in-app messaging is to show an alert. This works, but if you have a number of messages that could be firing, one after the other (say, for example, when logging analytic events being recorded as the user interacts with you app), you and your testers will have to dismiss each alert to see the next message.

Because of this, I like to implement a custom overlay view, instead, that displays a running list of messages as they are logged, keeping them all on screen until they are cleared. I make this overlay semitransparent, and have it ignore any touch events, allowing what’s behind it to still be seen and interacted with. This type of logging is available in this test harness.

To log to the screen, simply call:


Log.toScreen(_ message: Any?, details: Any?)

As with the enhanced console logs, you can log any object, including strings, and it will attempt to display it as a string using String(describing:). Any nil values passed will be displayed as “nil”. The details: parameter will be displayed as a second line, under the message, in smaller text.

To clear the screen log, tap the trash can.

One note about the screen logging overlay in this SwiftUI version of the test harness: it doesn’t scroll. Normally, I would have preferred the screen log to be a scrolling view, but I haven’t found an elegant way to both suppress gestures on a scroll view (so that the view behind it can still receive touch events), and have it still be scrollable, too. A future enhancement might be to make it scrollable, blocking events to the view behind it, and then provide controls to show and hide the log so you can continue to interact with the app. For now, the most recent logs are inserted at the top so that the most recent messages are the most visible, and the older logs will fall off the bottom of the screen as the list grows.


Custom App Test Settings

Custom settings can be included in the test harness’s settings panel by providing your own custom SwiftUI view that will get embedded in the panel.

Test harnesses are often very specific to an app, especially when needing ways to control and test the app’s unique features. To provide this customization for your own app, you can provide the test harness with your own class of app-specific control settings that you can check against in your code to modify your app’s behavior, and then provide a custom UI view that the harness can include in its settings panel for you to manage and control those settings during runtime.

You create your own app test settings by subclassing a new TestSettings class, and then adding any unique settings you want to it for testing your app.

For example, the Demo app included in the Swift Package adds one unique test setting, a Bool named skipWelcomeAtLaunch, which can be used to control how the app launches. The idea is that it might be useful when testing the app to jump straight to the main view to assist automated testing.

The Demo defines this setting with a new subclass of TestSettings named AppTestSettings (your class can be named anything you like, as long as it subclasses TestSettings) and it includes the one new Bool property defaulted to false (meaning it normally should not skip the welcome at launch):


import DurableTestHarness

class AppTestSettings: TestSettings {
    ...
    public var skipWelcomeAtLaunch: Bool = false { didSet { save() } }
    ...
}

It’s important to make sure the defaults for your custom app test settings are set to the values they would normally be for your App Store build. In doing so, you can safely leave test code in your app, like showing in line 12 of the snippet below, because the testing harness will return the default values when the harness is disabled.


import DurableTestHarness

struct DemoView: View {
    
    @State private var showWelcome = true
    
    /// The timer for keeping the welcome view visible.
    let welcomeWaitTimer = Timer.publish(every: 2.5, on: .current, in: .common).autoconnect()
    
    var body: some View {
        ZStack {
            if showWelcome && !AppTestSettings.get().skipWelcomeAtLaunch {
                DemoWelcomeView()
            } else {
                DemoMainView()
            }
        }
        .onReceive(welcomeWaitTimer) { _ in
            welcomeWaitTimer.upstream.connect().cancel()
            withAnimation {
                showWelcome = false
            }
        }
    }
}

You provide the test harness with your app’s custom test settings by passing as the first parameter to the .withTestHarness view modifier as shown in the snippet below in line 3. You pass your custom UI view for controlling those settings as the second parameter to the view modifier as shown on line 4:


DemoView()
    .withTestHarness(
        settings: AppTestSettings(),
        settingsView: AppTestSettingsView(),
        enabled: true
    )


The “Test Settings” Panel`

The test harness has a built-in settings panel that displays as a sheet for controlling and managing the test harness settings, including the app-specific settings view you provide it. To access it, simply press and hold anywhere in the app’s root view that you embedded into the TestHarness, pressing for a second or more, and the “Test Settings” panel will appear.

The custom app settings view you provided in the .withTestHarness view modifier will appear at the top, under the “APP TEST SETTINGS” section. Below that section are the built-in settings that come with the test harness.

With those built in settings, you can disable any log types (INFO, DEBUG, WARN, ERROR, CRITICAL, or TODO), simply turning off the switchs next to them.

You can use the FILTER: field in the settings panel to only show log statements that have the text you enter there passed in the filter: parameter of the log statement. For example, the following log statement in your code…

Log.debug("hello", filter: "myLogs")

…will only log “hello” to the console if the text “myLogs” is entered in the filter text field of the “Test Settings” panel.

By default, all ERROR and CRITICAL logs are not filtered out with the filter (in other words they will always be displayed), because of their importance. But you can turn off the “Do Not Filter ERROR or CRITICAL Logs” switch to filter out those logs too.

The “Persist Between Launches” switch specifies whether the test settings will persist between app launches and re-launches. Being able to turn this off can be helpful sometimes in automated testing.

One Important Future Enhancement

One additional feature for a testing harness is the ability to provide sample (or mock) data to test your app with. It’s extremely helpful for a number of different reasons. This, however, tends to be very app specific, so I’m still working through how I might implement that in an abstract way that can be extendable, too. I’ll be adding that as an enhancement to the Swift Package once I figure out an elegant way to handle it.

For iOS, iPadOS, and macOS

The Swift Package for this test harness is available on GitHub.

This current version of the test harness can be used in iPhone, iPad, and Mac apps. Future enhancements will include support for watchOS, tvOS, and visionOS.

Previous
Previous

The Path to Making and Maintaining a Professional-Grade Mobile App

Next
Next

Holiday at the Old Arcade