Figure

A blog about Swift and iOS development.

Testing UserDefaults

As with all dependencies, we should reduce UserDefaults to an abstract protocol, inject it into the classes that require it, and thereby decouple ourselves from implementation details and increase the testability of our code by introducing seams.

But I'll be honest, the UserDefaults API is so simple and pervasive that standard dependency management feels like overkill. I always end up calling it directly from my code. Perhaps you do the same?

If so, we've probably encountered the same problem: without the ability to inject mocks, testing code that makes use of UserDefaults can be a pain. Any time we use our test device (including running tests on it!) we potentially change the state of its persisted settings.

So all our tests have to initialize settings to some known state before running. Which introduces a lot of ugly boilerplate and makes it difficult for us to test uninitialized "default" states.

Thankfully, UserDefaults is pretty flexible and we can code our way out of this hole.

Domain Names

UserDefaults is built around five1 conceptual "domains":

  • Volatile Domain
  • Persistent Domain
  • Registration Domain
  • Argument Domain
  • Global Domain

Each domain can hold any number of keys and values. If two or more domains have values for the same key, the value of the higher domain overrides values of the lower ones.

We're probably all familiar with this concept in the context of the registration and persistent domains. The registration domain holds the "default" values we set up using register(defaults:) on app launch. The persistent domain holds the user values we persist using set(_:forKey:).2 And we know that if we register a default then persist a value from the user, it's the persisted value we'll get back from UserDefaults.

But the defaults we registered are still there in the registration domain. If we could somehow peel back the persistent domain, we could test from the "base state" of our app without any of the goofy stuff that might have been persisted by other tests or users.

A Clean Slate

UserDefaults has a mechanism for this: setPersistentDomain(_:forName:). The documentation helpfully states that "name" here "should be equal to your application's bundle identifier." So clearing out our UserDefaults is as simple putting something like this in our setUp():

override func setUp() {
  let bundle = Bundle.main
  let defaults = UserDefaults.standard
  guard let name = bundle.bundleIdentifier else {
      fatalError( ... )
  }
  defaults.setPersistentDomain([:], forName: name)
}

And this works. But two problems. First, it blows away the persisted preferences of our app. If we're running tests on our carry device, it can be a pain to have our app reset every time we test.

Second, I personally hate having setUp() and tearDown() methods in my tests. Code in setUp() feels so far removed from where it's actually used, and most of my tests require some amount custom setup that can't be reduced to a single function anyway.

So here's what I use instead. I've been very happy with it:

extension UserDefaults {
  let bundle = Bundle.main
  let defs = UserDefaults.standard
  static func blankDefaultsWhile(handler:()->Void){
    guard let name = bundle.bundleIdentifier else {
      fatalError("Couldn't find bundle ID.")
    }
    let old = defs.persistentDomain(forName: name)
    defer {
      defs.setPersistentDomain( old ?? [:], 
                                forName: name)
    }

    defs.removePersistentDomain(forName: name)
    handler()
  }
}

Then my tests look something like:

class MyTests: XCTestCase {
  func testThing() {
    // Defaults can be full of junk.
    UserDefaults.blankDefaultsWhile {
      // Some tests that expect clean defaults.
      // They can also scribble all over defaults
      // with test-specific values.
    }
    // Defaults are back to their pre-test state.
  }
}

Remember, as of Swift 3 closures are non-escaping by default. So blankDefaultsWhile's trailing closure doesn't need a @noescape to avoid the self tax.


1: There's actually a sixth "Host Domain" that scopes preferences to host name. But this is (even more) rarely used, and only accessible through Core Foundation.↩︎

2: As for the rest, the global domain holds system-wide preferences for all apps. The argument domain holds preferences we pass when launching apps from the command line (or via "Arguments Passed on Launch" in our Xcode project's scheme). The volatile domain is more or less equivalent to the persistent domain, except its values don't get saved to disk, and are thus lost every time an app is quit.↩︎


Hit me up on twitter (@jemmons) to continue the conversation.

Latest | Archives