DEV Community

loading...

Custom build configurations for testing and debugging in iOS/Xcode

Mathias Remshardt
Learner
Updated on ・5 min read

When debugging one of my unit tests I coincidentally noticed that the complete start-up procedure/code was running before the unit tests get executed. With my web development background, this was somehow surprising to me as I expected only the class/method... referenced by the test suite being run. After taking some time to think about it this makes a lot of sense as the tests are run e.g. on a simulator where the application first needs to be installed and started before the tests run.

This means that any side effects at application start e.g. an HTTP call is also triggered when any unit test gets executed. Even though this did not lead to any unwanted side effects in my particular case I felt uneasy about the requests being performed. So I was looking for some means to exchange the "production" implementation with a version suitable for test runs or debugging.

For the unit tests, I already relied on constructor-based dependency injection to pass stubs/mocks to avoid unwanted side effects and/or return test data. However, this only covered the dependencies for the subject under test, but not whatever is executed at application start.
Having dependency injection already in place I just required some means to "determine" at compile and/or runtime if the problematic dependencies should be replaced by e.g. a test- or in-memory version.

The idea was to define a "mode" like test or production which could be derived and set at application start.

Build Configuration based dependency injection

When creating a new project in XCode it also defines build configurations like DEBUG and RELEASE which can be used in combination with preprocessor macros to include different code sections depending on the configuration the build is executed with.

This seems to be often used e.g. to exclude SwiftUI previews from release builds or define a different PersistenceController when working with CoreData.

Schemes can then be used to set unique build configurations e.g. for Run or Test. This seemed to be sufficient to solve my test run dependency issues.

Using the DEBUG flag was one option but would also replace the release dependencies with the test/in-memory versions for debug runs on the simulator/real device (something I did not want). So a dedicated Test configuration would allow for having different dependencies injected during debug- and test runs.

As it turned out creating and using such a custom configuration requires three steps:

  1. Creating a custom build configuration and configuring the Active Compilation Conditions
  2. Injecting dependencies based on the configuration selected
  3. Creating/updating the Xcode scheme for Run and Test

Build configurations and Active Compilation Conditions

First a new custom Build Configuration is required, which can be created in the project settings Info tab by duplicating from the e.g. Debug configuration:

Test Build Configuration

Next a new entry for Active Compilations Conditions needs to be added for Test which can be done in the Build Settings Tab. It made sense to me to set TEST as well as DEBUG for the Test build configuration to make sure everything applied to Debug is also applied for Test (in addition to what is defined for Test).

Custom Active Compilations Conditions

Having it setup like this the TEST flag is now available (in addition to DEBUG and RELEASE) for usage in preprocessor macros.

Build Configuration based injection

Using macros alone, it is already possible to switch between different dependencies based on the active build configuration:

private static func makeTodosRepository() -> TodosRepository {
  #if TEST
  return InMemoryTodosRepository()
  #else
  return ProductionTodosRepository(host: "https://jsonplaceholder.typicode.com")
  #endif
}
Enter fullscreen mode Exit fullscreen mode

I did not want to use a macro wherever configuration specific code is required. So I next created a swift enum reflecting all application modes. The mode is determined at startup and made available as shared global property (same as it is often done e.g. for the PersistenceController and CoreData):

enum ApplicationMode: String {
  case release
  case test
  case debug
}


#if TEST
let mode = ApplicationMode.test
#elseif DEBUG
let mode = ApplicationMode.debug
#else
let mode = RunningMode.release
#endif
Enter fullscreen mode Exit fullscreen mode

To me, the approach has the advantage that Swift language features can be used when working with the configuration. It also decouples the application running mode from the build configurations (in case necessary for any reason).

The global available application mode property can next be combined with any dependency inject approach (e.g. like the Single-container approach), restricting the configuration dependant implementations to a few dedicated classes:

protocol TodosRepository: ListTodos {}

class AppContainer: ObservableObject {
  private let todosRepository: TodosRepository
  private let todosContainer: TodosContainer

  init() {
    todosRepository = AppContainer.makeTodosRepository()
    todosContainer = AppContainer.makeTodosContainer(listTodos: todosRepository)
    Logger().debug("Application running in mode: \(mode.rawValue)")
  }

  func makeTodosContainer() -> TodosContainer {
    todosContainer
  }

  private static func makeTodosRepository() -> TodosRepository {
    switch mode {
    case .test:
      return InMemoryTodosRepository()
    default:
      return ProductionTodosRepository(host: "https://jsonplaceholder.typicode.com")
    }
  }

  private static func makeTodosContainer(listTodos: ListTodos) -> TodosContainer {
    TodosContainer(listTodos: listTodos)
  }
}

extension ProductionTodosRepository: TodosRepository {}
extension InMemoryTodosRepository: TodosRepository {}
Enter fullscreen mode Exit fullscreen mode

Schemes

To make it all work for test runs the build configuration is set to Test for the Test step:

Test step scheme

If everything has been wired up correctly the application should now use the Test dependencies on test startup (at least mine did ;) ).

Conclusion

The short article described how to exchange dependencies based on tags by:

  • Defining a custom build configuration and Active Compilation Conditions property
  • Injecting dependencies based on the application running mode derived from the active build configuration
  • Creating/Updating a scheme to run the tests with the custom build configuration

This now allows for having different dependencies during test and debugging sessions. In case of interest, a small example application using the approach can be found on Github.

Environment Variables

As explained here exchanging dependencies only requires different behavior and not different compilation, so environment variables read at runtime could be a solution as well.

I opted against such an approach as the variables are set for all build configurations and therefore need to be manually switched on/off depending e.g. when running tests or debugging.

Besides the advantage of not having to recompile the application when changing the variable was not relevant for my use case/problem.

Discussion (0)