Testing Mastery

Refactoring Singletons for Testability

March 8, 2026
5 min read
Featured image for blog post: 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

  1. Extract - Create a protocol for what the singleton provides
  2. Conform - Make the singleton conform to the protocol
  3. Inject - Accept the protocol in init with singleton as default
  4. 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.

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch