How to unit test UserDefaults
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:
- We declare a variable of type
UserDefaults
that we will use to read and write data in memory. - We create a
UserDefaults
object initialized with default values, using the content of#file
as the name. - 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.