Speeding up your iOS Swift tests

Are your iOS Swift isolation tests running fast enough? My guess would be “no” unless you have taken deliberate actions to make them run faster. Here’s an approach to reduce the time we spend waiting for our tests to run.


Problem

If I head to Xcode and create a new iOS project with File > Project > Single View App. This gives me a bare bones project which hasn’t yet been sullied with my attempt at coding. I select the option to include unit tests so that everything is wired up and I get a blank test class.

From here I run the tests to get a sense of what my feedback cycle will be:

# Clean and Test
time xcodebuild -scheme SingleViewApp -destination 'platform=iOS Simulator,name=iPad Air 2,OS=latest' -quiet clean test

#=> 1) 38.470
#=> 2) 32.856
#=> 3) 34.413

# Test with warm caches
time xcodebuild -scheme SingleViewApp -destination 'platform=iOS Simulator,name=iPad Air 2,OS=latest' -quiet test

#=> 1) 31.669
#=> 2) 28.552
#=> 3) 28.462

I’m not going to dig into why these run times are soo slow but just know that this is way off the mark. This is a project with no production code and an empty test file but I have to wait ~30 seconds for feedback.


The End Result

Before going through the practical details of the Swift Package Manager based approach to speeding this up let’s look at the end results - this way I can avoid wasting people’s time if they decide the improvements don’t merit reading further.

Here I look at a single module extracted from a production code base. It’s a small module at 4633 lines of Swift, which is made from several smaller modules. It’s executing a modest 110 tests:

Xcode Clean SPM Clean Diff
46.873 22.717 2.06x faster
54.753 19.897 2.75x faster
58.447 19.883 2.93x faster

Clean builds are still painfully slow but they are 2-3x faster when using this technique so that’s still a win.

If you simply rerun the tests without changing any code you get a wild speed up of ~39% but that’s not a realistic test case. So the numbers for cached builds here will take into consideration changing some code to force some recompilation:

Xcode Cached SPM Cached Diff
31.840 7.389 4.30x faster
29.501 7.166 4.11x faster
25.396 6.306 4.02x faster

This is where the numbers get more exciting - ~7 seconds is still an awfully long time to wait for feedback when you are in a flow but it’s much more manageable than 30-60 seconds.


How?

So you made it past the results - let’s dig in. I’ve actually discussed this general idea already in Creating Swift Package Manager tools from your existing codebase (one of my catchier titles) but this is applying it to improving tests.

Prerequisites

Steps


Worked example of a simple project (not modularised)

Imagine I have a project called MySimpleApp, which has all the business logic separated from the UIKit related stuff. On disk it looks like:

├── MySimpleApp
│   ├── AppDelegate.swift
│   ├── Models                  <- business logic in this folder
│   │   ├── BlogPost.swift
│   │   └── BlogFilter.swift
│   └── ViewController.swift
├── MySimpleApp.xcodeproj
└── MySimpleAppTests
    └── Models
       ├── BlogPostTests.swift
       └── BlogFilterTests.swift

I would start by creating a new SPM project - I’m going to go with the arbitrary name of UIKitless. The swift package init command could be used but it generates a lot of stuff we don’t need so it’s simpler to build it manually here:

mkdir UIKitless
cd UIKitless
mkdir {Sources,Tests}
(cd Sources && ln -s ../../MySimpleApp/Models MySimpleApp)
(cd Tests && ln -s ../../MySimpleAppTests/Models MySimpleAppTests)

Next we need to create a Package.swift to tell SPM how to build all of this. The full file will look like this:

// swift-tools-version:4.0

import PackageDescription

let package = Package(
    name: "tests",
    products: [ ],
    targets: [
        .target(name: "MySimpleApp"),
        .testTarget(name: "MySimpleAppTests", dependencies: [ "MySimpleApp" ]),
    ]
)

With this we can run swift test and everything should work.


Worked example of a more complicated project

If you have modularised your project in any way then it will mean that you are using statements like import SomeOtherModule in your source files. This is not a problem as long as the module you want to import also follows the prerequisites above.

Here’s the additional steps that are required:

The trick is to use the same module name in SPM as is used in your code base e.g. if I import SomeOtherModule in my source files then the target name of my symlink should be SomeOtherModule

(cd Sources && ln -s ../../MySimpleApp/Modules/SomeOtherModule/Sources SomeOtherModule)

The last thing is to update Package.swift to tell it to build this new module and make it a dependency of MySimpleApp:

// swift-tools-version:4.0

import PackageDescription

let package = Package(
    name: "tests",
    products: [ ],
    targets: [
        .target(name: "MySimpleApp", dependencies: [ "SomeOtherModule" ]),
        .target(name: "SomeOtherModule"),
        .testTarget(name: "MySimpleAppTests", dependencies: [ "MySimpleApp" ]),
    ]
)

With this we can run swift test and everything should work.


Conclusion

Whilst this example is only using a small code base and possibly unrealistic tests it shows some promise. Hopefully I’ve shown that depending on how your codebase is structured this could be quite a cheap experiment to run and if it yields good results then you can keep using it. Having done a fair amount of Ruby where I would expect my tests to run in fractions of a second any time I save a file these tests are still mind numbingly slow - these things are improving all the time so I remain optimistic that testing won’t be this painful forever.