iOS Build Schemes Explained

A build created for beta testing is neither a Debug, nor a Release build. It has some settings such as extra paranoid (potentially privacy unfriendly) error reporting enabled in a controlled userbase consists of people that give proper consent. It can also connect to some endpoints only relevant to the testing environment. In some cases, you may even want to integrate entire frameworks in a test build. If you scratched the Apple docs and Stack Overflow to find a if isBeta() {} equivalent, you probably failed hard. The reason is, there is no simple solution to fit all the use cases. It is best practice to define custom build schemes for your own needs. Here, we are going to do exactly that.

Build schemes are not language features

You read it correctly. Your code, unless expressed explicitly inside a source file, do not know whether you are building it to debug a feature or release the next update. It is the job of the IDE (XCode) to do that. So what you do is, you teach your workflow to XCode with all the commonalities and differences that exist among all the build schemes you need. It then glues the appropriate pieces accordingly.

An app in its most basic, freshly created form comes with two predefined schemes, namely Debug and Release. This workflow can be sufficient if the app displays offline content and a frequent update schedule is not planned. However, if the app depends on an API server and interacts with outside services, it is probably more beneficial to have more than two schemes.

A Debug scheme defines how you build the app during the development. It is likely to break existing functionality. It probably connects to a controlled, private API server filled with placeholder data to play with features that are not complete yet. Users of a Debug build are probably other developers of the same app. If the app crashes in this build, none of your users are affected.

A Release scheme on the other hand, defines how your app is prepared for an App Store release. Once built with it, the app can be uploaded for review by Apple. In this scheme, the app connects to real API services and never serves placeholder data. Users of this build are real people outside of your company and probably your customers. If this build introduces a crash or updates the databases with improper data, potentially everyone downloaded your app can observe it.

When an end-user facing app gets older, complexities start to pile up. You can no longer tolerate occasional crashes. Placeholder data do not represent the scale of your real life use case. Eventually a test build becomes a necessity. Between the Debug and Release, you add a third option. Let’s call it Test.

Test Build

In this build, you prepare your app as if it is ready for release. All the features are implemented and as far as you know, it should work as expected. You don’t know if you eliminated all the crash causing mistakes and checked for all the edge cases. You need people to check for that kind of stuff and if something goes wrong, outside world won’t have an idea.

You prepare a Test build. Send it to your test crowd. Everyone in the crowd tests all the existing and newly added features. They use it as if it is the real thing. The crowd reports you the errors, crashes, unexpected behaviors, spelling mistakes, device compatibility issues and any other unwanted stuff they see.

In such builds, your app probably connects to a private server that has a snapshot of real life data.

It probably integrates a monitoring tool which gathers a huge number of useful analytics from the device to help you fix reported issues.

In a test build, you will want to log excessively the behavior of newly added features.

If your app is built in a way that supports remote updates, you may want to force-enable it in this kind of builds.

If you are convinced, let’s actually implement it!

Steps

Create an iOS project in XCode. Swift is preferrable but you can also stay with Objective-C.

On the Navigator (left side file viewer), click the top level project name. If target settings are shown, click the current target and change it to project to see all existing build schemes. You should see a screen that looks like below.

In order to create a Test scheme, you need to copy an existing scheme and make changes on the copy. Since our test builds are going to behave like release builds, it is logical to copy the Release scheme. Click the button shown below.

Create a duplicate from Release and give it a name you like. We called it Test in our example.

Now if you navigate to the Build Settings tab, you will see that for each setting on the list that accepts multiple values, a third option named Test is automatically added. By editting these fields, we will be able to configure our newly defined scheme.

There are three main behaviors we want to implement in our multiple scheme workflow.

  1. We need a way to check which scheme we are using at compile time.
  2. We need a way to check which scheme we have used during compilation at runtime.
  3. We need a way to define global constants that differ for each scheme.

Compile Time Scheme Check – Swift

On the top left side of the screen, there is a search bar. Type Active Compilation Conditions in it to filter out the option we need.

This option allows you to define any number of compiler flags that can be checked in your Swift code during compilation. In order to add one, double click the empty space below the DEBUG in the same row as Test and click the + button. Type TEST and click somewhere outside to close the pop up.

Awesome! Now we can use Swift preprocessor directives to check whether the flag named TEST is defined in any of our Swift files.

Preprocessor directives, unlike normal Swift code, do not run any logic. Their main purpose is to mark specific lines of your Swift code as hidden/visible to the compiler. There are also directives that can attach meta data to specific lines but we are ignoring those in our examples.

The way you use a preprocessor condition check is like below:

#if DEBUG
    // Visible to the compiler during DEBUG builds.
#elseif TEST
    // Visible to the compiler during TEST builds.
#else
    // Visible to the compiler during RELEASE builds.
#endif

Preprocessor “if” directives are not Swift statements. They don’t check what they are hiding/showing so you can do weird things like this, which is one the most useful features you will need for mocking functions.

#if DEBUG 
  func callApi() -> Request 
      print("Inside callApi()")
#elseif TEST
  func callApi() -> MockRequest {
#else
  func callApi() -> Request {
#endif 
      // implementation 
      ... 

  }

Compile Time Scheme Check – Obj-C

Achieveing the same result in Objective-C is quite similar. Unfortunately Objective-C compiler do not inherit compiler flags from Swift settings so we need to repeat the first step for Objective-C as well. Morever, being a superset of the programming language C, Objective-C preprocessor macros have real values so the syntax differ a little.

Type Preprocessor Macros to the search bar and locate our scheme.

Double click empty space inside the Test row and add a line like this.

Then inside of your Obj-C code, check the flags as shown below.

#if defined(DEBUG)
  // Do something only in DEBUG
#elif defined(TEST)
  // Do something only in TEST
#else
  // Do something only in RELEASE
#endif

There are also other ways like #ifdef but since #elifdef is not supported by Apple compilers, we omitted those examples.

Runtime Check – Swift

If you finished doing the steps above, implementing a runtime check should have become quite trivial.

func isTest() -> Bool {
    #if TEST
    return true
    #else
    return false
    #endif
}

Runtime Check – Obj-C

The same method applies to Objective-C as well.

- (BOOL)isTest {
#if defined(TEST)
    return YES;
#else
    return NO;
#endif
}

Global Constants

Luckily, defining a global constant has a unified way for Swift and Objective-C. Inside your build settings, click the button shown below and add a constant of your choice to the build.

For instance, you can define your API endpoints like below.

Source files do not immediately see user defined configurations inside build settings. You need to include each setting inside a plist file such as Info.plist. Copy the setting name you created (i.e API_SERVER_URL), add a row to Info.plist (right click -> add row) and paste it inside like this.

The $() operator inherits values from current scheme’s build settings. Make use of it with strings, bools or any other configuration parameter type of your choice.

Since our globals are now inside the Info.plist file, you can access them the regular way.

[[NSBundle mainBundle] objectForInfoDictionaryKey:@"API_SERVER_URL"];
Bundle.main.infoDictionary?["API_SERVER_URL"] as! String

Bonus

Here is how you enable TestFairy SDK in your Test builds.

#if defined(TEST)
[TestFairy begin:@"APP_TOKEN"];
#endif

Credits