Refactoring Singletons for Testability

AnalyticsService.shared. AuthManager.shared. APIClient.shared. Singletons are everywhere—and they make testing painful.
Follow along with the code: iOS-Practice on GitHub
The Problem
class UserProfileManager {
func loadProfile() async {
// ❌ Can't test without real analytics
AnalyticsServiceSingleton.shared.track(event: "profile_load_started")
// ❌ Can't control auth state in tests
guard let userId = AuthenticationManagerSingleton.shared.currentUserId else {
return
}
// ❌ Can't mock network responses
let profile = try? await APIClientSingleton.shared.fetchUserProfile(userId: userId)
}
}
Every Singleton.shared call is a hidden dependency. You can't:
- Test without hitting real services
- Control what the singleton returns
- Verify calls were made correctly
Want to try it yourself? In SingletonExerciseView.swift, delete everything from line 118 onwards (the // MARK: - AFTER section) and try to refactor UserProfileManagerBefore to be testable. Your goal: make all tests in SingletonExerciseTests.swift pass.
Step 1: Extract Protocols
Define what you need from each singleton:
protocol AnalyticsTracking {
func track(event: String, properties: [String: Any]?)
}
protocol AuthenticationProviding {
var currentUserId: String? { get }
var isAuthenticated: Bool { get }
}
protocol UserProfileFetching {
func fetchUserProfile(userId: String) async throws -> UserProfileData
}
Protocols describe capabilities, not implementations.
Step 2: Conform Singletons to Protocols
Your existing singletons already do the work—just declare conformance:
extension AnalyticsServiceSingleton: AnalyticsTracking {}
extension AuthenticationManagerSingleton: AuthenticationProviding {}
extension APIClientSingleton: UserProfileFetching {}
If the singleton's methods match the protocol, this is all you need.
Step 3: Inject Dependencies
Replace direct singleton access with injected protocols:
class UserProfileManager {
private let analytics: AnalyticsTracking
private let auth: AuthenticationProviding
private let api: UserProfileFetching
init(
analytics: AnalyticsTracking = AnalyticsServiceSingleton.shared,
auth: AuthenticationProviding = AuthenticationManagerSingleton.shared,
api: UserProfileFetching = APIClientSingleton.shared
) {
self.analytics = analytics
self.auth = auth
self.api = api
}
func loadProfile() async {
analytics.track(event: "profile_load_started", properties: nil)
guard let userId = auth.currentUserId else { return }
let profile = try? await api.fetchUserProfile(userId: userId)
}
}
Key insight: default parameter values let production code use singletons while tests inject mocks.
Step 4: Create Test Mocks
Now you can create simple mock implementations:
class MockAnalytics: AnalyticsTracking {
var trackedEvents: [(event: String, properties: [String: Any]?)] = []
func track(event: String, properties: [String: Any]?) {
trackedEvents.append((event, properties))
}
}
class MockAuth: AuthenticationProviding {
var currentUserId: String?
var isAuthenticated: Bool { currentUserId != nil }
}
class MockAPI: UserProfileFetching {
var profileToReturn: UserProfileData?
var errorToThrow: Error?
func fetchUserProfile(userId: String) async throws -> UserProfileData {
if let error = errorToThrow { throw error }
return profileToReturn!
}
}
Step 5: Write Tests
func test_loadProfile_whenNotAuthenticated_doesNotFetch() async {
// Arrange
let mockAuth = MockAuth()
mockAuth.currentUserId = nil // Not authenticated
let mockAPI = MockAPI()
mockAPI.profileToReturn = UserProfileData(id: "123", name: "Test")
let manager = UserProfileManager(
analytics: MockAnalytics(),
auth: mockAuth,
api: mockAPI
)
// Act
await manager.loadProfile()
// Assert - profile should not be set since not authenticated
XCTAssertNil(manager.profile)
}
func test_loadProfile_tracksStartAndSuccessEvents() async {
// Arrange
let mockAnalytics = MockAnalytics()
let mockAuth = MockAuth()
mockAuth.currentUserId = "user_123"
let mockAPI = MockAPI()
mockAPI.profileToReturn = UserProfileData(id: "user_123", name: "John")
let manager = UserProfileManager(
analytics: mockAnalytics,
auth: mockAuth,
api: mockAPI
)
// Act
await manager.loadProfile()
// Assert
XCTAssertEqual(mockAnalytics.trackedEvents.count, 2)
XCTAssertEqual(mockAnalytics.trackedEvents[0].event, "profile_load_started")
XCTAssertEqual(mockAnalytics.trackedEvents[1].event, "profile_load_success")
}
Production Code Unchanged
Here's the beauty: production code doesn't change its call site.
// Before (singleton)
let manager = UserProfileManager()
// After (still works!)
let manager = UserProfileManager()
Default parameter values mean you only specify dependencies when you need to override them (in tests).
The Refactoring Pattern
- Extract - Create a protocol for what the singleton provides
- Conform - Make the singleton conform to the protocol
- Inject - Accept the protocol in init with singleton as default
- Mock - Create test implementations of the protocol
When to Keep Singletons
Singletons aren't always bad. They're fine for:
- Truly global state (app configuration)
- Resource pools (URLSession.shared is fine)
- Logging (often doesn't need testing)
The issue is hidden singleton access. Even if you use singletons, make them injectable for testability.
The Rule
Extract, conform, inject, mock. Every singleton access is a hidden dependency. Making them explicit through protocols gives you control in tests while keeping production code clean.