Testing Mastery

Why UserDefaults.standard Breaks Your Tests

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

  1. Tests affect each other: Test A sets hasSeenOnboarding = true, Test B expects fresh state but finds true
  2. Tests modify real app data: Run your tests, then launch the app—your onboarding is already "complete"
  3. Can't test "first launch": The key exists forever once set
  4. Order dependency: Tests pass in one order, fail in another
  5. 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.

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