Overview

It’s that time of year where we get to play with the latest APIs in the beta versions Xcode, try out all the new shiny APIs as well as make sure our apps continue to work with the existing APIs. it’s both an exciting and somewhat interesting time.

As the name suggests, the beta versions of Xcode and operating systems are just that, they are beta versions and may come with bugs included. Some of which may cause some of our tests to genuinely fail, and may require us to to temporarily disable those tests while those bugs are resolved.

In this post, we’ll explore a number of different techniques we can use to disable or skip tests in Xcode, more specifically we’ll be looking into how we can skip tests based on the OS or Xcode version in use.

Backstory

Recently, while running our test suite using Xcode 12 beta 4, we uncovered an iOS 14 beta bug. Typically around this time, our CI is configured to run against both a release version of Xcode (e.g. Xcode 11.5) as well as the latest beta (e.g. Xcode 12 beta 4) to ensure our code is compatible with both.

This left us with a puzzling choice now that some of the tests fail on Xcode 12 beta:

  • Add a temporary workaround for the bug to get the tests passing again
  • Temporarily disable those tests and remember to re-enable them when the next beta is released

In this particular case, writing a workaround isn’t worthwhile (yet) as we hope this will get fixed in an upcoming beta, as such we’re left with temporarily disabling those failing tests.

This got me thinking, is there a way for us to skip tests based on a specific version of Xcode? Such that when we update to the next beta, the tests would automatically stop getting skipped.

Disabling Tests

Tempting Options

One temptation could be to comment out the failing test all together:

// func test_betaFailingTests() {
//    // ...
// }

While definitely a quick and easy option, it does come with a downside, the test code will not even be compiled. Depending on how long the test remains commented out, the code could evolve in a way that results in the test method no longer compiling in the future when it eventually gets uncommented.

Another temptation could be to rename the test method:

func disabled_test_betaFailingTests() {
   // ...
}

XCTest only runs test methods that have a test prefix, as such renaming to anything else will result in that test no longer getting run. This approach does at least allow the code to benefit from compilation, however will no longer be run or recognised as a test on any version of Xcode.

It’s best those options be avoided where possible in favor of some of the other options listed in this post.

Disabling tests via Xcode

Xcode has a built in mechanism that allows you to selectively disable tests without resorting to any code changes.

This can be achieved by right clicking on the test name in the test inspector and selecting the “Disable” option.

Disabling tests via Xcode's test inspector

This marks those tests within the selected scheme or test plan as disabled and subsequently they will be excluded when running the entire test suite. One benefit of this approach over the previous options is that even while excluded from the test suite, disabled tests can still be individually run from within the editor in Xcode when needed.

Running disabled tests in Xcode

Dedicated Schemes

While we can’t conditionally mark tests as disabled within a scheme, we can still leverage them to do so by creating different schemes, one for the release version of Xcode and another for the beta.

Dedicated scheme for Xcode beta

Where we only mark the tests as disabled within the beta scheme.

Then on our CI we can use the appropriate scheme based on Xcode version in use:

# When using Xcode 11
$ xcrun xcodebuild test \
  -scheme App  \
  -destination 'platform=iOS Simulator,OS=latest,name=iPhone 8'
# When using Xcode 12 beta
$ xcrun xcodebuild test \
  -scheme App_iOS14  \
  -destination 'platform=iOS Simulator,OS=latest,name=iPhone 8'

Dedicated Test Plans

An alternate to managing multiple schemes is to leverage multiple test plans. As of Xcode 11, Xcode supports more advanced configuration customisations for tests know as test plans. A scheme can have one or more test plans associated with it.

We can create a test plan dedicated to iOS 14 beta testing.

Dedicated test plan for Xcode beta

Tests can then be marked as disabled within that test plan via the test inspector, or even directly in the test plan itself (make sure to take note of which test plan is selected):

Disabling tests via Xcode's test inspector

Then on our CI we can use the appropriate test plan based on Xcode version in use:

# When using Xcode 11
$ xcrun xcodebuild test \
  -scheme App  \
  -testPlan App_TestPlan \
  -destination 'platform=iOS Simulator,OS=latest,name=iPhone 8'
# When using Xcode 12 beta
$ xcrun xcodebuild test \
  -scheme App  \
  -testPlan App_TestPlan_iOS14 \
  -destination 'platform=iOS Simulator,OS=latest,name=iPhone 8'

Skipping Tests

Skipping via Command Line

Another option available to us that doesn’t require maintaining multiple schemes or test plans is to leverage the -skip-testing command line option when using xcodebuild.

It takes the form of:

-skip-testing:”<TestTargetName>/<TestClassName>/<TestName>”

For example:

