How to unit test UserDefaults

AsyncLearn
4 min read2 days ago

--

User Defaults provide a mechanism for persisting data in our apps. They are very useful when we need to save information that should be available even after the user has closed the application.

However, they can cause some confusion when performing unit tests. Here are two options to handle this:

  • Option 1: Creating unit tests using a Mock.
  • Option 2: Creating an instance for testing.

Option 1: Creating Unit Tests Using a Mock

One option could be to create a mock, allowing us to inject an object into our objects that behaves like User Defaults in production, but can be substituted by another implementation when running tests.

To create User Defaults, we use the method:

UserDefaults.standard.set(10, forKey: "com.app.counter")

To read, we can use defined methods depending on the type of data we want to obtain. In this case, we’ll use integer:

UserDefaults.standard.integer(forKey: "com.app.counter")

We want to be able to test without writing the information to disk, so we’ll create a protocol to inject an object that can provide these functionalities:

protocol UserDefaultsProtocol {
func set(_ value: Int, forKey: String)
func integer(forKey: String) -> Int
}

To conform to this protocol, we need to provide methods to save (func set(Int, forKey: String)) and read data (func integer(forKey: String) -> Int).

Now let’s see how we could use this in our code:

struct TodoListService {
init(defaults: UserDefaultsProtocol) {
...
}
}

User Defaults provide a mechanism for persisting data in our apps. They are very useful when we need to save information that should be available even after the user has closed the application.

However, they can cause some confusion when performing unit tests. Here are two options to handle this:

  • Option 1: Creating unit tests using a Mock.
  • Option 2: Creating an instance for testing.

Option 1: Creating Unit Tests Using a Mock

One option could be to create a mock, allowing us to inject an object into our objects that behaves like User Defaults in production, but can be substituted by another implementation when running tests.

To create User Defaults, we use the method:swifCopy code

UserDefaults.standard.set(10, forKey: "com.app.counter")

To read, we can use defined methods depending on the type of data we want to obtain. In this case, we’ll use integer:

UserDefaults.standard.integer(forKey: "com.app.counter")

We want to be able to test without writing the information to disk, so we’ll create a protocol to inject an object that can provide these functionalities:

protocol UserDefaultsProtocol {
func set(_ value: Int, forKey: String)
func integer(forKey: String) -> Int
}

To conform to this protocol, we need to provide methods to save (func set(Int, forKey: String)) and read data (func integer(forKey: String) -> Int).

Now let’s see how we could use this in our code:

struct TodoListService {
init(defaults: UserDefaultsProtocol) {
...
}
}

Here we see the TodoListService, which expects a defaults parameter that must conform to UserDefaultsProtocol. This allows us to test our TodoListService and verify that calls to defaults are made appropriately.

This way of working is very useful in most cases. We inject a dependency and track its behavior using a unit testing technique, such as using a Spy, or passing a closure in the constructor that will execute when a specific action is performed. Let’s look at this second case:

struct UserDefaultsMock: UserDefaultsProtocol {
private let completion: () -> Void

init(completion: @escaping () -> Void) {
self.completion = completion
}

func set(_ value: Int, forKey: String) {
completion()
}

func integer(forKey: String) -> Int {
...
}
}

Now let’s see how we would use this in a unit test:

func testUserDefaultIsSet() throws {
let expectation = expectation(description: #function)
let mock = UserDefaultsMock {
expectation.fulfill()
}

let service = TodoListService(defaults: mock)
service.doSomething()

wait(for: [expectation])
}

This way of performing injection and tests is useful, but it is not the only one, and depending on the case, it may not be recommended.

Option 2: Creating an Instance for Testing

Another option is creating an instance of UserDefaults that we can configure as needed. Using the following code in the setUp() method of the XCTestCase, we can use UserDefaults without persisting to disk, but in memory:

// 1
private var userDefaults: UserDefaults!

override func setUp() {
super.setUp()

// 2
userDefaults = UserDefaults(suiteName: #file)
// 3
userDefaults.removePersistentDomain(forName: #file)
}

With this code:

  1. We declare a variable of type UserDefaults that we will use to read and write data in memory.
  2. We create a UserDefaults object initialized with default values, using the content of #file as the name.
  3. We clear the existing content; this is useful because it gives us an empty instance for each unit test.

In many cases, this may be sufficient. To perform the unit test we saw earlier, we would no longer need to create a mock, but pass the declared userDefaults instance directly.

func testUserDefaultIsSet() throws {
let service = TodoListService(defaults: userDefaults)
service.doSomething()

XCTAssertEqual(1, userDefaults.integer(forKey: "com.app.counter"))
}

Now, with the unit test, we can check that the integer has the value we require, which changes when we execute doSomething(). Without the need to use a mock, this technique can be more reliable in most cases, although mocks may be necessary for some use cases.

--

--

AsyncLearn

Stay up-to-date in the world of mobile applications with our specialised blog.