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:
- Confidence to refactor - Change implementation without fear of breaking behavior
- Documentation - Tests show how code is meant to be used
- Faster debugging - A failing test pinpoints exactly what broke
- 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:
- Refactoring Singletons - How to make
Singleton.sharedtestable - Protocol-Based DI - The pattern that makes everything mockable
- Network Layer Abstraction - Testing HTTP without hitting servers
- Time-Dependent Code - Making
Date()predictable in tests - Side Effect Management - UserDefaults, file system, notifications
- ViewModel Testing - Testing
@Publishedstate changes - Mock Types - When to use mocks, stubs, fakes, and spies
- 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.