Articles, podcasts and news about Swift development, by John Sundell.

Lightweight dependency injection and unit testing using async functions

Published on 09 Dec 2021
Discover page available: Concurrency

Very often, making code easy to unit test tends to go hand-in-hand with improving that code’s separation of concerns, its state management, and its overall architecture. In general, the more well-abstracted and organized our code is, the easier it tends to be to test it in an automated fashion.

However, in an effort to make code more testable, we can very often find ourselves introducing a ton of new protocols and other kinds of abstractions, and end up making our code significantly more complicated in the process — especially when testing asynchronous code that relies on some form of networking.

But does it really have to be that way? What if we could actually make our code fully testable in a way that doesn’t require us to introduce any new protocols, mocking types, or complicated abstractions? Let’s explore how we could make use of Swift’s new async/await capabilities to make that happen.

Essential Developer

Essential Developer: If you’re a mid/senior iOS developer who’s looking to improve both your skills and your salary level, then join the iOS Architect Crash Course, starting on January 31st. It’s 100% free and held entirely online. Click to learn more.

Injected networking

Let’s say that we’re working on an app that includes the following ProductViewModel, which uses the very common pattern of getting its URLSession (that it’ll use to perform network calls) injected through its initializer:

class ProductViewModel {
    var title: String { product.name }
    var detailText: String { product.description }
    var price: Price { product.price(in: localUser.currency) }
    ...

    private var product: Product
    private let localUser: User
    private let urlSession: URLSession

    init(product: Product, localUser: User, urlSession: URLSession = .shared) {
        self.product = product
        self.localUser = localUser
        self.urlSession = urlSession
    }

    func reload() async throws {
        let url = URL.forLoadingProduct(withID: product.id)
        let (data, _) = try await urlSession.data(from: url)
        let decoder = JSONDecoder()
        product = try decoder.decode(Product.self, from: data)
    }
}

Now, there’s really nothing wrong with the above code. It works, and it uses dependency injection to avoid accessing URLSession.shared directly as a singleton (which already has huge benefits in terms of testing and overall architecture), even though it does use that shared instance by default, for convenience.

However, it could definitely be argued that inlining raw network calls within types like view models and view controllers is something that ideally should be avoided — as that’d create a better separation of concerns within our project, and would let us reuse that network code whenever we need to perform a similar request elsewhere.

So, to continue iterating on the above example, let’s extract our view model’s product loading code into a dedicated ProductLoader type instead:

class ProductLoader {
    private let urlSession: URLSession

    init(urlSession: URLSession = .shared) {
        self.urlSession = urlSession
    }

    func loadProduct(withID id: Product.ID) async throws -> Product {
        let url = URL.forLoadingProduct(withID: id)
        let (data, _) = try await urlSession.data(from: url)
        let decoder = JSONDecoder()
        return try decoder.decode(Product.self, from: data)
    }
}

If we then make our view model use that new ProductLoader, rather than interacting with URLSession directly, then we can simplify its implementation quite significantly — as it can now simply call loadProduct whenever it’s asked to reload its underlying data model:

class ProductViewModel {
    ...
    private var product: Product
    private let localUser: User
    private let loader: ProductLoader

    init(product: Product, localUser: User, loader: ProductLoader) {
        self.product = product
        self.localUser = localUser
        self.loader = loader
    }

    func reload() async throws {
        product = try await loader.loadProduct(withID: product.id)
    }
}

So that’s already quite an improvement. But what if we now wanted to implement a few unit tests to ensure that our view model behaves as we’d expect? To do that, we’re going to need to mock our app’s networking one way or another, as we definitely don’t want to perform any real network calls within our unit tests (as that could add delays, flakiness, and require us to always be online while working on our code base).

Protocol-based mocking

One way to setup that kind of mocking would be to create a protocol-based Networking abstraction, which essentially just requires us to duplicate the signature of the URLSession.data method within that protocol, and to then make URLSession conform to our new protocol through an extension — like this:

