Weekly Swift articles, podcasts and tips by John Sundell.

Quickly replacing singletons with functions

Published on 27 May 2020
Basics article available: Unit Testing

The singleton pattern, while incredibly widely used, is a common source of debate within the Apple developer community. On one hand, singletons provide a really convenient way to share state and functionality across an application — but on the other hand, they also tend to blur the boundaries between the various layers of a code base, and often make tasks like testing more difficult than they otherwise would be.

As an example, let’s say that we’re working on a UserLoader, which provides a way to load a User with a given ID. Internally, our loader uses two singletons to perform its work — a UserCache and a NetworkManager — and currently looks like this:

struct UserLoader {
    func loadUser(
        withID id: User.ID,
        then handler: @escaping (Result<User, Error>) -> Void
    ) {
        // If our shared cache already contains the user we're
        // about to load, then use that cached value instead:
        if let user = UserCache.shared.user(withID: id) {
            return handler(.success(user))
        }

        // Use our application's shared network manager to perform
        // a network call, and then decode the result:
        NetworkManager.shared.loadData(from: .user(id: id)) { result in
            do {
                let data = try result.get()
                let user = try JSONDecoder().decode(User.self, from: data)
                UserCache.shared.insert(user)
                handler(.success(user))
            } catch {
                handler(.failure(error))
            }
        }
    }
}

While the above type does everything that we need it to do in terms of our production code, unit testing its internal logic would be quite difficult — as it currently offers no way for us to inject mocked versions of its dependencies.

For example, let’s say that we wanted to write a test that verifies our caching logic — to make sure that we’re not performing duplicate network requests when loading the same user multiple times. How could we do that using our current singleton-based setup — given that there’s currently no way for us to observe, or otherwise control, what type of networking that our UserLoader is using?

While there are a number of different approaches that we could take in order to solve this problem, including abstracting our dependencies behind protocols, or using some form of mocked networking session — it turns out that there’s a quite easy way to make most singletons mockable, without having to introduce a ton of new abstractions.

Since Swift supports first class functions, we could simply extract each of the functions that we’re looking to call on our singletons, and store them as properties instead — like this:

struct UserLoader {
    var networking = NetworkManager.shared.loadData
    var cacheInsertion = UserCache.shared.insert
    var cacheRetrieval = UserCache.shared.user
    
    ...
}

What’s really neat about the above approach is that it barely requires us to change our production code at all. All usages of our UserLoader type can remain identical, there’s no need for advanced dependency injection techniques, and within the implementation of our type we just need to replace our singleton calls with calls to our stored functions — like this:

struct UserLoader {
    var networking = NetworkManager.shared.loadData
    var cacheInsertion = UserCache.shared.insert
    var cacheRetrieval = UserCache.shared.user

    func loadUser(
        withID id: User.ID,
        then handler: @escaping (Result<User, Error>) -> Void
    ) {
        if let user = cacheRetrieval(id) {
            return handler(.success(user))
        }

        networking(.user(id: id)) { [cacheInsertion] result in
            do {
                let data = try result.get()
                let user = try JSONDecoder().decode(User.self, from: data)
                cacheInsertion(user)
                handler(.success(user))
            } catch {
                handler(.failure(error))
            }
        }
    }
}

With just that little tweak in place, our UserLoader has now been transformed from being really difficult to test, to being fully testable. All that we now have to do to write the test that we initially wanted to (for verifying our caching logic) is to replace our real networking code with a local closure within our test — for example like this:

class UserLoaderTests: XCTestCase {
    func testUserCaching() {
        let user = User(id: UUID(), name: "John")
        var loader = UserLoader()
        var networkCallCount = 0

        // Mock our networking using a closure
        loader.networking = { endpoint, handler in
            networkCallCount += 1

            handler(Result {
                try JSONEncoder().encode(user)
            })
        }

        // Call our load method twice and capture both results:
        var results = [User?]()
        loader.loadUser(withID: user.id) { results.append(try? $0.get()) }
        loader.loadUser(withID: user.id) { results.append(try? $0.get()) }

        XCTAssertEqual(networkCallCount, 1, """
        Only one network call should have been made,
        as the first one should have been cached.
        """)

        XCTAssertEqual(results, [user, user], """
        The same user should have been loaded both times.
        """)
    }
}

To learn more about the above approach to writing tests, check out “Mock-free unit tests in Swift” and “Using test assertion messages as comments”.

As an added bonus, if we wanted to make sure that our dependencies are only ever overridden within our tests, and other types of debug builds — then we could bring in the property wrapper from “Making properties overridable only in debug builds” and annotate each of our functional properties as @DebugOverridable:

struct UserLoader {
    @DebugOverridable
    var networking = NetworkManager.shared.loadData
    @DebugOverridable
    var cacheInsertion = UserCache.shared.insert
    @DebugOverridable
    var cacheRetrieval = UserCache.shared.user
    
    ...
}

Now, is the above technique a “silver bullet” that should always be used to manage singletons? Of course not. Creating proper abstractions and using dependency injection to keep track of our various dependencies is often the way to go for larger objects and dependency graphs — but if we only want to quickly make a given singleton-reliant type testable, then the above technique can be great to keep in mind.