9
\$\begingroup\$

In my application, I am using UserDefaults to save some data.
I also started writing tests for testing my application code.
At one place I stuck where I wanted to write tests for functions which are saving data to UserDefaults.
When I wrote tests with fake data, my actual data got overwritten by fake data.

So I choose to use Mocking for UserDefaults as follow:

This is controller class which manages saving and fetching data from UserDefaults:

UserDefaultsController-

protocol UserDefaultsProtocol: class {
    func theObject(forKey key: String) -> Any?
    func setTheObject(_ object: Any, forKey key: String)
    func removeTheObject(forKey key: String)
    func synchronizeAll()
}

class UserDefaultsController: NSObject {
    static let shared = UserDefaultsController()
    var delegate: UserDefaultsProtocol?

    override init() {
        super.init()
        //default delegate
        delegate = UserDefaults.standard
    }

    func object(forKey key: String) -> Any? {
        return delegate?.theObject(forKey: key)
    }

    func set(_ value: Any, forKey key: String) {
        delegate?.setTheObject(value, forKey: key)
    }

    func removeObject(forKey key: String) {
        delegate?.removeTheObject(forKey: key)
    }

    func synchronize() {
        delegate?.synchronizeAll()
    }
}

Here is UserDefault class extension which conforms protocol as:

extension UserDefaults: UserDefaultsProtocol {

    func theObject(forKey key: String) -> Any? {
        return self.object(forKey: key)
    }

    func setTheObject(_ object: Any, forKey key: String) {
        self.set(object, forKey: key)
    }

    func removeTheObject(forKey key: String) {
        self.removeObject(forKey: key)
    }

    func synchronizeAll() {
        self.synchronize()
    }
}

Whenever I want to interact with UserDefaults, I do it th’r above class as, e.g.:

UserDefaultsController.shared.set("test.com", forKey: “DomainNameKey”)

It is working fine, no issue at all!

Now, when I write tests, I use mock object to conform to UserDefaultsProtocol as:

class UserDefaultsMock: NSObject, UserDefaultsProtocol {

    private var dict = [String: Any?]()

    deinit {
        dict.removeAll()
    }

    //MARK: - Protocol Methods -
    func theObject(forKey key: String) -> Any? {
        if let object = dict[key] {
            return object
        }
        return nil
    }

    func setTheObject(_ object: Any, forKey key: String) {
        dict[key] = object
    }

    func removeTheObject(forKey key: String) {
        dict.removeValue(forKey: key)
    }

    func synchronizeAll() {
        //none
    }
}

And here is actual test:

class ArrayTests: XCTestCase {

    override func setUp() {
        super.setUp()
        // Put setup code here. This method is called before the invocation of each test method in the class.
        UserDefaultsController.shared.delegate = UserDefaultsMock()
        UserDefaultsController.shared.set("test.com", forKey: “DomainNameKey”)
    }

    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
        UserDefaultsController.shared.delegate = nil
        super.tearDown()
    }

    func testFunction() {
        XCTAssertEqual(someFunctionWhichInteractsWithUserDefaults())
    }
}

Now my test is not overwriting anything on my actual data, works perfect!
But my worry is, I need to write all the UserDefault APIs like string(forKey), int(forKey), etc in protocol, controller class, and in UserDefault extension and mock.

Is this is the way it should be Or am I missing any other approach on testing UserDefaults?


My only worry is because every time in my app I have to use UserDefaultsController instead of UserDefaults!

\$\endgroup\$
1
  • 5
    \$\begingroup\$ If you define the protocol to exactly line up with the interface on UserDefaults, you can add protocol adherence with no extra code. Then program to the protocol and inject the live or the fake object. \$\endgroup\$
    – CAD97
    Jan 20, 2017 at 19:04

1 Answer 1

13
\$\begingroup\$

So first, as CAD97 rightly points out, if you simply define your protocol as identically matching the methods from UserDefaults you intend to use, then making UserDefaults conform to the protocol is much simpler:

extension UserDefaults: UserDefaultsProtocol {}

And there ya go, done. Having to write less code is nice. What's even nicer here is that we're doing all of this presumably for the sake of testing. If you're testing right, you're probably looking at your code coverage numbers. Using this approach, we don't have rogue uncovered lines in the extension that we're not going to hit because we're using the mock.


I'm not a huge fan of the name you've picked for the protocol. Maybe it's not so bad... and it does largely depend on what you're actually using user defaults for. But more generally, user defaults is just a key-value store for persisting information between sessions. There are other key-value store options as well though. And I believe you'd be better served by making your protocol more generally match more of the options. The advantage gained here is that swapping from user defaults to key chain to any other system because pretty effortless.


I do not like the UserDefaultsController at all. You've added an extra, unnecessary layer of complications here. It's a singleton god class... and it means that if you set up your whole app today to use user defaults, the option to change just parts of it over to a different option aren't as easy.

There's a better way.

Your current approach looks like this:

func fooBarFunc() -> String? {
    return UserDefaultsController.shared.object(forKey: "fooBarKey") as? String
}

However, we could alternatively do something more like this...

func fooBarFunc(from storage: UserDefaultsProtocol = UserDefaults.standard) -> String? {
    return storage.object(forKey: "fooBarKey") as? String
}

Both of these are still called the same:

let value = fooBarFunc()

Because in the second implementation, we pass UserDefaults.standard as the default argument for the storage to pull from. However, when we want to test this method, we create a different object and pass that in:

let mockStorage = MockStorage(withValue: "expectedValue")
let value = fooBarFunc(from: mockStorage)
XCTAssertEqual(value, "expectedValue")

And alternative, the storage could be pass in to an object's constructor:

init(storage: UserDefaultsProtocol = UserDefaults.standard)

In the main target, we don't pass anything for this argument and just let it default to the standard user defaults. But again in tests, we implement a mock class that conforms to the protocol and initialize our class with that mock.


This is called dependency injection. Instead of relying on a god class living somewhere in our code base (and increasing the coupling of our whole app), we pass dependencies into the code that relies on these dependencies. We use a protocol to keep our code base tightly aligned, but passing objects in, we keep coupling relatively low. If one class suddenly starts needing something a little different, we can just start passing it something different instead of having to touch the code in the god class... which every other corner of our code base touches... potentially introducing tons of regressions.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.