Testing Mastery

Why Testable Code Matters

March 5, 2026
5 min read
Featured image for blog post: Why Testable Code Matters

You've shipped features, fixed bugs, and written a lot of code. But can you test it?

Follow along with the code: iOS-Practice on GitHub. New here? Read the setup guide to get started.

Why Tests Matter

"It works on my machine" isn't a testing strategy. Tests give you:

  1. Confidence to refactor - Change implementation without fear of breaking behavior
  2. Documentation - Tests show how code is meant to be used
  3. Faster debugging - A failing test pinpoints exactly what broke
  4. Better design - Testable code is usually better-designed code

That last point is key. If code is hard to test, it's often because of design problems. Making code testable forces you to improve it.

What Makes Code Untestable?

Three patterns kill testability:

1. Hidden Dependencies

class UserProfileManager {
    func loadProfile() async {
        // Hidden: directly accesses singletons
        guard let userId = AuthManager.shared.currentUserId else { return }
        let profile = try? await APIClient.shared.fetchProfile(userId)
        AnalyticsService.shared.track("profile_loaded")
    }
}

How do you test this without:

  • Actually authenticating a user?
  • Making real network calls?
  • Sending analytics events to production?

You can't. The dependencies are hidden inside the implementation.

2. Tight Coupling to External Systems

class ProductService {
    func fetchProducts() async throws -> [Product] {
        let url = URL(string: "https://api.example.com/products")!
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Product].self, from: data)
    }
}

Testing requires:

  • Network connectivity
  • The API server to be running
  • Specific data to be in the database

Tests become slow and flaky.

3. Side Effects Everywhere

class SettingsManager {
    func saveTheme(_ theme: String) {
        UserDefaults.standard.set(theme, forKey: "theme")
        NotificationCenter.default.post(name: .themeChanged, object: nil)
    }
}

After tests run:

  • UserDefaults is polluted with test data
  • Notifications triggered observers you didn't expect
  • State leaked between test cases

The Solution: Dependency Injection

Instead of reaching for dependencies, have them passed in:

// Protocol defines what we need
protocol AuthProviding {
    var currentUserId: String? { get }
}

protocol ProfileFetching {
    func fetchProfile(_ userId: String) async throws -> Profile
}

protocol AnalyticsTracking {
    func track(_ event: String)
}

// Dependencies are injected
class UserProfileManager {
    private let auth: AuthProviding
    private let api: ProfileFetching
    private let analytics: AnalyticsTracking

    init(
        auth: AuthProviding,
        api: ProfileFetching,
        analytics: AnalyticsTracking
    ) {
        self.auth = auth
        self.api = api
        self.analytics = analytics
    }

    func loadProfile() async {
        guard let userId = auth.currentUserId else { return }
        let profile = try? await api.fetchProfile(userId)
        analytics.track("profile_loaded")
    }
}

Now in tests:

class MockAuth: AuthProviding {
    var currentUserId: String? = "test_user"
}

class MockAPI: ProfileFetching {
    var profileToReturn: Profile?

    func fetchProfile(_ userId: String) async throws -> Profile {
        return profileToReturn!
    }
}

class MockAnalytics: AnalyticsTracking {
    var trackedEvents: [String] = []

    func track(_ event: String) {
        trackedEvents.append(event)
    }
}

func test_loadProfile_tracksAnalytics() async {
    let mockAnalytics = MockAnalytics()
    let manager = UserProfileManager(
        auth: MockAuth(),
        api: MockAPI(),
        analytics: mockAnalytics
    )

    await manager.loadProfile()

    XCTAssertEqual(mockAnalytics.trackedEvents, ["profile_loaded"])
}

No network. No real auth. No production analytics. Fast, deterministic, isolated.

What This Series Covers

Over the next several posts, we'll tackle:

  1. Refactoring Singletons - How to make Singleton.shared testable
  2. Protocol-Based DI - The pattern that makes everything mockable
  3. Network Layer Abstraction - Testing HTTP without hitting servers
  4. Time-Dependent Code - Making Date() predictable in tests
  5. Side Effect Management - UserDefaults, file system, notifications
  6. ViewModel Testing - Testing @Published state changes
  7. Mock Types - When to use mocks, stubs, fakes, and spies
  8. Async Testing - Testing async/await and actors

The Mindset Shift

Writing testable code isn't about tests—it's about design. When you ask "how would I test this?", you're really asking:

  • Are my dependencies explicit?
  • Is this class doing too much?
  • Can I verify the behavior without side effects?

Good answers to these questions lead to better code, whether or not you write the tests.

The Rule

If you can't test it in isolation, it's too coupled. Dependencies should be visible, injectable, and mockable. The goal isn't 100% coverage—it's code that's possible to test when it matters.

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