You can’t change the behavior of singletons you don’t own. But any singleton you do own provides an opportunity to add a singleton backdoor. We can use conditional compilation to ensure that the backdoors aren’t available in release builds.
For this experiment, let’s assume MySingletonAnalytics is code we own. It uses the Adapter design pattern[17] to wrap the actual analytics API:
| class MySingletonAnalytics { |
| static let shared = MySingletonAnalytics() |
| |
| func track(event: String) { |
| Analytics.shared.track(event: event) |
| |
| if self !== MySingletonAnalytics.shared { |
| print(">> Not the MySingletonAnalytics singleton") |
| } |
| } |
| } |
![]() |
Any time you use a third-party framework, consider wrapping it in an Adapter. This will let you change or augment the underlying implementation without changing the call sites. |
MySingletonViewController uses this singleton to track calls to viewDidAppear(_:).
| override func viewDidAppear(_ animated: Bool) { |
| super.viewDidAppear(animated) |
| MySingletonAnalytics.shared.track( |
| event: "viewDidAppear - \(type(of: self))" |
| ) |
| } |
Using this singleton is fine. But during testing, we want to use something else. Later, we’ll learn how to create mock objects that can record calls. For now, let’s just get it to use a different instance.
We’ll add a layer of indirection around the singleton. In the other cases where we don’t own the singleton, we’ll do this at the calling code. But if you own the singleton, you can add indirection inside the called code.
Add a new static instance of MySingletonAnalytics. Declare it private to restrict its visibility. Then change shared from a stored property to a computed property returning the new instance:
| private static let instance = MySingletonAnalytics() |
| |
| static var shared: MySingletonAnalytics { |
| return instance |
| } |
Also wrap the track(event:) method’s print statement in a conditional, to compare against the new static instance. This way, it’ll report when we’re not using the regular singleton:
» | if self !== MySingletonAnalytics.instance { |
| print(">> Not the MySingletonAnalytics singleton") |
» | } |
Build to confirm these changes, which are transparent to the call sites.
Now let’s add the backdoor, wrapped in #if DEBUG conditional compilation. What we want is a way for test code to provide a different object in place of the singleton:
| private static let instance = MySingletonAnalytics() |
| |
| #if DEBUG |
» | static var stubbedInstance: MySingletonAnalytics? |
| #endif |
| static var shared: MySingletonAnalytics { |
| #if DEBUG |
» | if let stubbedInstance = stubbedInstance { |
» | return stubbedInstance |
» | } |
| #endif |
| |
| return instance |
| } |
Now if a test provides a stubbedInstance, the shared property will return it instead of the singleton. To ensure that we’re doing this substitution consistently, inject the stub in setUp and remove it in tearDown. Add a new test suite MySingletonViewControllerTests:
| @testable import HardDependencies |
| import XCTest |
| |
| class MySingletonViewControllerTests: XCTestCase { |
| |
| override func setUp() { |
| super.setUp() |
| MySingletonAnalytics.stubbedInstance = MySingletonAnalytics() |
| } |
| |
| override func tearDown() { |
| MySingletonAnalytics.stubbedInstance = nil |
| super.tearDown() |
| } |
| |
| func test_viewDidAppear() { |
| let sut = MySingletonViewController() |
| sut.loadViewIfNeeded() |
| |
| sut.viewDidAppear(false) |
| |
| // Normally, assert something |
| } |
| } |
Run tests. Since this is an experiment, the test case has no assertion. But our fake implementation of event tracking has print(_:) statements in Analytics.swift. By examining the console output (see Examine Console Output) we can see the log for this test case:
| Test Case '-[HardDependenciesTests.MySingletonViewControllerTests |
| test_viewDidAppear]' started. |
| >> viewDidAppear - MySingletonViewController |
| >> Not the MySingletonAnalytics singleton |
| Test Case '-[HardDependenciesTests.MySingletonViewControllerTests |
| test_viewDidAppear]' passed (0.001 seconds). |
The log shows the event tracking works. The message “Not the MySingletonAnalytics singleton” also shows that we replaced the singleton with something else.
![]() |
In general, you should avoid mixing test code into production code. Conditional compilation makes code hard to read, reason about, and maintain. Dependency Injection Principles, Practices, and Patterns [vS19] describes the singleton backdoor as an anti-pattern called Ambient Context. It’s far preferable to use other means of injection, especially constructor injection. We’ll look at this in Inject Instances Through Initializers or Properties. But if you already have a singleton you own, and it’s already in wide use, adding a backdoor can provide a small enabling point[18] to switch behavior. It’s like a hidden panel on a home theater system, concealing controls you don’t need for daily use. It doesn’t improve the singleton-centric design. But it allows you to test code that uses that singleton, without modifying the call sites. That’s progress, and any progress is good. |