protocol Networking {
    func data(
    from url: URL,
    delegate: URLSessionTaskDelegate?
) async throws -> (Data, URLResponse)
}

extension Networking {
    // If we want to avoid having to always pass 'delegate: nil'
    // at call sites where we're not interested in using a delegate,
    // we also have to add the following convenience API (which
    // URLSession itself provides when using it directly):
    func data(from url: URL) async throws -> (Data, URLResponse) {
        try await data(from: url, delegate: nil)
    }
}

extension URLSession: Networking {}

With the above in place, we can now make our ProductLoader accept any object that conforms to our new Networking protocol, rather than always using a concrete URLSession instance (we’ll still default to URLSession.shared for convenience):

class ProductLoader {
    private let networking: Networking

    init(networking: Networking = URLSession.shared) {
        self.networking = networking
    }

    func loadProduct(withID id: Product.ID) async throws -> Product {
        let url = URL.forLoadingProduct(withID: id)
        let (data, _) = try await networking.data(from: url)
        let decoder = JSONDecoder()
        return try decoder.decode(Product.self, from: data)
    }
}

With all of that preparation work done, we can now finally start writing our tests. To do that, we’ll start by creating a mocked implementation of our Networking protocol, and we’ll then set up a ProductLoader and ProductViewModel that uses that mocked implementation when performing all network calls — which in turn enables us to write our tests like this:

class NetworkingMock: Networking {
    var result = Result<Data, Error>.success(Data())

    func data(
        from url: URL,
        delegate: URLSessionTaskDelegate?
    ) async throws -> (Data, URLResponse) {
        try (result.get(), URLResponse())
    }
}

class ProductViewModelTests: XCTestCase {
    private var product: Product!
    private var networking: NetworkingMock!
    private var viewModel: ProductViewModel!

    override func setUp() {
        super.setUp()

        product = .stub()
        networking = NetworkingMock()
        viewModel = ProductViewModel(
            product: product,
            localUser: .stub(),
            loader: ProductLoader(networking: networking)
        )
    }

    func testReloadingProductUpdatesTitle() async throws {
        product.name = "Reloaded product"
        networking.result = try .success(JSONEncoder().encode(product))
        XCTAssertNotEqual(viewModel.title, product.name)

        try await viewModel.reload()
        XCTAssertEqual(viewModel.title, product.name)
    }
    
    ...
}

To learn more about the .stub() method that’s called above in order to generate stubbed versions of our data models, check out “Defining testing data in Swift”.

Alright! We have now successfully refactored all of our ProductViewModel-related code to become fully testable, and we’ve started covering it with unit tests. Very nice.

But, if we take a closer look at the above test case, we can see that our ProductLoader isn’t really participating much in our testing code at all. That’s because we’re really only interested in mocking our actual networking code in this case, since that’s the part of our code that would be problematic to run in a testing context.

Now, it could definitely be argued that we also should’ve added an additional protocol and mocking layer for ProductLoader as well — which would let us mock it directly, rather than using its real, actual implementation with a mocked networking instance. You could even argue that the above unit test isn’t actually a unit test at all — that it instead is an integration test, as it integrates multiple units (our view model, product loader, and networking) when performing verifications.

However, if we were to take that unit-testing-by-the-book route and introduce yet another protocol and mocking type, then we could quickly end up going down a slippery slope where every single object in our code base also has an associated protocol and mocking type, which would lead to a ton of code duplication and added complexity (even when using code generation tools to automatically generate those types).

But perhaps there’s a way that we could have our cake and eat it too? Let’s see if we could actually make our above test case just verify our ProductViewModel as a single unit, while also getting rid of those mocks and test-specific protocols in the process.

Adding a bit of functional programming

If we stop thinking about our product loading code in terms of object-oriented constructs like classes and protocols, and instead look at it from a more functional perspective, then we could actually model our view model’s loading code using the following function signature:

typealias Loading<T> = () async throws -> T

That is, a function that loads some form of value in an asynchronous manner, and either returns that value, or throws an error.

