Reducing Xcode Build Times for Unit Tests and SwiftUI Preview Builds

Daniel Reiling
5 min readFeb 9, 2021

In this article, I want to walk through how I reduced the incremental build times in my app from ~24 seconds to ~0.3 seconds when building to run Unit Tests or SwiftUI Previews. I hope that someone may be able to take the same approach to help reduce these build times in their project.

Why Worry?

This may seem obvious, be quickly I would like to talk about the benefits of reducing these build times in your project. In my project, we had gotten to a point with SwiftUI Previews that it took so long to build and update, it was not worth using the previews anymore. I have heard this complaint from many others (that have also given up on Previews). But after fixing these issues, Previews because usable (even enjoyable)! Now instead of just deleting out the preview code when creating a SwiftUI View, I use the previews to design the entire screen before even running the app.

Additionally, quicker tests mean quicker results. It is hard enough justifying taking the time away from what you are working on that new shiny feature to write and run tests (but worth it). When your tests are taking forever to run, that iteration time increases and you lose even more time. When we reduce this time, unit tests start running faster and we can run them more often, both when developing tests as well as verifying a refactor.

Understanding how the builds work

One of the biggest reasons for these builds growing in time is the missing piece in the story about how these builds work. If you are like me, you may have assumed that Xcode does a special process or build type when preparing for unit tests or previews. This is not the case. Your app gets built the same way (and with the same steps) as when you build to run your app on a device or simulator.

What does this mean? First, any special processes/build scripts like SwiftLint, SwiftGen, or Apollo to name a few examples will still be run every time you tap CMD+U or resume for previews!

Correct me if I am wrong, but I don’t care about linting the build while trying to run a SwiftUI Preview.

Additionally, the app will open as normal as well, running all of the processes you have set up in your AppDelegate. Not only will it run these, but if your main screen (UIViewController or SwiftUI View) does any work on startup/appearance, this work will be started as well. This could mean changing state, sending network requests, or anything else while you are just trying to test the app. Fortunately, there is a way we can avoid all of this.

Skipping AppDelegate & SceneDelegate Work

The first problem we will tackle is skipping all unnecessary work in your AppDelegate and SceneDelegate when running tests or previews. The way we will do this is to check if the build is for one of these purposes, and bailout of the appropriate methods before doing any actual work.

Step 1

To start we will create the following file:

This will give us two methods to work with. With these methods areTestsRunning and arePreviewsRunning we will be able to detect the type of build and act accordingly

PS: arePreviewsRunning does use an environment variable that is not guaranteed to work forever. Fortunately, with this implementation, the worst that will ever happen if it fails, is that our work will continue as before and our build times will increase. But no crashing or weird behavior.

Step 2

In your AppDelegate’s applicationDidFinsishLaunchingWithOptions we can utilize these methods and return early if needed.

The important code here is:

guard BuildChecker.areTestsRunning() == false,                         BuildChecker.arePreviewsRunning() == false else { return true }

Step 3

Our SceneDelegate is slightly different in that it is responsible for setting up the UI. Instead of just bailing out of the method before any work gets done, we can set up the rootViewController to be an empty UIViewController instance.

Again the important code here is:

if BuildChecker.areTestsRunning() || BuildChecker.arePreviewsRunning() {
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.rootViewController = UIViewController()
self.window = window
window.makeKeyAndVisible()
}
return
}

Skipping any custom build scripts

Our next and final task will be to skip any unnecessary build scripts, such as SwiftLint, SwiftGen, etc. To do this we will need to be able to determine what build variant we are working with inside of the bash scripts. For SwiftUI preview builds this is relatively easy due to the existence of an environment variable we can check. For unit tests, this proves more tricky (but still possible).

The trick is to create a new build configuration specific to tests.

Step 1

Go to your project configuration and add a new build configuration named “XCTest” (or whatever you prefer).

Step 2

Once you have this new build configuration, press CMD+SHIFT+< to get to edit your current scheme. In the Test section, choose “XCTest” for the build configuration.

Step 3

For the final step, insert the following script at the top of any custom build scripts (found in the ‘Build Phases’ section) that you do not want to run during tests or preview builds.

The important section of this code is:

# Skip running this scirpt if we are running previews or tests
if [ "${ENABLE_PREVIEWS}" = "YES" ] || [ "${CONFIGURATION}" = "XCTest" ]
then
echo "Skipping script when running preivews or tests"
exit 0
fi

This will check for either the environment variable set during preview builds or the build configuration of “XCTest”. When one of these conditions is met, we stop execution before running any of the time-intensive tasks.

Conclusion

These are the steps I took in my project and I hope they might be useful for you. If you have any feedback, improvements, or other tips I would love to hear it!

--

--