# When using Xcode 12 beta
$ xcrun xcodebuild test \
  -scheme App  \
  -destination 'platform=iOS Simulator,OS=latest,name=iPhone 8' \
  -skip-testing:"AppTests/AppTests/test_betaFailingTest" \
  -skip-testing:"AppTests/AppTests/test_anotherBetaFailingTest"

This will result in those tests getting skipped on the Xcode 12 CI job only, however will continue to run on other CI jobs as well as locally in Xcode.

Skipping at Runtime

In Xcode 11.4, XCTSkip , XCTSkipIf and XCTSkipUnless were introduced to allow skipping tests at runtime.

func test_skipMe() throws {
   throw XCTSkip("Skipping this test")
   // ...
}

func test_skipIf() throws {
   try XCTSkipIf(someConditionIsTrue(), "Skipping this test")
   // ...
}

func test_skipUnless() throws {
   try XCTSkipUnless(someConditionIsFalse(), "Skipping this test")
   // ...
}

It’s worth watching this year’s WWDC’s XCTSkip your tests video for more details.

Skipping Based on OS Version

We could leverage the new skipping feature to conditionally skip based on OS version.

For example:

final class AppTests: XCTestCase {
    func test_betaFailingTest() throws {
        try skipIfiOS14()
        // ...
    }
}

extension XCTestCase {
   func skipIfiOS14() throws {
      if #available(iOS 14, *)  {
         throw XCTSkip("Skipping test on iOS 14")
      }
   }
}

Skipping Based on Swift Compiler Version

Along the same lines, we could also skip based on Swift compiler version.

extension XCTestCase {
    func skipIfSwift53Compiler() throws {
        #if compiler(>=5.3)
            throw XCTSkip("Skipping test on Swift 5.3 compiler")
        #endif
    }
}

This can sometimes be handy in the event the issue causing the tests to fail relates to a specific compiler version as opposed to a specific iOS version (e.g. Running the test built with Xcode beta on an iOS 13 simulator would also fail). Xcode 12 beta has a new Swift compiler version 5.3, this can allow us to distinguish between it and Xcode 11.

Skipping based on Xcode Version

Going back to the scenario we faced, we would like to temporarily skip the test for a particular Xcode beta version, and not for iOS 14 or the Swift 5.3 compiler forever more … in fact it would be great if it gets re-enabled automatically next time we update Xcode.

We’d like something along the lines of:

final class AppTests: XCTestCase {
    func test_betaFailingTest() throws {
        try skipIf(xcodeVersion: "12.0-beta4")
        // ...
    }
}

Finding the Xcode Version

As it happens, the information we’re after is already included within the Info.plist of our test target. Xcode by default includes a few additional keys at compile time to the final plist file that gets embedded within the built product (the .xctests bundle).

We can inspect the contents of the plist file to see for ourselves:

defaults read /path/to/DerrivedData/AppTests.xctests/Info.plist

The .xctests bundle can be located by right clicking on the test target under the Products group in Xcode and selecting Reveal in Finder.

Note: for unit tests with host applications, the .xctests bundle is embedded within the host application.

defaults read /path/to/DerrivedData/App.app/PlugIns/AppTests.xctests/Info.plist

We should see something similar to this:

{
    BuildMachineOSBuild = 19E287;
    CFBundleDevelopmentRegion = en;
    CFBundleExecutable = AppTests;
    CFBundleIdentifier = "net.testing.AppTests";
    CFBundleInfoDictionaryVersion = "6.0";
    CFBundleName = AppTests;
    CFBundlePackageType = BNDL;
    CFBundleShortVersionString = "1.0";
    CFBundleSupportedPlatforms =     (
        iPhoneSimulator
    );
    CFBundleVersion = 1;
    DTCompiler = "com.apple.compilers.llvm.clang.1_0";
    DTPlatformBuild = 18A5342e;
    DTPlatformName = iphonesimulator;
    DTPlatformVersion = "14.0";
    DTSDKBuild = 18A5342e;
    DTSDKName = "iphonesimulator14.0";
    DTXcode = 1200;
    DTXcodeBuild = 12A8179i;
    MinimumOSVersion = "13.0";
    UIDeviceFamily =     (
        1,
        2
    );
}

Notice two interesting keys, DTXcode and DTXcodeBuild:

    DTXcode = 1200;
    DTXcodeBuild = 12A8179i;

While we do not have the beta number per se, the DTXcodeBuild (build version) value is unique to each Xcode version including beta versions. This can be found in Xcode’s about screen:

Xcode's about screen

Or from the command line:

$ xcrun xcodebuild -version
Xcode 12.0
Build version 12A8179i

Accessing the Xcode Version at Runtime