Next, let’s change our ProductViewModel once more, to now accept some function that matches the above signature (specialized using our Product model), rather than accepting a ProductLoader instance directly:

class ProductViewModel {
    ...

    private var product: Product
    private let localUser: User
    private let reloading: Loading<Product>

    init(product: Product,
         localUser: User,
         reloading: @escaping Loading<Product>) {
        self.product = product
        self.localUser = localUser
        self.reloading = reloading
    }

    func reload() async throws {
        product = try await reloading()
    }
}

One thing that’s very neat about the above pattern is that it still lets us keep using our existing Networking and ProductLoader code just like before — all that we have to do is to call that code within the reloading function/closure that we pass into our ProductViewModel when creating it:

func makeProductViewModel(
    for product: Product,
    localUser: User,
    networking: Networking
) -> ProductViewModel {
    let loader = ProductLoader(networking: networking)

    return ProductViewModel(
        product: product,
        localUser: localUser,
        reloading: {
    try await loader.loadProduct(withID: product.id)
}
    )
}

If you’ve been reading Swift by Sundell for a while, then you might recognize the above pattern from 2019’s “Functional networking in Swift” article, which used Futures and Promises to achieve a similar result.

But here’s where things get really interesting. Now, when unit testing our ProductViewModel, we no longer have to worry about mocking our networking, or even create a ProductLoader instance — all that we have to do is to inject an inline closure that returns a specific Product value, which we can then mutate whenever we want to change our reloading response in any way:

class ProductViewModelTests: XCTestCase {
    private var product: Product!
    private var viewModel: ProductViewModel!

    override func setUp() {
        super.setUp()

        product = .stub()
        viewModel = ProductViewModel(
            product: product,
            localUser: .stub(),
            reloading: { [unowned self] in self.product }
        )
    }

    func testReloadingProductUpdatesTitle() async throws {
        product.name = "Reloaded product"
        XCTAssertNotEqual(viewModel.title, product.name)

        try await viewModel.reload()
        XCTAssertEqual(viewModel.title, product.name)
    }
    
    ...
}

Note how there’s no longer any protocols or mocking types involved in our entire test case! Since we’ve now completely separated our ProductViewModel from our networking code, we can unit test that class in complete isolation — since, as far as it’s concerned, it just gets access to a closure that loads a Product value from somewhere.

Scaling things up

But now the big question becomes — how does this pattern scale if we need to perform multiple kinds of loading operations within a given type? To explore that, let’s start by introducing a second kind of async function signature, which’ll let us perform an action using a given value:

typealias AsyncAction<T> = (T) async throws -> Void

Then, let’s say that we wanted to extend our ProductViewModel with support for marking a given product as a favorite, and to also be able to add that product to a user-defined list. To make that happen, we could inject those two new pieces of functionality as separate closures — like this:

class ProductViewModel {
    ...
    private let reloading: Loading<Product>
    private let favoriteToggling: Loading<Product>
private let listAdding: AsyncAction<List.ID>

    init(product: Product,
         localUser: User,
         reloading: @escaping Loading<Product>,
         favoriteToggling: @escaping Loading<Product>,
         listAdding: @escaping AsyncAction<List.ID>) {
        self.product = product
        self.localUser = localUser
        self.reloading = reloading
        self.favoriteToggling = favoriteToggling
self.listAdding = listAdding
    }

    func reload() async throws {
        product = try await reloading()
    }

    func toggleProductFavoriteStatus() async throws {
        product = try await favoriteToggling()
    }

    func addProductToList(withID listID: List.ID) async throws {
        try await listAdding(listID)
    }
}

The above still works fine, but our implementation is arguably starting to become a bit messy, since we now have to juggle multiple closures when initializing our view model.

So let’s take some inspiration from “Extracting view controller actions in Swift”, and group the above three closures into an Actions struct, which will give us a bit of added structure (no pun intended) when both implementing and initializing our ProductViewModel:

class ProductViewModel {
    ...
    private let actions: Actions

