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:
- Creating a custom build configuration and configuring the Active Compilation Conditions
- Injecting dependencies based on the configuration selected
- Creating/updating the Xcode
scheme
forRun
andTest
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:
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
).
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
}
I did not want to use a macro wherever configuration specific code is required. So I next created a swift enum reflecting all application mode
s. 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
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 {}
Schemes
To make it all work for test runs the build configuration is set to Test
for the Test
step:
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.
Top comments (0)