We can access values from our Info.plist file at runtime via bundle.object(forInfoDictionaryKey:)

func currentXcodeBuildVersion() -> String? {
   class BundleFinder { }
   let bundle = Bundle(for: BundleFinder.self)
   return bundle.object(forInfoDictionaryKey: "DTXcodeBuild") as? String
}

Final Results

Piecing together what we have so far, we can write a small helper method to skip based on a specific Xcode version:

extension XCTestCase {
    func skipIf(xcodeVersion: String) throws {
         guard let currentVersion = currentXcodeBuildVersion() else {
            return
        }
        try XCTSkipIf(xcodeVersion == currentVersion, "Skipping test, Xcode build version is \(xcodeVersion)")
    }
}

Instead of dealing with string versions, we can tidy things up a bit by creating an XcodeVersion enum that helps us map them to their semantic versions:

enum XcodeVersion: String {
   // ...
   case xcode12_beta4 = "12A8179i"
}

extension XCTestCase {
    func skipIf(xcodeVersion: XcodeVersion) throws {
        guard let currentVersion = currentXcodeBuildVersion() else {
            return
        }
        try XCTSkipIf(xcodeVersion == currentVersion, "Skipping test, Xcode build version is \(xcodeVersion)")
    }

    func currentXcodeBuildVersion() -> XcodeVersion? {
        class BundleFinder { }
        let bundle = Bundle(for: BundleFinder.self)
        let versionString = bundle.object(forInfoDictionaryKey: "DTXcodeBuild") as? String
        return versionString.flatMap {
            XcodeVersion(rawValue: $0)
        }
    }
}

And we finally end up with the following at the call site:

final class AppTests: XCTestCase {
    func test_betaFailingTest() throws {
        try skipIf(xcodeVersion: .xcode12_beta4)
        // ...
    }
}

Side Notes

First Attempt

At first I didn’t realise that the DTXcodeBuild key was already included in the final Info.plist file, it’s not till I started preparing this blog post and explaining the methodology that I discovered it. This is one of the great things about blogging or presenting topics publicly, it encourages you to research that topic further to help explain it better, and by doing so you actually end up learning more about said topic!

For completeness, here was my initial approach to obtaining the Xcode build version (for the next few paragraphs, let’s pretend DTXcodeBuild doesn’t exist).

Is the Xcode build version available to us at build time? To answer this question, we can inspect all the build settings available and their resolved values via:

$ xcrun xcodebuild -showBuildSettings -target AppTests

This will flood our console (there’s a fair bit of build settings!), we can either use grep to filter through or store the output in a file and browse it in our favourite editor.

Inspecting the build settings, we can see a few Xcode related settings:

$ xcrun xcodebuild -showBuildSettings -target AppTests | grep -i xcode_
    # ...
    XCODE_PRODUCT_BUILD_VERSION = 12A8179i
    XCODE_VERSION_ACTUAL = 1200
    XCODE_VERSION_MAJOR = 1200
    XCODE_VERSION_MINOR = 1200

This is quite useful as we can reference any of those build settings in our test target’s Info.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <!-- ... -->
  <key>CurrentXcodeBuildVersion</key>
   <string>$(XCODE_PRODUCT_BUILD_VERSION)</string>
</plist>

When compiling our test target, Xcode will evaluate the Info.plist file and embed a version with the final resolved values within the built .xctests bundle product.

We can inspect the evaluated plist as we previously did:

defaults read /path/to/DerrivedData/AppTests.xctests/Info.plist

{
    ...
    CurrentXcodeBuildVersion = 12A8179i;
}

We’ve already seen how to access those values at runtime.

bundle.object(forInfoDictionaryKey: "CurrentXcodeBuildVersion") as? String

Alas we don’t need to jump through those hoops as DTXcodeBuild is automatically set for us.

Skipping Based on Other Custom Data

If you find yourself needing to skip tests based on other environmental conditions at runtime, you can leverage the same Info.plist technique used above and pass your own custom data.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <!-- ... -->
  <key>CustomKey</key>
   <string>$(MY_CUSTOM_KEY)</string>
</plist>

MY_CUSTOM_KEY can then be set directly in Xcode’s build settings editor as a user defined setting, or in an .xcconfig file, or even specified via the command line:

$ xcrun xcodebuild test \
  -scheme App  \
  -destination 'platform=iOS Simulator,OS=latest,name=iPhone 8' \
  MY_CUSTOM_KEY="CustomValue"

Closing Thoughts

What started out as curious exploration of the question “Can we skip tests based on a specific Xcode version?”, turned into this longish post on a number of different ways one can disable or skip tests in Xcode.

I hope you find this useful, happy testing!

Reference Material