    init(product: Product, localUser: User, actions: Actions) {
        self.product = product
        self.localUser = localUser
        self.actions = actions
    }

    func reload() async throws {
        product = try await actions.reload()
    }

    func toggleProductFavoriteStatus() async throws {
        product = try await actions.toggleFavorite()
    }

    func addProductToList(withID listID: List.ID) async throws {
        try await actions.addToList(listID)
    }
}

extension ProductViewModel {
    struct Actions {
    var reload: Loading<Product>
    var toggleFavorite: Loading<Product>
    var addToList: AsyncAction<List.ID>
}
}

func makeProductViewModel(
    for product: Product,
    localUser: User,
    networking: Networking,
    listManager: ListManager
) -> ProductViewModel {
    let loader = ProductLoader(networking: networking)

    return ProductViewModel(
        product: product,
        localUser: localUser,
        actions: ProductViewModel.Actions(
            reload: {
                try await loader.loadProduct(withID: product.id)
            },
            toggleFavorite: {
                try await loader.toggleFavoriteStatusForProduct(
                    withID: product.id
                )
            },
            addToList: { listID in
                try await listManager.addProduct(
                    withID: product.id,
                    toListWithID: listID
                )
            }
        )
    )
}

With the above change in place, we can still mock all of the above three actions using simple closures within our tests, while now also making it easy to manage those actions, especially if we keep adding new ones in the future. Of course, the above pattern probably wouldn’t scale that well to types that have 10, 15, 20 actions — but at that point, it’s probably also worth asking if perhaps that type has too many responsibilities to begin with.

However, one fair piece of criticism against the above pattern would be that it does end up pushing some of the internal implementation details of our ProductViewModel to the call sites that create instances of it. For example, our makeProductViewModel function now has to know exactly what logic that it’s supposed to place within each of our view model’s Action closures.

One way to address that problem would be to provide default implementations of those closures using the underlying objects that our production code should ideally use — which could be done using an extension that can be placed within the same file as our ProductViewModel itself:

extension ProductViewModel.Actions {
    init(productID: Product.ID,
         loader: ProductLoader,
         listManager: ListManager) {
        reload = {
            try await loader.loadProduct(withID: productID)
        }
        toggleFavorite = {
            try await loader.toggleFavoriteStatusForProduct(
                withID: productID
            )
        }
        addToList = {
            try await listManager.addProduct(
                withID: productID,
                toListWithID: $0
            )
        }
    }
}

With that final tweak in place, our makeProductViewModel can now simply inject our view model’s dependencies, more or less exactly like how things were done when using our earlier, protocol-based setup:

func makeProductViewModel(
    for product: Product,
    localUser: User,
    networking: Networking,
    listManager: ListManager
) -> ProductViewModel {
    ProductViewModel(
        product: product,
        localUser: localUser,
        actions: ProductViewModel.Actions(
    productID: product.id,
    loader: ProductLoader(networking: networking),
    listManager: listManager
)
    )
}

With that approach, we’ve arguably struck a quite nice balance between being able to unit test our view model through a very lightweight set of abstractions, while also not leaking any implementation details to whichever call site that’ll initialize that view model within our production code.

Support Swift by Sundell by checking out this sponsor:

Essential Developer

Essential Developer: If you’re a mid/senior iOS developer who’s looking to improve both your skills and your salary level, then join the iOS Architect Crash Course, starting on January 31st. It’s 100% free and held entirely online. Click to learn more.

Conclusion

While there’s probably no such thing as a perfect dependency injection setup — by experimenting with different techniques, we can often arrive at an architecture that strikes a neat balance between how our code base is organized, its testing needs, and the personal preferences of the developers working on it.

I hope you found this article interesting and useful, and although I’m not saying that anyone should replace all of their protocols with the above kind of functional setup, I think it’s an approach that’s at least worth exploring — especially now that we have the power of async/await at our disposal.

For more Swift dependency injection techniques, check out this category page, and if you have any questions, comments, or feedback, then feel free to reach out via email.

Thanks for reading!