Why UserDefaults.standard Breaks Your Tests

Your test suite passes. Then you run it again. Now it fails because the previous run left data behind.
Follow along with the code: iOS-Practice on GitHub
The Problem
class OnboardingManagerBefore {
private let hasSeenOnboardingKey = "hasSeenOnboarding"
var hasSeenOnboarding: Bool {
get { UserDefaults.standard.bool(forKey: hasSeenOnboardingKey) }
set { UserDefaults.standard.set(newValue, forKey: hasSeenOnboardingKey) }
}
func shouldShowOnboarding() -> Bool {
return !hasSeenOnboarding
}
func completeOnboarding() {
hasSeenOnboarding = true
}
}
Want to try it yourself? In UserDefaultsExerciseView.swift, delete everything from line 91 onwards (the // MARK: - AFTER section) and try to refactor OnboardingManagerBefore to be testable. Your goal: make all tests in UserDefaultsExerciseTests.swift pass.
Why UserDefaults.standard Breaks Tests
UserDefaults.standard is a singleton backed by a persistent plist file. When your tests call it, they're writing to the same file your app uses—and that file survives between test runs.
// In your test
sut.completeOnboarding() // Writes "hasSeenOnboarding = true" to disk
// Next test run, or when you launch the app...
UserDefaults.standard.bool(forKey: "hasSeenOnboarding") // Still true!
The consequences:
- Tests affect each other: Test A sets
hasSeenOnboarding = true, Test B expects fresh state but findstrue - Tests modify real app data: Run your tests, then launch the app—your onboarding is already "complete"
- Can't test "first launch": The key exists forever once set
- Order dependency: Tests pass in one order, fail in another
- Parallel test failures: Multiple tests writing to the same keys simultaneously
The Fix: Abstract the Storage
Define a protocol for key-value storage:
protocol KeyValueStore {
func bool(forKey key: String) -> Bool
func string(forKey key: String) -> String?
func integer(forKey key: String) -> Int
func setBool(_ value: Bool, forKey key: String)
func setString(_ value: String?, forKey key: String)
func setInt(_ value: Int, forKey key: String)
func removeObject(forKey key: String)
}
Make UserDefaults conform:
extension UserDefaults: KeyValueStore {
func setBool(_ value: Bool, forKey key: String) {
set(value, forKey: key)
}
func setString(_ value: String?, forKey key: String) {
set(value, forKey: key)
}
func setInt(_ value: Int, forKey key: String) {
set(value, forKey: key)
}
}
Inject the Store
class OnboardingManager {
private let store: KeyValueStore
private enum Keys {
static let hasSeenOnboarding = "hasSeenOnboarding"
static let lastVersion = "lastAppVersion"
static let launchCount = "launchCount"
}
init(store: KeyValueStore = UserDefaults.standard) {
self.store = store
}
var hasSeenOnboarding: Bool {
get { store.bool(forKey: Keys.hasSeenOnboarding) }
set { store.setBool(newValue, forKey: Keys.hasSeenOnboarding) }
}
func shouldShowOnboarding() -> Bool {
return !hasSeenOnboarding
}
func completeOnboarding() {
hasSeenOnboarding = true
}
}
Production code still uses UserDefaults.standard by default. Tests inject a mock store that's isolated and reset between tests.